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

663 lines
29 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 { useDeferredValue, useEffect, useState } from 'react';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
type SignatureChannelCarrierQualityStat,
type SignatureChannelQualityItem,
type SignatureChannelQualityResponse,
type SignatureRetirementHeatmapItem,
type SignatureRetirementHeatmapDimension,
type UnreportedSignatureItem,
type PagedResult,
} from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
import { successRateClassName, successRateTone } 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 [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureRetirementHeatmapItem[]>([]);
const [retirementDimensions, setRetirementDimensions] = useState<SignatureRetirementHeatmapDimension[]>([]);
const [unreportedSignatures, setUnreportedSignatures] = useState<(PagedResult<UnreportedSignatureItem> & { date: string }) | null>(null);
const [signatureKeyword, setSignatureKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [unreportedKeyword, setUnreportedKeyword] = useState('');
const [appliedUnreportedKeyword, setAppliedUnreportedKeyword] = 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 [signatureData, heatmapData, unreportedData] = await Promise.all([
adminApi.getSignatureQuality({
date: statisticsDate,
keyword: keyword || undefined,
page,
pageSize: 10,
}),
adminApi.getSignatureRetirementHeatmap(statisticsDate),
adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: appliedUnreportedKeyword || undefined, page: 1, pageSize: 10 }),
]);
setSignatureQuality(signatureData);
setRetirementHeatmap(heatmapData.items);
setRetirementDimensions(heatmapData.dimensions);
setUnreportedSignatures(unreportedData);
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 effectiveDate = signatureQuality?.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) {
setLoading(true);
void adminApi.getSignatureQuality({ date: statisticsDate, keyword: appliedKeyword || undefined, page, pageSize: 10 })
.then((data) => {
setSignatureQuality(data);
setError('');
})
.catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '签名发送质量加载失败'))
.finally(() => setLoading(false));
}
function loadUnreportedSignatures(page: number, keyword = appliedUnreportedKeyword) {
setLoading(true);
void adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: keyword || undefined, page, pageSize: 10 })
.then((data) => {
setUnreportedSignatures(data);
setAppliedUnreportedKeyword(keyword);
setError('');
})
.catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '未报备签名加载失败'))
.finally(() => setLoading(false));
}
return (
<section className="page-stack">
<div className="page-heading">
<div>
<Breadcrumb items={['签名质量检测']} />
<h1>签名质量检测</h1>
</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="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>
<RetirementHeatmap date={statisticsDate} dimensionType="enterprise" dimensions={retirementDimensions} items={retirementHeatmap} title="企业签名活跃度热力图" />
<RetirementHeatmap date={statisticsDate} dimensionType="channel" dimensions={retirementDimensions} items={retirementHeatmap} title="通道签名活跃度热力图" />
<UnreportedSignaturesCard
data={unreportedSignatures}
keyword={unreportedKeyword}
loading={loading}
onKeywordChange={setUnreportedKeyword}
onPageChange={(page) => loadUnreportedSignatures(page)}
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
/>
{selectedSignature ? (
<SignatureQualityDrawer
date={signatureQuality?.date ?? effectiveDate}
item={selectedSignature}
onClose={() => setSelectedSignature(null)}
/>
) : null}
</section>
);
}
function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: { date: string; dimensionType: 'enterprise' | 'channel'; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[]; title: string }) {
const pageSize = 10;
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim().toLocaleLowerCase('zh-CN'));
const visible = items.filter((item) => item.dimensionType === dimensionType);
const dates = previousDateKeys(date, 30);
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.activityDate.slice(0, 10)}`, item]));
const rows = dimensions
.filter((item) => item.dimensionType === dimensionType)
.filter((item) => !deferredKeyword || [item.tenantName, item.applicationName, item.signatureName]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword)))
.map((item) => ({
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
signatureName: item.signatureName,
channelName: item.channelName,
tenantName: item.tenantName,
applicationName: item.applicationName,
carrier: item.carrier,
approvedAt: item.approvedAt.slice(0, 10),
total: dates.reduce((sum, dateKey) => sum + (cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`)?.acceptedBusinessCount ?? 0), 0),
}))
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => {
setPage(1);
}, [date, deferredKeyword, dimensionType, dimensions.length]);
return (
<div className="surface signature-retirement-heatmap">
<div className="section-heading signature-retirement-heatmap__heading">
<div>
<h2>{title}</h2>
<p className="muted">数字为真实受理业务短信数;零提交为灰色,非零按现有六档成功率色阶展示。</p>
</div>
<div className="signature-retirement-heatmap__actions">
<Input
aria-label={`${title}搜索企业、企业应用或签名`}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、企业应用或签名"
value={keyword}
/>
<Tag tone="info">T-1 T-30</Tag>
</div>
</div>
{rows.length ? (
<>
<div className="signature-retirement-heatmap__scroll">
<table>
<thead>
<tr><th>签名维度</th><th>30日合计</th>{dates.map((dateKey) => <th key={dateKey}>{dateKey.slice(5)}</th>)}</tr>
</thead>
<tbody>
{pagedRows.map((row) => (
<tr key={row.key}>
<th>
<span className="signature-retirement-heatmap__identity">
<strong title={`企业:${row.tenantName}\n企业应用:${row.applicationName || '未绑定企业应用'}`}>{row.signatureName}</strong>
{row.channelName ? <small>{row.channelName}</small> : null}
</span>
<CarrierTag carrier={row.carrier} />
</th>
<td className="signature-retirement-heatmap__total">{row.total.toLocaleString('zh-CN')}</td>
{dates.map((dateKey) => {
const item = cellMap.get(`${row.key}:${dateKey}`);
const beforeApproval = dateKey < row.approvedAt;
const successRate = item?.acceptedBusinessCount ? item.deliveredBusinessCount / item.acceptedBusinessCount * 100 : 0;
const className = beforeApproval ? 'is-inapplicable' : !item ? '' : item.acceptedBusinessCount === 0 ? 'is-zero' : `is-rate-${successRateTone(successRate)}`;
const titleText = beforeApproval ? '报备前,不适用' : item ? `提交条数:${item.submittedAttempts}\n上游接受条数:${item.acceptedBusinessCount}\n发送成功条数:${item.deliveredBusinessCount}\n发送成功率:${successRate.toFixed(1)}%\n检测状态:${item.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold} 条` : '当日无检测快照';
return <td className={className} key={dateKey} title={titleText}>{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
<Pagination
nextDisabled={currentPage >= totalPages}
onNext={() => setPage(currentPage + 1)}
onPageChange={setPage}
onPrevious={() => setPage(currentPage - 1)}
page={currentPage}
previousDisabled={currentPage <= 1}
total={rows.length}
totalPages={totalPages}
/>
</>
) : <p className="empty-state">{deferredKeyword ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}</p>}
</div>
);
}
function UnreportedSignaturesCard({
data,
keyword,
loading,
onKeywordChange,
onPageChange,
onSearch,
}: {
data: (PagedResult<UnreportedSignatureItem> & { date: string }) | null;
keyword: string;
loading: boolean;
onKeywordChange: (value: string) => void;
onPageChange: (page: number) => void;
onSearch: () => void;
}) {
const columns: Array<TableColumn<UnreportedSignatureItem>> = [
{ key: 'signatureName', title: '短信签名', render: (record) => <strong>{record.signatureName}</strong> },
{ key: 'tenantName', title: '企业名称', render: (record) => record.tenantName },
{ key: 'applicationName', title: '企业应用', render: (record) => record.applicationName || '未关联企业应用' },
{ key: 'messageCount', title: '未报备短信', align: 'right', width: '150px', render: (record) => `${record.messageCount.toLocaleString('zh-CN')} 条` },
];
const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / (data?.pageSize ?? 10)));
return (
<div className="surface signature-quality-card">
<div className="signature-quality-card__heading">
<div>
<div className="section-heading__title"><h2>未报备签名</h2><Tag tone="warning">待处理</Tag></div>
<p className="muted">{data?.date ?? '所选日期'} 已进入平台、但系统签名库中没有对应记录的业务短信。</p>
</div>
<div className="signature-quality-card__query">
<Input
aria-label="搜索未报备签名、企业或企业应用"
onChange={(event) => onKeywordChange(event.target.value)}
onKeyDown={(event) => { if (event.key === 'Enter') onSearch(); }}
placeholder="搜索签名、企业或企业应用"
value={keyword}
/>
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary">查询</Button>
</div>
</div>
<div className="signature-quality-card__note"><strong>统计说明:</strong>从短信正文开头识别签名;当前企业应用存在同名签名记录时不计入。</div>
<Table
columns={columns}
data={data?.items ?? []}
emptyText={loading ? '正在加载未报备签名…' : '所选日期没有未报备签名短信'}
pagination={false}
rowKey={(record) => `${record.signatureId}:${record.applicationId ?? ''}`}
/>
{(data?.total ?? 0) > 0 ? (
<Pagination
nextDisabled={(data?.page ?? 1) >= totalPages}
onNext={() => onPageChange((data?.page ?? 1) + 1)}
onPageChange={onPageChange}
onPrevious={() => onPageChange((data?.page ?? 1) - 1)}
page={data?.page ?? 1}
previousDisabled={(data?.page ?? 1) <= 1}
total={data?.total ?? 0}
totalPages={totalPages}
/>
) : null}
</div>
);
}
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>
<CarrierTag carrier={carrier.carrier} />
<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 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')}`;
}
function previousDateKeys(endKey: string, days: number) {
const end = new Date(`${endKey}T12:00:00+08:00`);
return Array.from({ length: days }, (_, index) => shanghaiDateKey(new Date(end.getTime() - (index + 1) * 86_400_000)));
}