@@ -0,0 +1,104 @@
import { useEffect , useRef , useState } from 'react' ;
import { Alert , Badge , Button , Field , Input , Select } from '../components/ui.jsx' ;
import { PageTitle , Panel , Toolbar , SimpleTable , Drawer , Pagination } from '../components/layout.jsx' ;
import { api , explainApiError } from '../api.js' ;
import { formatDateTime } from '../utils/formatters.js' ;
const percent = v => v === null || v === undefined ? '—' : ` ${ Number ( v ) . toFixed ( 2 ) } % ` ;
const rateColumns = [
{ key : 'connectionRate' , label : '实时接通率' , render : r => percent ( r . connectionRate ) } ,
{ key : 'overallAnswerRate' , label : '实时总体应答率' , render : r => percent ( r . overallAnswerRate ) } ,
{ key : 'connectedAnswerRate' , label : '实时已接通应答率' , render : r => percent ( r . connectedAnswerRate ) }
] ;
const countColumns = [ { key : 'totalCalls' , label : '总呼叫数' } , { key : 'connectedCalls' , label : '接通数' } , { key : 'notConnectedCalls' , label : '未接通数' } , { key : 'answeredCalls' , label : '应答数' } ] ;
const initial = { view : 'landing' , minutes : '15' , caller : '' , customerId : '' , customerGatewayId : '' , vendorGatewayId : '' , city : '' , carrier : '' , minSamples : '0' , sort : 'totalCalls' , order : 'desc' , skip : 0 , take : 25 , onlyAlerts : false } ;
export function CallerAnalyticsPage ( ) {
const [ draft , setDraft ] = useState ( initial ) ; const [ filters , setFilters ] = useState ( initial ) ;
const [ data , setData ] = useState ( null ) ; const [ error , setError ] = useState ( '' ) ; const [ loading , setLoading ] = useState ( false ) ;
const [ options , setOptions ] = useState ( { customers : [ ] , customerGateways : [ ] , vendorGateways : [ ] } ) ;
const [ auto , setAuto ] = useState ( true ) ; const [ tick , setTick ] = useState ( 0 ) ;
const [ target , setTarget ] = useState ( null ) ; const [ detail , setDetail ] = useState ( null ) ; const [ detailError , setDetailError ] = useState ( '' ) ;
const [ detailSkip , setDetailSkip ] = useState ( 0 ) ; const sequence = useRef ( 0 ) ;
useEffect ( ( ) => { const c = new AbortController ( ) ; api . callerAnalyticsOptions ( { signal : c . signal } ) . then ( setOptions ) . catch ( e => { if ( ! c . signal . aborted ) setError ( explainApiError ( e ) ) ; } ) ; return ( ) => c . abort ( ) ; } , [ ] ) ;
useEffect ( ( ) => {
const controller = new AbortController ( ) ; const id = ++ sequence . current ;
let timer ;
const refresh = async ( ) => {
setLoading ( true ) ;
try {
const q = { ... filters , onlyAlerts : String ( filters . onlyAlerts ) } ;
if ( q . minutes === 'today' ) { const now = new Date ( ) ; q . from = new Date ( now . toLocaleDateString ( 'en-CA' , { timeZone : 'Asia/Shanghai' } ) + 'T00:00:00+08:00' ) . toISOString ( ) ; delete q . minutes ; }
if ( q . minutes === 'custom' ) { q . from = new Date ( q . from ) . toISOString ( ) ; q . to = new Date ( q . to ) . toISOString ( ) ; delete q . minutes ; }
const result = await api . callerAnalytics ( q , { signal : controller . signal } ) ;
if ( id === sequence . current ) { setData ( result ) ; setError ( '' ) ; }
} catch ( e ) { if ( ! controller . signal . aborted && id === sequence . current ) setError ( explainApiError ( e ) ) ; }
finally { if ( ! controller . signal . aborted && id === sequence . current ) { setLoading ( false ) ; if ( auto ) timer = setTimeout ( refresh , 4000 ) ; } }
} ;
void refresh ( ) ; return ( ) => { controller . abort ( ) ; clearTimeout ( timer ) ; } ;
} , [ filters , auto , tick ] ) ;
useEffect ( ( ) => {
if ( ! target || ! data ) return undefined ;
const c = new AbortController ( ) ;
api . callerAnalyticsCalls ( { ... filters , from : data . from , to : data . to , caller : target . caller , customerId : target . customerId , skip : detailSkip , take : 25 } , { signal : c . signal } )
. then ( r => { setDetail ( r ) ; setDetailError ( '' ) ; } ) . catch ( e => { if ( ! c . signal . aborted ) setDetailError ( explainApiError ( e ) ) ; } ) ;
return ( ) => c . abort ( ) ;
} , [ target , data , filters , detailSkip ] ) ;
const change = ( key , value ) => setDraft ( d => ( { ... d , [ key ] : value , ... ( key === 'view' && value === 'original' ? { vendorGatewayId : '' } : { } ) } ) ) ;
const names = Object . fromEntries ( options . customers . map ( c => [ c . id , c . name ] ) ) ;
const gatewayNames = Object . fromEntries ( options . vendorGateways . map ( c => [ c . id , c . name ] ) ) ;
const select = ( label , key , choices ) => < Field label = { label } > < Select value = { draft [ key ] } onChange = { e => change ( key , e . target . value ) } > < option value = "" > 全部 < / option > { choices . map ( c => < option key = { c . id } value = { c . id } > { c . name } < / option > ) } < / Select > < / Field > ;
const openDetail = row => { setTarget ( row ) ; setDetail ( null ) ; setDetailSkip ( 0 ) ; setDetailError ( '' ) ; } ;
const metrics = data ? . summary ;
const timeline = data ? . trends ? ? [ ] ;
const max = Math . max ( 1 , ... timeline . map ( r => r . totalCalls ) ) ;
return < >
< PageTitle title = "主叫号码分析" desc = "接通:180 / 183 或后续阶段;应答:实际通话时长大于 0。" actions = { < > < Button variant = "outline" onClick = { ( ) => setAuto ( v => ! v ) } > { auto ? '自动刷新中' : '已暂停刷新' } < / Button > < Button disabled = { loading } onClick = { ( ) => setTick ( v => v + 1 ) } > { loading ? '更新中…' : '刷新' } < / Button > < / > } / >
{ error ? < Alert tone = "danger" title = "更新失败,保留上次数据" > { error } < / Alert > : null }
< Panel title = "筛选条件" > < form onSubmit = { e => { e . preventDefault ( ) ; setFilters ( { ... draft , skip : 0 } ) ; setTarget ( null ) ; } } >
< Toolbar >
< Field label = "号码视角" > < Select value = { draft . view } onChange = { e => change ( 'view' , e . target . value ) } > < option value = "landing" > 落地主叫 < / option > < option value = "original" > 原始主叫 < / option > < / Select > < / Field >
< Field label = "时间窗口" > < Select value = { draft . minutes } onChange = { e => change ( 'minutes' , e . target . value ) } > { [ 5 , 15 , 30 , 60 ] . map ( n => < option key = { n } value = { n } > 最近 { n } 分钟 < / option > ) } < option value = "today" > 今日 < / option > < option value = "custom" > 自定义 < / option > < / Select > < / Field >
{ draft . minutes === 'custom' ? < > < Field label = "开始时间" > < Input type = "datetime-local" required value = { draft . from || '' } onChange = { e => change ( 'from' , e . target . value ) } / > < / Field > < Field label = "结束时间" > < Input type = "datetime-local" required value = { draft . to || '' } onChange = { e => change ( 'to' , e . target . value ) } / > < / Field > < / > : null }
< Field label = "主叫号码" > < Input value = { draft . caller } onChange = { e => change ( 'caller' , e . target . value ) } placeholder = "精确匹配号码" / > < / Field >
{ select ( '客户' , 'customerId' , options . customers ) }
{ select ( '客户网关' , 'customerGatewayId' , options . customerGateways . filter ( g => ! draft . customerId || g . customerId === draft . customerId ) ) }
{ draft . view === 'landing' ? select ( '落地网关' , 'vendorGatewayId' , options . vendorGateways ) : null }
{ select ( '被叫运营商' , 'carrier' , [ 'MOBILE' , 'UNICOM' , 'TELECOM' , 'BROADCAST' , 'MVNO' , 'UNKNOWN' ] . map ( ( id , i ) => ( { id , name : [ '移动' , '联通' , '电信' , '广电' , '虚拟运营商' , '未知' ] [ i ] } ) ) ) }
< Field label = "被叫城市代码" > < Input value = { draft . city } onChange = { e => change ( 'city' , e . target . value ) } / > < / Field >
< Field label = "最小呼叫数" > < Input type = "number" min = "0" value = { draft . minSamples } onChange = { e => change ( 'minSamples' , e . target . value ) } / > < / Field >
< Field label = "排序" > < Select value = { draft . sort } onChange = { e => change ( 'sort' , e . target . value ) } > { [ ... countColumns , ... rateColumns ] . map ( c => < option key = { c . key } value = { c . key } > { c . label } < / option > ) } < / Select > < / Field >
< Field label = "顺序" > < Select value = { draft . order } onChange = { e => change ( 'order' , e . target . value ) } > < option value = "desc" > 从高到低 < / option > < option value = "asc" > 从低到高 < / option > < / Select > < / Field >
< Field label = "异常筛选" > < Select value = { String ( draft . onlyAlerts ) } onChange = { e => change ( 'onlyAlerts' , e . target . value === 'true' ) } > < option value = "false" > 全部号码 < / option > < option value = "true" > 仅看告警 < / option > < / Select > < / Field >
< / Toolbar >
< details className = "cra-rate-filters" > < summary > 接通率 / 应答率区间 < / summary > < Toolbar > { [ [ 'connection' , '接通率' ] , [ 'overall' , '总体应答率' ] , [ 'connected' , '已接通应答率' ] ] . map ( ( [ key , label ] ) => < Field key = { key } label = { ` ${ label } ( %) ` } > < span className = "table-actions" > < Input aria - label = { ` ${ label } 下限 ` } type = "number" min = "0" max = "100" value = { draft [ ` ${ key } Min ` ] ? ? '' } onChange = { e => change ( ` ${ key } Min ` , e . target . value ) } placeholder = "0" / > < span > 至 < / span > < Input aria - label = { ` ${ label } 上限 ` } type = "number" min = "0" max = "100" value = { draft [ ` ${ key } Max ` ] ? ? '' } onChange = { e => change ( ` ${ key } Max ` , e . target . value ) } placeholder = "100" / > < / span > < / Field > ) } < / Toolbar > < / details >
< Button type = "submit" > 查询 < / Button >
< / form > < / Panel >
< section className = "metric-grid cra-metrics" > { [ ... countColumns , ... rateColumns ] . map ( c => < div key = { c . key } className = "metric-card" > < span > { c . label } < / span > < strong > { metrics ? c . render ? c . render ( metrics ) : metrics [ c . key ] : '—' } < / strong > < / div > ) } < / section >
< p className = "muted-text" role = "status" > { data ? ` ${ formatDateTime ( data . from ) } — ${ formatDateTime ( data . to ) } · ${ data . qualityStatus === 'LIVE' ? '采集运行中' : '数据滞后或不完整' } · 待接通 ${ metrics . pendingCalls } · 已结束未接通 ${ metrics . failedCalls } · 未知 ${ metrics . unknownCalls } · 更新于 ${ formatDateTime ( data . asOf ) } ` : '正在读取真实统计数据…' } < / p >
{ data ? < Alert title = "口径与数据范围" tone = { data . qualityStatus === 'LIVE' ? 'info' : 'warning' } > { data . historyNotice } { data . summaryScope } 。 183 不代表实际振铃 ; 未接通数含待接通 , 进行中指标为暂定值 。 < / Alert > : null }
< Panel title = "分钟趋势" aside = { < span className = "muted-text" > 灰 : 呼叫 / 蓝 : 接通 / 绿 : 应答 < / span > } >
< div className = "cra-chart" aria - label = "分钟呼叫接通应答趋势" > { timeline . map ( ( r , i ) => < div key = { r . time ? ? i } className = "cra-chart-column" title = { ` ${ formatDateTime ( Number ( r . time ) ) } 呼叫 ${ r . totalCalls } 接通 ${ r . connectedCalls } 应答 ${ r . answeredCalls } ` } > < div className = "cra-bars" > < span style = { { height : ` ${ r . totalCalls / max * 100 } % ` } } / > < span style = { { height : ` ${ r . connectedCalls / max * 100 } % ` } } / > < span style = { { height : ` ${ r . answeredCalls / max * 100 } % ` } } / > < / div > < small > { new Date ( Number ( r . time ) ) . toLocaleTimeString ( 'zh-CN' , { hour : '2-digit' , minute : '2-digit' } ) } < / small > < / div > ) } < / div >
{ ! timeline . length ? < p className = "muted-text" > 当前窗口暂无已采集呼叫 < / p > : < details > < summary > 查看趋势数值与三个率 < / summary > < SimpleTable rows = { timeline . map ( ( r , i ) => ( { ... r , id : i , minute : formatDateTime ( Number ( r . time ) ) } ) ) } columns = { [ { key : 'minute' , label : '分钟' } , ... countColumns , ... rateColumns ] } / > < / details > }
< / Panel >
< Panel title = "主叫号码列表" aside = { < Badge > { data ? . total ? ? 0 } 个号码 < / Badge > } className = "wide-panel" >
< SimpleTable rows = { ( data ? . numbers ? ? [ ] ) . map ( r => ( { ... r , id : ` ${ r . customerId } : ${ r . caller } ` , customerName : names [ r . customerId ] || r . customerId } ) ) } loading = { ! data && loading } onRowClick = { openDetail } columns = { [
{ key : 'caller' , label : '主叫号码' , render : r => < Button size = "sm" variant = "ghost" onClick = { ( ) => openDetail ( r ) } > { r . caller } < / Button > } , { key : 'customerName' , label : '客户' } , ... countColumns , ... rateColumns ,
{ key : 'pendingCalls' , label : '待接通' } , { key : 'unknownCalls' , label : '未知' } , { key : 'alert' , label : '状态' , render : r => < Badge tone = { r . alert ? 'danger' : 'neutral' } > { r . alert ? r . alert . reasons ? . join ( '、' ) || '告警' : r . unknownCalls ? '证据不足' : '观察' } < / Badge > }
] } / >
< Pagination total = { data ? . total ? ? 0 } take = { filters . take } skip = { filters . skip } count = { data ? . numbers . length ? ? 0 } loading = { loading } onPageChange = { skip => setFilters ( f => ( { ... f , skip } ) ) } onPageSizeChange = { take => setFilters ( f => ( { ... f , take , skip : 0 } ) ) } / >
< / Panel >
{ target ? < Drawer title = { ` 号码详情 · ${ target . caller } ` } onClose = { ( ) => setTarget ( null ) } >
{ detailError ? < Alert tone = "danger" title = "详情更新失败" > { detailError } < / Alert > : null }
< p > { names [ target . customerId ] || target . customerId } · { filters . view === 'landing' ? '落地主叫' : '原始主叫' } < / p >
< Panel title = "线路 / 地区 / 运营商分布" > < SimpleTable rows = { ( detail ? . breakdown ? ? [ ] ) . map ( ( r , i ) => ( { ... r , id : i , gateway : gatewayNames [ r . vendorGatewayId ] || r . vendorGatewayId || '业务呼叫汇总' } ) ) } columns = { [ { key : 'gateway' , label : '落地网关' } , { key : 'city' , label : '城市' } , { key : 'carrier' , label : '运营商' } , ... countColumns , ... rateColumns ] } / > < / Panel >
< Panel title = "关联呼叫与信令证据" > < SimpleTable rows = { ( detail ? . rows ? ? [ ] ) . map ( r => {
const p = r . payload ; const legs = p . legs ? Object . values ( p . legs ) : [ p ] ;
return { ... r , callId : p . start ? . callId || p . event ? . callId , original : p . start ? . caller || p . event ? . caller ,
phase : legs . map ( l => l . first180At ? '180 Ringing' : l . first183At ? '183 Session Progress' : l . acceptedAt ? '直接成功' : '未达接通' ) . join ( ' / ' ) ,
final : legs . map ( l => l . finalCode || '进行中' ) . join ( ' / ' ) , duration : ` ${ ( r . counts . talkMs / 1000 ) . toFixed ( 3 ) } 秒 ${ r . counts . activeCalls ? '(暂定)' : '' } ` , time : formatDateTime ( r . startedAt ) } ;
} ) } columns = { [ { key : 'callId' , label : 'Call-ID' } , { key : 'original' , label : '原始主叫' } , { key : 'callee' , label : '被叫' } , { key : 'phase' , label : '接通证据' } , { key : 'final' , label : '最终结果' } , { key : 'duration' , label : '通话时长' } , { key : 'time' , label : '发起时间' } ] } / > < / Panel >
< div className = "table-actions" > < Button variant = "outline" disabled = { ! detailSkip } onClick = { ( ) => setDetailSkip ( v => Math . max ( 0 , v - 25 ) ) } > 上一页 < / Button > < Button variant = "outline" disabled = { ( detail ? . rows . length ? ? 0 ) < 25 } onClick = { ( ) => setDetailSkip ( v => v + 25 ) } > 下一页 < / Button > < / div >
< p className = "muted-text" > 按 Call - ID 对应原始话单或信令 ; 旧固定时长话单仅作追溯 , 不参与本统计校准 。 < / p >
< / Drawer > : null }
< / > ;
}