feat: complete reporting and filing workflows
This commit is contained in:
@@ -1,27 +1,77 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
import { adminApi, type SendQualityResponse } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Chart, Input, Tag } from '@/components/ui';
|
||||
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';
|
||||
|
||||
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
|
||||
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('');
|
||||
|
||||
function loadData() {
|
||||
adminApi.getSendQuality(statisticsDate)
|
||||
.then((qualityData) => {
|
||||
setQuality(qualityData);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '统计数据加载失败'));
|
||||
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(() => {
|
||||
loadData();
|
||||
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) ?? [] }],
|
||||
@@ -32,6 +82,88 @@ export function AdminAnalyticsPage() {
|
||||
}), [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">
|
||||
@@ -40,13 +172,15 @@ export function AdminAnalyticsPage() {
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<Input
|
||||
aria-label="通道占比统计日期"
|
||||
aria-label="统计日期"
|
||||
max={shanghaiDateKey()}
|
||||
onChange={(event) => setStatisticsDate(event.target.value)}
|
||||
type="date"
|
||||
value={statisticsDate}
|
||||
/>
|
||||
<Button icon={<BarChart3 size={16} />} onClick={loadData} variant="ghost">查询统计</Button>
|
||||
<Button disabled={loading} icon={<BarChart3 size={16} />} onClick={queryStatistics} variant="ghost">
|
||||
{loading ? '查询中' : '查询统计'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
@@ -86,10 +220,257 @@ export function AdminAnalyticsPage() {
|
||||
<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 carriers = aggregateByCarrier(item.breakdowns);
|
||||
const channels = [...new Map(item.breakdowns.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="最终成功率" tone={rateTone(item.successRate)} value={`${item.successRate.toFixed(1)}%`} />
|
||||
<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.total.toLocaleString('zh-CN')} 次</strong>
|
||||
</div>
|
||||
<dl>
|
||||
<div><dt>成功率</dt><dd>{carrier.successRate.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>“—”表示所选日期没有该通道与运营商组合的真实提交,并不等同于通道不支持该运营商。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="signature-quality-matrix">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>通道名称</th>
|
||||
{visibleCarriers.map((carrier) => <th key={carrier}>{carrierLabel(carrier)}</th>)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="signature-quality-drawer__footnote">
|
||||
平均到达时间从该通道提交受理开始计算,到该通道全部成功回执完成为止,仅统计成功送达的提交尝试。
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QualityMetric({ label, value, tone = 'default' }: { label: string; value: string; tone?: string }) {
|
||||
return (
|
||||
<div className={`signature-quality-metric signature-quality-metric--${tone}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatrixMetric({ metric }: { metric: SignatureChannelCarrierQualityStat }) {
|
||||
return (
|
||||
<div className="signature-quality-matrix__metric">
|
||||
<strong>{metric.total.toLocaleString('zh-CN')} 次</strong>
|
||||
<span className={`signature-quality-matrix__rate signature-quality-matrix__rate--${rateTone(metric.successRate)}`}>
|
||||
{metric.successRate.toFixed(1)}%
|
||||
</span>
|
||||
<small>{formatDuration(metric.averageArrivalMs)}</small>
|
||||
{metric.submitFailureCount > 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={`signature-quality-rate--${rateTone(value)}`}>{value.toFixed(1)}%</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function aggregateByCarrier(items: SignatureChannelCarrierQualityStat[]) {
|
||||
return carrierOrder
|
||||
.map((carrier) => {
|
||||
const entries = items.filter((item) => normalizeCarrier(item.carrier) === carrier);
|
||||
if (entries.length === 0) return null;
|
||||
const acceptedCount = entries.reduce((sum, item) => sum + item.acceptedCount, 0);
|
||||
const successCount = entries.reduce((sum, item) => sum + item.successCount, 0);
|
||||
const arrivalWeight = entries.reduce((sum, item) => (
|
||||
sum + (item.averageArrivalMs == null ? 0 : item.averageArrivalMs * item.successCount)
|
||||
), 0);
|
||||
const arrivalCount = entries.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0);
|
||||
return {
|
||||
carrier,
|
||||
total: entries.reduce((sum, item) => sum + item.total, 0),
|
||||
successRate: acceptedCount === 0 ? 0 : successCount * 100 / acceptedCount,
|
||||
averageArrivalMs: arrivalCount === 0 ? null : Math.round(arrivalWeight / arrivalCount),
|
||||
channelCount: new Set(entries.map((item) => item.channelId)).size,
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
}
|
||||
|
||||
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 rateTone(value: number) {
|
||||
if (value >= 98) return 'success';
|
||||
if (value >= 95) return 'warning';
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user