@@ -0,0 +1,350 @@
'use client' ;
// 埋点 / 上报成功率大盘:度量客户端埋点数据可信度,两段漏斗两个大率。
// 数据源 analytics_selfstat 快照差分(与埋点日志表无关);三个只读端点在 admin 服务(8771)。
// 时区:端点吃 UTC 时刻(见 toUtcRange);trend.day 已是北京日,直接展示不再换算。
import { useCallback , useEffect , useMemo , useState } from 'react' ;
import dynamic from 'next/dynamic' ;
import type { ColumnsType } from 'antd/es/table' ;
import {
App ,
Button ,
Card ,
Col ,
DatePicker ,
Empty ,
Row ,
Segmented ,
Space ,
Statistic ,
Table ,
Tag ,
Tooltip ,
Typography ,
} from 'antd' ;
import { InfoCircleOutlined , ReloadOutlined } from '@ant-design/icons' ;
import dayjs , { type Dayjs } from 'dayjs' ;
import utc from 'dayjs/plugin/utc' ;
import timezone from 'dayjs/plugin/timezone' ;
import { api , errMsg } from '@/lib/api' ;
import type { HealthBreakdownRow , HealthMetrics , HealthTrendPoint } from '@/lib/types' ;
dayjs . extend ( utc ) ;
dayjs . extend ( timezone ) ;
const CN = 'Asia/Shanghai' ;
// @ant-design/plots 依赖 DOM,关掉 SSR 仅客户端渲染(否则 Next 预渲染报 document undefined)。
// 用仓内 v2 写法(colorField/scale/axis/interaction),非指南里的 v1(seriesField/yAxis)。
const Line = dynamic ( ( ) = > import ( '@ant-design/plots' ) . then ( ( m ) = > m . Line ) , { ssr : false } ) ;
const { RangePicker } = DatePicker ;
type Dim = 'event' | 'app_ver' | 'oem' ;
const DIM_LABEL : Record < Dim , string > = { event : '事件' , app_ver : '版本' , oem : 'ROM' } ;
const LOW = 0.9 ; // 成功率低于此阈值标红(定位掉数据最狠的点位/版本/ROM)
// 北京日范围 → UTC ISO 瞬时(右开区间;date_to = 结束日 +1 天的北京零点)。
// 端点按 UTC 时刻比较 created_at,直接传 YYYY-MM-DD 会被当 UTC 零点,与北京日差 8 小时。
function toUtcRange ( start : Dayjs , end : Dayjs ) {
return {
date_from : dayjs.tz ( start . format ( 'YYYY-MM-DD' ) , CN ) . utc ( ) . toISOString ( ) ,
date_to : dayjs.tz ( end . add ( 1 , 'day' ) . format ( 'YYYY-MM-DD' ) , CN ) . utc ( ) . toISOString ( ) ,
} ;
}
// 率:null → "-"(分母为 0 ≠ 0%);否则百分比一位小数。
const pct = ( v : number | null | undefined ) = > ( v == null ? '-' : ` ${ ( v * 100 ) . toFixed ( 1 ) } % ` ) ;
const int = ( v : number | null | undefined ) = >
v == null ? '-' : new Intl . NumberFormat ( 'zh-CN' ) . format ( v ) ;
// 四原子计数说明(总览卡标题 + 下钻表头 tooltip 共用,一处改两处同步)。
const METRIC_TIP = {
attempted : '端上 track() 被调用的次数,是埋点段分母。埋点成功率 =(attempted − drop_capture)/ attempted。' ,
drop_capture : '采集环节因异常(队列满 / 序列化失败等)被丢弃、未能落地的事件数,计入埋点失败。' ,
delivered : '成功送达并入库后端的事件数。上报成功率 = delivered /(delivered + drop_undelivered)。' ,
drop_undelivered :
'已落盘但久未送达、被队列淘汰的事件数(弱网 / 被 ROM 杀后台等);在途未送达的不计入。' ,
} as const ;
// 标题/表头后跟一个说明图标(hover 出 tooltip)。
const withTip = ( label : string , tip : string ) = > (
< Space size = { 4 } >
{ label }
< Tooltip title = { tip } >
< InfoCircleOutlined style = { { color : '#bfbfbf' , cursor : 'help' } } / >
< / Tooltip >
< / Space >
) ;
// 大率卡:率偏低标红 + 「偏低」标。
function RateStat ( {
title ,
value ,
loading ,
} : {
title : string ;
value : number | null | undefined ;
loading : boolean ;
} ) {
const low = value != null && value < LOW ;
return (
< Card loading = { loading } >
< Statistic
title = {
< Space size = { 6 } >
{ title }
{ low && < Tag color = "error" > 偏 低 < / Tag > }
< / Space >
}
value = { value == null ? '-' : ( value * 100 ) . toFixed ( 1 ) }
suffix = { value == null ? undefined : '%' }
valueStyle = { low ? { color : '#cf1322' } : undefined }
/ >
< / Card >
) ;
}
// 原子量卡(attempted / drop_capture / delivered / drop_undelivered)。
function CountStat ( {
title ,
value ,
loading ,
warn ,
tip ,
} : {
title : string ;
value : number | null | undefined ;
loading : boolean ;
warn? : boolean ; // drop_* > 0 时橙色提示
tip? : string ;
} ) {
const bad = warn && value != null && value > 0 ;
return (
< Card loading = { loading } >
< Statistic
title = { tip ? withTip ( title , tip ) : title }
value = { value == null ? '-' : value }
valueStyle = { bad ? { color : '#d46b08' } : undefined }
/ >
< / Card >
) ;
}
export default function AnalyticsHealthPage() {
const { message } = App . useApp ( ) ;
const [ range , setRange ] = useState < [ Dayjs , Dayjs ] > ( [
dayjs ( ) . tz ( CN ) . subtract ( 6 , 'day' ) ,
dayjs ( ) . tz ( CN ) ,
] ) ;
const [ dim , setDim ] = useState < Dim > ( 'event' ) ;
const [ overview , setOverview ] = useState < HealthMetrics | null > ( null ) ;
const [ trend , setTrend ] = useState < HealthTrendPoint [ ] > ( [ ] ) ;
const [ rows , setRows ] = useState < HealthBreakdownRow [ ] > ( [ ] ) ;
const [ loading , setLoading ] = useState ( false ) ; // 总览 + 趋势
const [ rowsLoading , setRowsLoading ] = useState ( false ) ; // 下钻表
const [ updatedAt , setUpdatedAt ] = useState < string | null > ( null ) ;
// 总览 + 趋势:只随区间变。
const loadMain = useCallback ( async ( ) = > {
const params = toUtcRange ( range [ 0 ] , range [ 1 ] ) ;
setLoading ( true ) ;
try {
const [ ov , tr ] = await Promise . all ( [
api . get < HealthMetrics > ( '/admin/api/analytics-health/overview' , { params } ) ,
api . get < HealthTrendPoint [ ] > ( '/admin/api/analytics-health/trend' , { params } ) ,
] ) ;
setOverview ( ov . data ) ;
setTrend ( tr . data ) ;
setUpdatedAt ( dayjs ( ) . tz ( CN ) . format ( 'YYYY-MM-DD HH:mm' ) ) ;
} catch ( e ) {
message . error ( errMsg ( e , '加载成功率数据失败' ) ) ;
} finally {
setLoading ( false ) ;
}
} , [ range , message ] ) ;
// 下钻表:随区间或维度变(切 dim 只重拉表,不动总览/趋势)。
const loadRows = useCallback ( async ( ) = > {
const params = toUtcRange ( range [ 0 ] , range [ 1 ] ) ;
setRowsLoading ( true ) ;
try {
const bd = await api . get < HealthBreakdownRow [ ] > ( '/admin/api/analytics-health/breakdown' , {
params : { . . . params , dim } ,
} ) ;
setRows ( bd . data ) ;
} catch ( e ) {
message . error ( errMsg ( e , '加载下钻数据失败' ) ) ;
} finally {
setRowsLoading ( false ) ;
}
} , [ range , dim , message ] ) ;
useEffect ( ( ) = > {
void loadMain ( ) ;
} , [ loadMain ] ) ;
useEffect ( ( ) = > {
void loadRows ( ) ;
} , [ loadRows ] ) ;
const refresh = ( ) = > {
void loadMain ( ) ;
void loadRows ( ) ;
} ;
// 折线长表:每天 ×{埋点率, 上报率}。保留 rate=null 的点(不 filter)→ G2 在断点处断线,
// 真正实现「null 点跳过不连线」;若 filter 掉 null 会把两侧点连成一条误导直线。
const trendLong = useMemo (
( ) = >
trend . flatMap ( ( p ) = > [
{ day : p.day , metric : '埋点成功率' , rate : p.track_success_rate } ,
{ day : p.day , metric : '上报成功率' , rate : p.report_success_rate } ,
] ) ,
[ trend ] ,
) ;
const chartConfig = {
data : trendLong ,
xField : 'day' ,
yField : 'rate' ,
colorField : 'metric' ,
height : 300 ,
scale : {
y : { domainMin : 0 , domainMax : 1 , nice : false } ,
color : { range : [ '#1677ff' , '#fa8c16' ] } , // 埋点率蓝 / 上报率橙(按长表首次出现序)
} ,
axis : { y : { labelFormatter : ( v : number ) = > ` ${ ( v * 100 ) . toFixed ( 0 ) } % ` } } ,
point : { style : { r : 3 } } ,
legend : { color : { position : 'top' as const } } ,
tooltip : { items : [ { channel : 'y' as const , valueFormatter : ( v : number ) = > pct ( v ) } ] } ,
interaction : { tooltip : { shared : true } } ,
} ;
const columns : ColumnsType < HealthBreakdownRow > = [
{ title : DIM_LABEL [ dim ] , dataIndex : 'key' , key : 'key' , render : ( v : string ) = > v || '(unknown)' } ,
{
title : '埋点成功率' ,
key : 'track' ,
width : 110 ,
align : 'right' ,
render : ( _ : unknown , r : HealthBreakdownRow ) = > (
< span style = { r . track_success_rate != null && r . track_success_rate < LOW ? { color : '#cf1322' , fontWeight : 600 } : undefined } >
{ pct ( r . track_success_rate ) }
< / span >
) ,
} ,
{
title : '上报成功率' ,
key : 'report' ,
width : 110 ,
align : 'right' ,
render : ( _ : unknown , r : HealthBreakdownRow ) = > (
< span style = { r . report_success_rate != null && r . report_success_rate < LOW ? { color : '#cf1322' , fontWeight : 600 } : undefined } >
{ pct ( r . report_success_rate ) }
< / span >
) ,
} ,
{ title : withTip ( '尝试总数' , METRIC_TIP . attempted ) , dataIndex : 'attempted' , key : 'attempted' , width : 120 , align : 'right' , render : ( v : number ) = > int ( v ) } ,
{ title : withTip ( '采集丢弃' , METRIC_TIP . drop_capture ) , dataIndex : 'drop_capture' , key : 'drop_capture' , width : 128 , align : 'right' , render : ( v : number ) = > int ( v ) } ,
{ title : withTip ( '送达' , METRIC_TIP . delivered ) , dataIndex : 'delivered' , key : 'delivered' , width : 108 , align : 'right' , render : ( v : number ) = > int ( v ) } ,
{ title : withTip ( '淘汰未送达' , METRIC_TIP . drop_undelivered ) , dataIndex : 'drop_undelivered' , key : 'drop_undelivered' , width : 150 , align : 'right' , render : ( v : number ) = > int ( v ) } ,
] ;
return (
< div >
< div style = { { display : 'flex' , alignItems : 'center' , justifyContent : 'space-between' , flexWrap : 'wrap' , gap : 12 , marginBottom : 16 } } >
< Space align = "center" >
< h2 style = { { margin : 0 } } > 埋 点 成 功 率 < / h2 >
< Typography.Text type = "secondary" style = { { fontSize : 12 } } >
端 上 埋 点 / 上 报 可 信 度 ; 数 据 分 钟 级 延 迟 , 非 实 时
< / Typography.Text >
< / Space >
< Space align = "center" >
{ updatedAt && (
< Typography.Text type = "secondary" style = { { fontSize : 12 } } >
数 据 更 新 于 { updatedAt }
< / Typography.Text >
) }
< Button icon = { < ReloadOutlined / > } onClick = { refresh } loading = { loading || rowsLoading } >
刷 新
< / Button >
< / Space >
< / div >
< Card size = "small" style = { { marginBottom : 16 } } >
< Space size = { 6 } align = "center" >
< Typography.Text type = "secondary" > 日 期 ( 北 京 ) < / Typography.Text >
< RangePicker
value = { range }
onChange = { ( v ) = > v && v [ 0 ] && v [ 1 ] && setRange ( [ v [ 0 ] , v [ 1 ] ] ) }
allowClear = { false }
presets = { [
{ label : '今天' , value : [ dayjs ( ) . tz ( CN ) , dayjs ( ) . tz ( CN ) ] } ,
{ label : '近 7 天' , value : [ dayjs ( ) . tz ( CN ) . subtract ( 6 , 'day' ) , dayjs ( ) . tz ( CN ) ] } ,
{ label : '近 30 天' , value : [ dayjs ( ) . tz ( CN ) . subtract ( 29 , 'day' ) , dayjs ( ) . tz ( CN ) ] } ,
] }
/ >
< / Space >
< / Card >
{ /* 总览:两大率居前,四原子量次之 */ }
< Row gutter = { [ 16 , 16 ] } style = { { marginBottom : 16 } } >
< Col xs = { 24 } sm = { 12 } >
< RateStat title = "埋点成功率" value = { overview ? . track_success_rate } loading = { loading } / >
< / Col >
< Col xs = { 24 } sm = { 12 } >
< RateStat title = "上报成功率" value = { overview ? . report_success_rate } loading = { loading } / >
< / Col >
< / Row >
< Row gutter = { [ 16 , 16 ] } style = { { marginBottom : 16 } } >
< Col xs = { 12 } sm = { 6 } >
< CountStat title = "尝试总数 attempted" value = { overview ? . attempted } loading = { loading } tip = { METRIC_TIP . attempted } / >
< / Col >
< Col xs = { 12 } sm = { 6 } >
< CountStat title = "采集丢弃 drop_capture" value = { overview ? . drop_capture } loading = { loading } warn tip = { METRIC_TIP . drop_capture } / >
< / Col >
< Col xs = { 12 } sm = { 6 } >
< CountStat title = "送达 delivered" value = { overview ? . delivered } loading = { loading } tip = { METRIC_TIP . delivered } / >
< / Col >
< Col xs = { 12 } sm = { 6 } >
< CountStat title = "淘汰未送达 drop_undelivered" value = { overview ? . drop_undelivered } loading = { loading } warn tip = { METRIC_TIP . drop_undelivered } / >
< / Col >
< / Row >
{ /* 趋势:两条率折线,Y 轴锁 [0,1] 显示百分比,null 断线 */ }
< Card title = "按天趋势(北京时间)" size = "small" style = { { marginBottom : 16 } } >
{ trendLong . length > 0 ? (
< Line { ...chartConfig } / >
) : (
< Empty style = { { padding : '60px 0' } } description = { loading ? '加载中…' : '该区间暂无埋点数据' } / >
) }
< / Card >
{ /* 下钻:后端已按上报率升序(最差在前),率 < 90% 标红 */ }
< Card
title = "维度下钻(按上报成功率升序,最差在前)"
size = "small"
extra = {
< Segmented
value = { dim }
onChange = { ( v ) = > setDim ( v as Dim ) }
options = { [
{ label : '按事件' , value : 'event' } ,
{ label : '按版本' , value : 'app_ver' } ,
{ label : '按 ROM' , value : 'oem' } ,
] }
/ >
}
>
< Table < HealthBreakdownRow >
rowKey = "key"
dataSource = { rows }
columns = { columns }
loading = { rowsLoading }
pagination = { false }
size = "small"
scroll = { { x : 820 } }
locale = { { emptyText : rowsLoading ? '加载中…' : '该区间暂无数据' } }
/ >
< / Card >
< / div >
) ;
}