feat: add caller connection and answer rate analytics

This commit is contained in:
hectorzhao
2026-08-31 17:21:20 +08:00
parent 8627228bbc
commit 5284d41f9a
29 changed files with 1284 additions and 0 deletions
+3
View File
@@ -16,6 +16,7 @@ import {
normalizeVendorGateway,
} from './utils/formatters.js';
import { DashboardPage } from './pages/DashboardPage.jsx';
import { CallerAnalyticsPage } from './pages/CallerAnalyticsPage.jsx';
import { ActiveCallsPage } from './pages/ActiveCallsPage.jsx';
import { CustomersPage } from './pages/CustomersPage.jsx';
import { RechargeRecordsPage } from './pages/RechargeRecordsPage.jsx';
@@ -58,6 +59,7 @@ const navGroups = [
items: [
{ key: 'activeCalls', label: '当前通话', permissions: PAGE_PERMISSIONS.activeCalls },
{ key: 'cdr', label: '话单中心', permissions: PAGE_PERMISSIONS.cdr },
{ key: 'callerAnalytics', label: '主叫号码分析', permissions: PAGE_PERMISSIONS.callerAnalytics },
{ key: 'quality', label: '质检中心', permissions: PAGE_PERMISSIONS.quality },
{ key: 'sipops', label: 'SIP 运维', pending: true },
{ key: 'monitoring', label: '监控告警', pending: true },
@@ -76,6 +78,7 @@ const navGroups = [
const pages = {
callerAnalytics: CallerAnalyticsPage,
dashboard: DashboardPage,
activeCalls: ActiveCallsPage,
customers: CustomersPage,
+3
View File
@@ -128,6 +128,9 @@ function queryString(params = {}) {
}
export const api = {
callerAnalytics: (params, options) => request(`/caller-analytics/overview${queryString(params)}`, options),
callerAnalyticsCalls: (params, options) => request(`/caller-analytics/calls${queryString(params)}`, options),
callerAnalyticsOptions: options => request('/caller-analytics/options', options),
captcha: () => request('/auth/captcha'),
login: async (body) => {
const response = await request('/auth/login', { method: 'POST', body: jsonBody(body) });
+104
View File
@@ -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}
</>;
}
+1
View File
@@ -1,4 +1,5 @@
export const PAGE_PERMISSIONS = {
callerAnalytics: ['caller_analytics.view'],
dashboard: ['dashboard.view'],
activeCalls: ['active_calls.view'],
customers: ['customers.view'],
+9
View File
@@ -2893,3 +2893,12 @@ a {
animation: none;
}
}
.cra-metrics { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); margin: 20px 0; }
.cra-rate-filters { margin: 12px 0; }
.cra-chart { display: flex; gap: 8px; overflow-x: auto; min-height: 160px; padding: 12px 0; }
.cra-chart-column { flex: 1 0 38px; max-width: 70px; text-align: center; }
.cra-bars { display: flex; align-items: flex-end; justify-content: center; height: 120px; gap: 2px; }
.cra-bars span { width: 9px; min-height: 1px; background: #94a3b8; border-radius: 2px 2px 0 0; }
.cra-bars span:nth-child(2) { background: #2563eb; }
.cra-bars span:nth-child(3) { background: #16a34a; }
.cra-chart small { color: #64748b; font-size: 10px; }