Files
lisglosips/apps/web/src/pages/CallerAnalyticsPage.jsx
T

105 lines
13 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}
</>;
}