feat: add signature quality period trends
This commit is contained in:
@@ -4,11 +4,12 @@ import {
|
||||
adminApi,
|
||||
type SendQualityResponse,
|
||||
type SignatureChannelCarrierQualityStat,
|
||||
type SignatureChannelCarrierDailyQualityStat,
|
||||
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 { createBarOption, createPieOption, createQualityTrendOption } from '@/theme/chartOptions';
|
||||
import { successRateClassName } from '@/utils/successRate';
|
||||
|
||||
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
|
||||
@@ -25,6 +26,13 @@ const carrierLabels: Record<string, string> = {
|
||||
unknown: '未知',
|
||||
};
|
||||
|
||||
type TrendSelection = {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
carrier: string;
|
||||
days: 3 | 7 | 30;
|
||||
};
|
||||
|
||||
export function AdminAnalyticsPage() {
|
||||
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
|
||||
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
|
||||
@@ -104,48 +112,48 @@ export function AdminAnalyticsPage() {
|
||||
},
|
||||
{
|
||||
key: 'businessTotal',
|
||||
title: '业务短信',
|
||||
title: '近30日业务短信',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => record.total.toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
key: 'channelSubmitTotal',
|
||||
title: '通道提交',
|
||||
title: '近30日通道提交',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => record.channelSubmitTotal.toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
key: 'successCount',
|
||||
title: '送达成功',
|
||||
title: '近30日送达',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => <span className="quality-number quality-number--success">{record.successCount.toLocaleString('zh-CN')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'failureCount',
|
||||
title: '送达失败',
|
||||
title: '近30日送达失败',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => <span className="quality-number quality-number--danger">{record.failureCount.toLocaleString('zh-CN')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'submitFailureCount',
|
||||
title: '提交失败',
|
||||
title: '近30日提交失败',
|
||||
width: '110px',
|
||||
align: 'right',
|
||||
render: (record) => <span className="quality-number quality-number--warning">{record.submitFailureCount.toLocaleString('zh-CN')}</span>,
|
||||
},
|
||||
{
|
||||
key: 'successRate',
|
||||
title: '成功率',
|
||||
title: '近30日成功率',
|
||||
width: '170px',
|
||||
render: (record) => <QualityRate value={record.successRate} />,
|
||||
},
|
||||
{
|
||||
key: 'averageArrivalMs',
|
||||
title: '平均到达时间',
|
||||
title: '近30日平均到达',
|
||||
width: '140px',
|
||||
align: 'right',
|
||||
render: (record) => formatDuration(record.averageArrivalMs),
|
||||
@@ -175,11 +183,11 @@ export function AdminAnalyticsPage() {
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['数据统计']} />
|
||||
<Breadcrumb items={['签名发送质量']} />
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<Input
|
||||
aria-label="统计日期"
|
||||
aria-label="统计截止日"
|
||||
max={shanghaiDateKey()}
|
||||
onChange={(event) => setStatisticsDate(event.target.value)}
|
||||
type="date"
|
||||
@@ -198,6 +206,11 @@ export function AdminAnalyticsPage() {
|
||||
<strong>{quality?.summary.total.toLocaleString('zh-CN') ?? 0}</strong>
|
||||
<small>所选日期真实消息记录</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>当天发送签名数量</span>
|
||||
<strong>{signatureQuality?.selectedDaySignatureCount.toLocaleString('zh-CN') ?? 0}</strong>
|
||||
<small>{effectiveDate} 有业务短信记录的已登记签名去重数</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>{effectiveDate} 成功率</span>
|
||||
<strong>{(quality?.summary.successRate ?? 0).toFixed(1)}%</strong>
|
||||
@@ -232,11 +245,11 @@ export function AdminAnalyticsPage() {
|
||||
<div className="signature-quality-card__heading">
|
||||
<div>
|
||||
<div className="section-heading__title">
|
||||
<h2>签名通道发送质量</h2>
|
||||
<h2>签名发送质量</h2>
|
||||
<Tag tone="info">已登记签名</Tag>
|
||||
</div>
|
||||
<p className="muted">
|
||||
{signatureQuality?.date ?? effectiveDate} 按签名查看业务结果,明细按真实通道提交尝试拆分运营商与通道。
|
||||
统计截止日为 {signatureQuality?.date ?? effectiveDate},近 3/7/30 日均包含截止日当天;明细按业务短信最终归属通道与运营商统计。
|
||||
</p>
|
||||
</div>
|
||||
<div className="signature-quality-card__query">
|
||||
@@ -254,12 +267,12 @@ export function AdminAnalyticsPage() {
|
||||
</div>
|
||||
<div className="signature-quality-card__note">
|
||||
<strong>统计说明:</strong>
|
||||
业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。
|
||||
业务短信按消息记录去重;近 3 日、近 7 日、近 30 日分别覆盖截止日及之前 2、6、29 个自然日。
|
||||
</div>
|
||||
<Table
|
||||
columns={signatureColumns}
|
||||
data={signatureQuality?.items ?? []}
|
||||
emptyText={loading ? '正在加载签名统计…' : '所选日期暂无已登记签名发送数据'}
|
||||
emptyText={loading ? '正在加载签名统计…' : '截至所选日期的近30日暂无已登记签名发送数据'}
|
||||
pagination={false}
|
||||
rowKey="signatureId"
|
||||
/>
|
||||
@@ -298,6 +311,7 @@ function SignatureQualityDrawer({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [matrixMode, setMatrixMode] = useState<'overall' | 'drainage'>('overall');
|
||||
const [trendSelection, setTrendSelection] = useState<TrendSelection | null>(null);
|
||||
const carriers = item.carrierOverview
|
||||
.map((carrier) => ({
|
||||
...carrier,
|
||||
@@ -318,6 +332,10 @@ function SignatureQualityDrawer({
|
||||
.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));
|
||||
const businessQualityRows = [...new Map(item.dailyBreakdowns.map((entry) => [
|
||||
`${entry.channelId}:${normalizeCarrier(entry.carrier)}`,
|
||||
{ channelId: entry.channelId, channelName: entry.channelName, carrier: normalizeCarrier(entry.carrier) },
|
||||
])).values()];
|
||||
|
||||
return (
|
||||
<div className="signature-quality-drawer__backdrop" onMouseDown={(event) => {
|
||||
@@ -335,17 +353,56 @@ function SignatureQualityDrawer({
|
||||
|
||||
<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)} />
|
||||
<QualityMetric label="近30日业务短信" value={item.total.toLocaleString('zh-CN')} />
|
||||
<QualityMetric label="近30日通道提交" value={item.channelSubmitTotal.toLocaleString('zh-CN')} />
|
||||
<QualityMetric label="近30日最终成功率" value={`${item.successRate.toFixed(1)}%`} valueClassName={successRateClassName(item.successRate)} />
|
||||
<QualityMetric label="近30日平均到达时间" 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-business-quality-table">
|
||||
<table>
|
||||
<thead><tr><th>通道</th><th>运营商</th>{([3, 7, 30] as const).map((days) => <th key={days}>近 {days} 日</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{businessQualityRows.map((row) => (
|
||||
<tr key={`${row.channelId}:${row.carrier}`}>
|
||||
<th>{row.channelName}</th>
|
||||
<td><Tag tone={carrierTagTone(row.carrier)}>{carrierLabel(row.carrier)}</Tag></td>
|
||||
{([3, 7, 30] as const).map((days) => {
|
||||
const metric = aggregateBusinessQuality(item.dailyBreakdowns, row.channelId, row.carrier, date, days);
|
||||
return (
|
||||
<td key={days}>
|
||||
<button
|
||||
className="signature-business-quality-metric"
|
||||
onClick={() => setTrendSelection({ ...row, days })}
|
||||
type="button"
|
||||
>
|
||||
<strong>{metric.sent.toLocaleString('zh-CN')} / {metric.delivered.toLocaleString('zh-CN')}</strong>
|
||||
<span className={successRateClassName(metric.rate)}>{metric.rate.toFixed(1)}%</span>
|
||||
<small>查看趋势</small>
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
{businessQualityRows.length === 0 ? <tr><td colSpan={5}>近 30 日暂无业务短信数据</td></tr> : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="signature-quality-section">
|
||||
<div className="signature-quality-section__heading">
|
||||
<div>
|
||||
<h3>运营商概览</h3>
|
||||
<p>按真实业务短信去重统计;补发不会重复计数,成功率取短信最终状态。</p>
|
||||
<p>按近30日真实业务短信去重统计;补发不会重复计数,成功率取短信最终状态。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="signature-carrier-grid">
|
||||
@@ -370,7 +427,7 @@ function SignatureQualityDrawer({
|
||||
<div>
|
||||
<h3>通道 × 运营商矩阵</h3>
|
||||
<p>{matrixMode === 'overall'
|
||||
? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。'
|
||||
? '整体口径展示近30日该组合全部真实提交;“—”表示该期间没有真实提交。'
|
||||
: '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 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>
|
||||
@@ -431,11 +488,84 @@ function SignatureQualityDrawer({
|
||||
平均到达时间从该通道提交受理开始计算,到该通道全部成功回执完成为止,仅统计成功送达的提交尝试。
|
||||
</p>
|
||||
</div>
|
||||
{trendSelection ? (
|
||||
<SignatureQualityTrend
|
||||
date={date}
|
||||
entries={item.dailyBreakdowns}
|
||||
onClose={() => setTrendSelection(null)}
|
||||
selection={trendSelection}
|
||||
signatureName={item.signatureName}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureQualityTrend({
|
||||
date,
|
||||
entries,
|
||||
onClose,
|
||||
selection,
|
||||
signatureName,
|
||||
}: {
|
||||
date: string;
|
||||
entries: SignatureChannelCarrierDailyQualityStat[];
|
||||
onClose: () => void;
|
||||
selection: TrendSelection;
|
||||
signatureName: string;
|
||||
}) {
|
||||
const dates = inclusiveDateKeys(date, selection.days);
|
||||
const points = dates.map((dateKey) => entries.find((entry) => (
|
||||
entry.date === dateKey
|
||||
&& entry.channelId === selection.channelId
|
||||
&& normalizeCarrier(entry.carrier) === selection.carrier
|
||||
)));
|
||||
const option = createQualityTrendOption({
|
||||
labels: dates.map((dateKey) => dateKey.slice(5)),
|
||||
sent: points.map((point) => point?.businessMessageCount ?? 0),
|
||||
delivered: points.map((point) => point?.deliveredMessageCount ?? 0),
|
||||
rates: points.map((point) => point?.deliveryRate ?? 0),
|
||||
});
|
||||
return (
|
||||
<div className="signature-quality-trend" role="dialog" aria-modal="true" aria-label="签名发送质量趋势">
|
||||
<div className="signature-quality-trend__heading">
|
||||
<div><h3>{signatureName} · 近 {selection.days} 日趋势</h3><p>{selection.channelName} · {carrierLabel(selection.carrier)} · {dates[0]} 至 {date}(含截止日)</p></div>
|
||||
<button aria-label="关闭趋势图" onClick={onClose} type="button"><X size={18} /></button>
|
||||
</div>
|
||||
<Chart height={360} option={option} />
|
||||
<p className="muted">折线分别展示每日发送业务条数、到达业务条数和到达率;无发送时到达率按 0% 展示。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateBusinessQuality(
|
||||
entries: SignatureChannelCarrierDailyQualityStat[],
|
||||
channelId: string,
|
||||
carrier: string,
|
||||
date: string,
|
||||
days: 3 | 7 | 30,
|
||||
) {
|
||||
const includedDates = new Set(inclusiveDateKeys(date, days));
|
||||
const relevant = entries.filter((entry) => (
|
||||
entry.channelId === channelId
|
||||
&& normalizeCarrier(entry.carrier) === carrier
|
||||
&& includedDates.has(entry.date)
|
||||
));
|
||||
const sent = relevant.reduce((sum, entry) => sum + entry.businessMessageCount, 0);
|
||||
const delivered = relevant.reduce((sum, entry) => sum + entry.deliveredMessageCount, 0);
|
||||
return { sent, delivered, rate: sent === 0 ? 0 : delivered * 100 / sent };
|
||||
}
|
||||
|
||||
function inclusiveDateKeys(endDate: string, days: number) {
|
||||
const [year, month, day] = endDate.split('-').map(Number);
|
||||
const end = new Date(Date.UTC(year, month - 1, day));
|
||||
return Array.from({ length: days }, (_, index) => {
|
||||
const current = new Date(end.getTime() - (days - index - 1) * 24 * 60 * 60 * 1000);
|
||||
return current.toISOString().slice(0, 10);
|
||||
});
|
||||
}
|
||||
|
||||
function QualityMetric({ label, value, valueClassName }: { label: string; value: string; valueClassName?: string }) {
|
||||
return (
|
||||
<div className="signature-quality-metric">
|
||||
|
||||
Reference in New Issue
Block a user