Files
lislgosms/src/apps/admin/AdminAnalyticsPage.tsx
T

510 lines
21 KiB
TypeScript
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, useMemo, useState } from 'react';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
type SendQualityResponse,
type SignatureChannelCarrierQualityStat,
type SignatureChannelQualityItem,
type SignatureChannelQualityResponse,
} from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
import { createBarOption, createPieOption } from '@/theme/chartOptions';
import { successRateClassName } from '@/utils/successRate';
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
const majorCarrierOrder = ['mobile', 'unicom', 'telecom'] as const;
const drainageStates = [
{ value: 'with', label: '含引流' },
{ value: 'without', label: '不含引流' },
{ value: 'unknown', label: '未检测' },
] as const;
const carrierLabels: Record<string, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
unknown: '未知',
};
export function AdminAnalyticsPage() {
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
const [signatureKeyword, setSignatureKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [selectedSignature, setSelectedSignature] = useState<SignatureChannelQualityItem | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function loadData(page = 1, keyword = appliedKeyword) {
setLoading(true);
try {
const [qualityData, signatureData] = await Promise.all([
adminApi.getSendQuality(statisticsDate),
adminApi.getSignatureQuality({
date: statisticsDate,
keyword: keyword || undefined,
page,
pageSize: 10,
}),
]);
setQuality(qualityData);
setSignatureQuality(signatureData);
setAppliedKeyword(keyword);
setSelectedSignature((current) => current
? signatureData.items.find((item) => item.signatureId === current.signatureId) ?? null
: null);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '统计数据加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadData(1, '');
}, []);
useEffect(() => {
if (!selectedSignature) return undefined;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setSelectedSignature(null);
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', handleKeyDown);
};
}, [selectedSignature]);
const applicationOption = useMemo(() => createBarOption({
labels: quality?.applications.map((item) => item.applicationName) ?? [],
series: [{ name: '发送量', data: quality?.applications.map((item) => item.total) ?? [] }],
}), [quality]);
const channelOption = useMemo(() => createPieOption({
data: quality?.channels.map((item) => ({ name: item.channelName || item.channelId, value: item.total })) ?? [],
}), [quality]);
const effectiveDate = quality?.date ?? statisticsDate;
const signatureColumns: Array<TableColumn<SignatureChannelQualityItem>> = [
{
key: 'signature',
title: '短信签名',
width: '260px',
render: (record) => (
<div className="signature-quality-name">
<strong>{record.signatureName}</strong>
<span>{record.tenantName}</span>
<small>{record.applicationNames || '全部企业应用'}</small>
</div>
),
},
{
key: 'businessTotal',
title: '业务短信',
width: '110px',
align: 'right',
render: (record) => record.total.toLocaleString('zh-CN'),
},
{
key: 'channelSubmitTotal',
title: '通道提交',
width: '110px',
align: 'right',
render: (record) => record.channelSubmitTotal.toLocaleString('zh-CN'),
},
{
key: 'successCount',
title: '送达成功',
width: '110px',
align: 'right',
render: (record) => <span className="quality-number quality-number--success">{record.successCount.toLocaleString('zh-CN')}</span>,
},
{
key: 'failureCount',
title: '送达失败',
width: '110px',
align: 'right',
render: (record) => <span className="quality-number quality-number--danger">{record.failureCount.toLocaleString('zh-CN')}</span>,
},
{
key: 'submitFailureCount',
title: '提交失败',
width: '110px',
align: 'right',
render: (record) => <span className="quality-number quality-number--warning">{record.submitFailureCount.toLocaleString('zh-CN')}</span>,
},
{
key: 'successRate',
title: '成功率',
width: '170px',
render: (record) => <QualityRate value={record.successRate} />,
},
{
key: 'averageArrivalMs',
title: '平均到达时间',
width: '140px',
align: 'right',
render: (record) => formatDuration(record.averageArrivalMs),
},
{
key: 'actions',
title: '操作',
width: '110px',
align: 'right',
render: (record) => (
<Button icon={<Eye size={15} />} onClick={() => setSelectedSignature(record)} size="sm" variant="ghost">
查看明细
</Button>
),
},
];
function queryStatistics() {
void loadData(1, signatureKeyword.trim());
}
function changeSignaturePage(page: number) {
void loadData(page, appliedKeyword);
}
return (
<section className="page-stack">
<div className="page-heading">
<div>
<Breadcrumb items={['数据统计']} />
</div>
<div className="page-actions">
<Input
aria-label="统计日期"
max={shanghaiDateKey()}
onChange={(event) => setStatisticsDate(event.target.value)}
type="date"
value={statisticsDate}
/>
<Button disabled={loading} icon={<BarChart3 size={16} />} onClick={queryStatistics} variant="ghost">
{loading ? '查询中' : '查询统计'}
</Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface metric-card">
<span>{effectiveDate} 发送量</span>
<strong>{quality?.summary.total.toLocaleString('zh-CN') ?? 0}</strong>
<small>所选日期真实消息记录</small>
</div>
<div className="surface metric-card">
<span>{effectiveDate} 成功率</span>
<strong>{(quality?.summary.successRate ?? 0).toFixed(1)}%</strong>
<small>{quality?.summary.successCount.toLocaleString('zh-CN') ?? 0} 条已送达 / {quality?.summary.total.toLocaleString('zh-CN') ?? 0} 条发送</small>
</div>
</div>
<div className="chart-grid">
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2>企业应用发送排行</h2>
<p className="muted">{effectiveDate} 当天按企业应用名称聚合真实短信消息记录。</p>
</div>
<Tag tone="info">企业应用</Tag>
</div>
<Chart height={320} option={applicationOption} />
</div>
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2>通道占比</h2>
<p className="muted">{effectiveDate} 当天按真实通道提交及回执聚合。</p>
</div>
<Tag tone="accent">通道</Tag>
</div>
<Chart height={320} option={channelOption} />
</div>
</div>
<div className="surface signature-quality-card">
<div className="signature-quality-card__heading">
<div>
<div className="section-heading__title">
<h2>签名通道发送质量</h2>
<Tag tone="info">已登记签名</Tag>
</div>
<p className="muted">
{signatureQuality?.date ?? effectiveDate} 按签名查看业务结果,明细按真实通道提交尝试拆分运营商与通道。
</p>
</div>
<div className="signature-quality-card__query">
<Input
aria-label="搜索短信签名、企业或应用"
onChange={(event) => setSignatureKeyword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') queryStatistics();
}}
placeholder="搜索签名、企业或应用"
value={signatureKeyword}
/>
<Button disabled={loading} icon={<Search size={16} />} onClick={queryStatistics} variant="secondary">查询</Button>
</div>
</div>
<div className="signature-quality-card__note">
<strong>统计说明:</strong>
业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。
</div>
<Table
columns={signatureColumns}
data={signatureQuality?.items ?? []}
emptyText={loading ? '正在加载签名统计…' : '所选日期暂无已登记签名发送数据'}
pagination={false}
rowKey="signatureId"
/>
{(signatureQuality?.total ?? 0) > 0 ? (
<Pagination
nextDisabled={(signatureQuality?.page ?? 1) >= Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))}
onNext={() => changeSignaturePage((signatureQuality?.page ?? 1) + 1)}
onPageChange={changeSignaturePage}
onPrevious={() => changeSignaturePage((signatureQuality?.page ?? 1) - 1)}
page={signatureQuality?.page ?? 1}
previousDisabled={(signatureQuality?.page ?? 1) <= 1}
total={signatureQuality?.total ?? 0}
totalPages={Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? 10))}
/>
) : null}
</div>
{selectedSignature ? (
<SignatureQualityDrawer
date={signatureQuality?.date ?? effectiveDate}
item={selectedSignature}
onClose={() => setSelectedSignature(null)}
/>
) : null}
</section>
);
}
function SignatureQualityDrawer({
date,
item,
onClose,
}: {
date: string;
item: SignatureChannelQualityItem;
onClose: () => void;
}) {
const [matrixMode, setMatrixMode] = useState<'overall' | 'drainage'>('overall');
const carriers = item.carrierOverview
.map((carrier) => ({
...carrier,
channelCount: new Set(
item.breakdowns
.filter((entry) => normalizeCarrier(entry.carrier) === normalizeCarrier(carrier.carrier))
.map((entry) => entry.channelId),
).size,
}))
// Database aggregation order is not a display contract; keep the three major carriers stable.
.sort((left, right) => {
const leftRank = carrierOrder.indexOf(normalizeCarrier(left.carrier));
const rightRank = carrierOrder.indexOf(normalizeCarrier(right.carrier));
return (leftRank < 0 ? carrierOrder.length : leftRank)
- (rightRank < 0 ? carrierOrder.length : rightRank);
});
const channels = [...new Map([...item.breakdowns, ...item.drainageBreakdowns]
.map((entry) => [entry.channelId, entry.channelName])).entries()]
.map(([channelId, channelName]) => ({ channelId, channelName }));
const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier));
return (
<div className="signature-quality-drawer__backdrop" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}>
<aside aria-labelledby="signature-quality-drawer-title" aria-modal="true" className="signature-quality-drawer" role="dialog">
<div className="signature-quality-drawer__header">
<div>
<p>全部签名 / {item.signatureName}</p>
<h2 id="signature-quality-drawer-title">{item.signatureName}发送质量详情</h2>
<span>{date} · {item.tenantName} · {item.applicationNames || '全部企业应用'}</span>
</div>
<button aria-label="关闭签名发送质量详情" onClick={onClose} type="button"><X size={20} /></button>
</div>
<div className="signature-quality-drawer__body">
<div className="signature-quality-overview">
<QualityMetric label="业务短信" value={item.total.toLocaleString('zh-CN')} />
<QualityMetric label="通道提交" value={item.channelSubmitTotal.toLocaleString('zh-CN')} />
<QualityMetric label="最终成功率" value={`${item.successRate.toFixed(1)}%`} valueClassName={successRateClassName(item.successRate)} />
<QualityMetric label="平均到达时间" value={formatDuration(item.averageArrivalMs)} />
</div>
<section className="signature-quality-section">
<div className="signature-quality-section__heading">
<div>
<h3>运营商概览</h3>
<p>按真实业务短信去重统计;补发不会重复计数,成功率取短信最终状态。</p>
</div>
</div>
<div className="signature-carrier-grid">
{carriers.map((carrier) => (
<article className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`} key={carrier.carrier}>
<div>
<Tag tone={carrierTagTone(carrier.carrier)}>{carrierLabel(carrier.carrier)}</Tag>
<strong>{carrier.businessMessageCount.toLocaleString('zh-CN')} 条业务短信</strong>
</div>
<dl>
<div><dt>最终成功率</dt><dd className={successRateClassName(carrier.finalSuccessRate)}>{carrier.finalSuccessRate.toFixed(1)}%</dd></div>
<div><dt>平均到达</dt><dd>{formatDuration(carrier.averageArrivalMs)}</dd></div>
<div><dt>涉及通道</dt><dd>{carrier.channelCount} </dd></div>
</dl>
</article>
))}
</div>
</section>
<section className="signature-quality-section">
<div className="signature-quality-section__heading">
<div>
<h3>通道 × 运营商矩阵</h3>
<p>{matrixMode === 'overall'
? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。'
: '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}</p>
</div>
<div className="page-actions"><Button onClick={() => setMatrixMode('overall')} size="sm" variant={matrixMode === 'overall' ? 'primary' : 'ghost'}>整体统计</Button><Button onClick={() => setMatrixMode('drainage')} size="sm" variant={matrixMode === 'drainage' ? 'primary' : 'ghost'}>按引流切分</Button></div>
</div>
<div className="signature-quality-matrix">
<table>
<thead>
{matrixMode === 'overall' ? (
<tr>
<th>通道名称</th>
{visibleCarriers.map((carrier) => <th key={carrier}>{carrierLabel(carrier)}</th>)}
</tr>
) : (
<tr>
<th>通道名称</th>
<th>引流类型</th>
{majorCarrierOrder.map((carrier) => <th key={carrier}>{carrierLabel(carrier)}</th>)}
</tr>
)}
</thead>
<tbody>
{matrixMode === 'overall'
? channels.map((channel) => (
<tr key={channel.channelId}>
<th>{channel.channelName}</th>
{visibleCarriers.map((carrier) => {
const metric = item.breakdowns.find((entry) => (
entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier
));
return (
<td key={carrier}>
{metric ? <MatrixMetric metric={metric} /> : <span className="signature-quality-matrix__empty"></span>}
</td>
);
})}
</tr>
))
: channels.flatMap((channel) => drainageStates.map((state, stateIndex) => (
<tr key={`${channel.channelId}-${state.value}`}>
{stateIndex === 0 ? <th rowSpan={drainageStates.length}>{channel.channelName}</th> : null}
<th className="signature-quality-matrix__drainage-label">{state.label}</th>
{majorCarrierOrder.map((carrier) => {
const metric = item.drainageBreakdowns.find((entry) => (
entry.channelId === channel.channelId
&& normalizeCarrier(entry.carrier) === carrier
&& entry.drainageState === state.value
));
return <td key={carrier}><MatrixMetric metric={metric} zeroWhenEmpty /></td>;
})}
</tr>
)))}
</tbody>
</table>
</div>
</section>
<p className="signature-quality-drawer__footnote">
平均到达时间从该通道提交受理开始计算,到该通道全部成功回执完成为止,仅统计成功送达的提交尝试。
</p>
</div>
</aside>
</div>
);
}
function QualityMetric({ label, value, valueClassName }: { label: string; value: string; valueClassName?: string }) {
return (
<div className="signature-quality-metric">
<span>{label}</span>
<strong className={valueClassName}>{value}</strong>
</div>
);
}
function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureChannelCarrierQualityStat; zeroWhenEmpty?: boolean }) {
const total = metric?.total ?? 0;
const successRate = metric?.successRate ?? 0;
if (zeroWhenEmpty && total === 0) return <span className="signature-quality-matrix__zero">0</span>;
return (
<div className="signature-quality-matrix__metric">
<strong>{total.toLocaleString('zh-CN')} </strong>
<span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>
{successRate.toFixed(1)}%
</span>
<small>{formatDuration(metric?.averageArrivalMs)}</small>
{(metric?.submitFailureCount ?? 0) > 0 ? <em>提交失败 {metric?.submitFailureCount}</em> : null}
</div>
);
}
function QualityRate({ value }: { value: number }) {
return (
<div className="signature-quality-rate">
<div><span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} /></div>
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
</div>
);
}
function normalizeCarrier(value: string) {
const normalized = value.toLowerCase();
if (['mobile', 'cmcc', '移动'].includes(normalized)) return 'mobile';
if (['unicom', 'cucc', '联通'].includes(normalized)) return 'unicom';
if (['telecom', 'ctcc', '电信'].includes(normalized)) return 'telecom';
return 'unknown';
}
function carrierLabel(value: string) {
return carrierLabels[normalizeCarrier(value)] ?? '未知';
}
function carrierTagTone(value: string): 'info' | 'accent' | 'warning' | 'neutral' {
const carrier = normalizeCarrier(value);
if (carrier === 'mobile') return 'info';
if (carrier === 'unicom') return 'accent';
if (carrier === 'telecom') return 'warning';
return 'neutral';
}
function formatDuration(value?: number | null) {
if (value == null) return '—';
if (value < 1000) return `${Math.round(value)} 毫秒`;
return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)} 秒`;
}
function shanghaiDateKey(value = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(value);
const byType = new Map(parts.map((part) => [part.type, part.value]));
return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`;
}