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) => ; 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 <> } /> {error ? {error} : null}
{ e.preventDefault(); setFilters({ ...draft, skip: 0 }); setTarget(null); }}> {draft.minutes === 'custom' ? <> change('from', e.target.value)} /> change('to', e.target.value)} /> : null} change('caller', e.target.value)} placeholder="精确匹配号码" /> {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]})))} change('city', e.target.value)} /> change('minSamples', e.target.value)} />
接通率 / 应答率区间{[['connection','接通率'],['overall','总体应答率'],['connected','已接通应答率']].map(([key,label]) => change(`${key}Min`, e.target.value)} placeholder="0" /> change(`${key}Max`, e.target.value)} placeholder="100" />)}
{[...countColumns, ...rateColumns].map(c =>
{c.label}{metrics ? c.render ? c.render(metrics) : metrics[c.key] : '—'}
)}

{data ? `${formatDateTime(data.from)} — ${formatDateTime(data.to)} · ${data.qualityStatus === 'LIVE' ? '采集运行中' : '数据滞后或不完整'} · 待接通 ${metrics.pendingCalls} · 已结束未接通 ${metrics.failedCalls} · 未知 ${metrics.unknownCalls} · 更新于 ${formatDateTime(data.asOf)}` : '正在读取真实统计数据…'}

{data ? {data.historyNotice} {data.summaryScope}。183 不代表实际振铃;未接通数含待接通,进行中指标为暂定值。 : null} 灰:呼叫 / 蓝:接通 / 绿:应答}>
{timeline.map((r,i) =>
{new Date(Number(r.time)).toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'})}
)}
{!timeline.length ?

当前窗口暂无已采集呼叫

:
查看趋势数值与三个率 ({ ...r, id:i, minute:formatDateTime(Number(r.time)) }))} columns={[{key:'minute',label:'分钟'},...countColumns,...rateColumns]} />
}
{data?.total ?? 0} 个号码} className="wide-panel"> ({ ...r, id:`${r.customerId}:${r.caller}`, customerName:names[r.customerId] || r.customerId }))} loading={!data && loading} onRowClick={openDetail} columns={[ {key:'caller',label:'主叫号码',render:r => }, {key:'customerName',label:'客户'}, ...countColumns,...rateColumns, {key:'pendingCalls',label:'待接通'}, {key:'unknownCalls',label:'未知'}, {key:'alert',label:'状态',render:r => {r.alert ? r.alert.reasons?.join('、') || '告警' : r.unknownCalls ? '证据不足' : '观察'}} ]} /> setFilters(f => ({...f,skip}))} onPageSizeChange={take => setFilters(f => ({...f,take,skip:0}))} /> {target ? setTarget(null)}> {detailError ? {detailError} : null}

{names[target.customerId] || target.customerId} · {filters.view === 'landing' ? '落地主叫' : '原始主叫'}

({...r,id:i,gateway:gatewayNames[r.vendorGatewayId] || r.vendorGatewayId || '业务呼叫汇总'}))} columns={[{key:'gateway',label:'落地网关'},{key:'city',label:'城市'},{key:'carrier',label:'运营商'},...countColumns,...rateColumns]} /> { 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:'发起时间'}]} />

按 Call-ID 对应原始话单或信令;旧固定时长话单仅作追溯,不参与本统计校准。

: null} ; }