feat: complete reporting and filing workflows

This commit is contained in:
hectorzhao
2026-07-28 20:28:47 +08:00
parent 352a6293b4
commit 99c8c7c68b
52 changed files with 3490 additions and 376 deletions
+394 -13
View File
@@ -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',