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

973 lines
36 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, useRef, useState } from 'react';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
type SignatureChannelCarrierQualityStat,
type SignatureChannelQualityItem,
type SignatureActivityResponse,
type SignatureAnalyticsMetadata,
type SignatureChannelQualityResponse,
type SignatureActivityItem,
type SignatureRetirementHeatmapDimension,
type UnreportedSignatureItem,
type PagedResult,
} from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, Input, Pagination, Tabs, Table, Tag, type TableColumn } from '@/components/ui';
import './AdminAnalyticsPage.css';
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 analyticsTabs = [
{ value: 'quality', label: '签名通道发送质量' },
{ value: 'enterprise', label: '企业签名活跃度' },
{ value: 'channel', label: '通道签名活跃度' },
{ value: 'unreported', label: '未报备签名' },
];
export function AdminAnalyticsPage() {
const [active, setActive] = useState('quality');
const [visited, setVisited] = useState(['quality']);
return (
<section className="page-stack admin-analytics-page">
<div className="page-heading">
<div>
<Breadcrumb items={['签名质量检测']} />
<h1>签名质量检测</h1>
</div>
</div>
<Tabs
value={active}
onChange={(value) => {
setActive(value);
setVisited((current) => (current.includes(value) ? current : [...current, value]));
}}
items={analyticsTabs.map((tab) => ({ ...tab, content: null }))}
/>
{visited.map((kind) => (
<div key={kind} hidden={kind !== active}>
<AnalyticsPanel kind={kind} />
</div>
))}
</section>
);
}
function AnalyticsPanel({ kind }: { kind: string }) {
const [pageSize, setPageSize] = useState(25);
const [appliedDate, setAppliedDate] = useState(() => shanghaiDateKey());
const requestId = useRef(0);
const abort = useRef<AbortController | null>(null);
const [activity, setActivity] = useState<SignatureActivityResponse | null>(null);
const [activityFilters, setActivityFilters] = useState({
tenantName: '',
applicationName: '',
signatureName: '',
channelName: '',
});
const [appliedActivityFilters, setAppliedActivityFilters] = useState(activityFilters);
const [metadata, setMetadata] = useState<SignatureAnalyticsMetadata | null>(null);
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureActivityItem[]>([]);
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,
unreported = appliedUnreportedKeyword,
date = statisticsDate,
size = pageSize,
filters = appliedActivityFilters,
) {
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
const id = ++requestId.current;
setLoading(true);
setError('');
try {
if (kind === 'quality') {
const data = await adminApi.getSignatureQuality(
{ date, keyword: keyword || undefined, page, pageSize: size },
controller.signal,
);
if (id !== requestId.current) return;
setSignatureQuality(data);
setMetadata(data);
setAppliedKeyword(keyword);
setSelectedSignature(null);
} else if (kind === 'unreported') {
const data = await adminApi.getUnreportedSignatures(
{
date,
keyword: unreported || undefined,
page,
pageSize: size,
},
controller.signal,
);
if (id !== requestId.current) return;
setUnreportedSignatures(data);
setMetadata(data);
setAppliedUnreportedKeyword(unreported);
} else {
const data = await adminApi.getSignatureActivity(
{ date, dimensionType: kind as 'enterprise' | 'channel', page, pageSize: size, ...filters },
controller.signal,
);
if (id !== requestId.current) return;
setActivity(data);
setAppliedActivityFilters(filters);
setRetirementHeatmap(data.items);
setRetirementDimensions(data.dimensions);
}
setAppliedDate(date);
} catch (failure) {
if (id === requestId.current) setError(failure instanceof Error ? failure.message : '统计数据加载失败');
} finally {
if (id === requestId.current) setLoading(false);
}
}
const initialLoad = useRef(loadData);
useEffect(() => {
void initialLoad.current(1, '');
return () => {
abort.current?.abort();
requestId.current += 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(), unreportedKeyword.trim(), statisticsDate, pageSize, activityFilters);
}
function changeSignaturePage(page: number) {
void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate);
}
function loadUnreportedSignatures(page: number, keyword = appliedUnreportedKeyword) {
void loadData(page, appliedKeyword, keyword, appliedDate);
}
function changePageSize(size: number) {
setPageSize(size);
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
}
return (
<section className="page-stack" aria-label={analyticsTabs.find((tab) => tab.value === kind)?.label}>
<div className="page-heading">
<div>
<p className="muted">日期、筛选和分页在当前页签内独立生效。</p>
</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}
{signatureQuality || activity || unreportedSignatures
? `;仍显示上次成功查询的数据,日期为 ${appliedDate}。`
: ';尚无成功查询的数据。'}
</p>
) : null}
{metadata ? (
<p className="muted" role="status">
{metadata.dataSource === 'live' ? '当天实时查询' : metadata.frozen ? '已冻结日报' : '近三日可刷新日报'} ·{' '}
{metadata.reportState === 'missing'
? '报表尚未生成'
: metadata.reportState === 'failed'
? '生成失败,保留上次完整日报'
: metadata.reportState === 'refreshing'
? '正在刷新,显示上次完整日报'
: '查询完成'}
{metadata.provenance === 'backfill-current-source' ? ' · 事后补建' : ''}
{metadata.sourceAsOf ? ` · 数据截止 ${new Date(metadata.sourceAsOf).toLocaleString('zh-CN')}` : ''}
</p>
) : null}
{kind === 'quality' ? (
<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"
/>
<Pagination
pageSize={pageSize}
onPageSizeChange={changePageSize}
nextDisabled={
(signatureQuality?.page ?? 1) >=
Math.max(1, Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? pageSize)))
}
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.max(
1,
Math.ceil((signatureQuality?.total ?? 0) / (signatureQuality?.pageSize ?? pageSize)),
)}
/>
</div>
) : null}
{kind === 'enterprise' ? (
<RetirementHeatmap
pageSize={pageSize}
onPageSizeChange={changePageSize}
date={appliedDate}
dimensionType="enterprise"
activity={activity}
filters={activityFilters}
onFilterChange={setActivityFilters}
onSearch={queryStatistics}
onPageChange={(page) => void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)}
dimensions={retirementDimensions}
items={retirementHeatmap}
title="企业签名活跃度热力图"
/>
) : null}
{kind === 'channel' ? (
<RetirementHeatmap
pageSize={pageSize}
onPageSizeChange={changePageSize}
date={appliedDate}
dimensionType="channel"
activity={activity}
filters={activityFilters}
onFilterChange={setActivityFilters}
onSearch={queryStatistics}
onPageChange={(page) => void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)}
dimensions={retirementDimensions}
items={retirementHeatmap}
title="通道签名活跃度热力图"
/>
) : null}
{kind === 'unreported' ? (
<UnreportedSignaturesCard
pageSize={pageSize}
onPageSizeChange={changePageSize}
data={unreportedSignatures}
keyword={unreportedKeyword}
loading={loading}
onKeywordChange={setUnreportedKeyword}
onPageChange={(page) => loadUnreportedSignatures(page)}
onSearch={queryStatistics}
/>
) : null}
{selectedSignature ? (
<SignatureQualityDrawer
date={signatureQuality?.date ?? effectiveDate}
item={selectedSignature}
onClose={() => setSelectedSignature(null)}
/>
) : null}
</section>
);
}
function RetirementHeatmap({
activity,
filters,
onFilterChange,
onSearch,
onPageChange,
pageSize,
onPageSizeChange,
date,
dimensionType,
dimensions,
items,
title,
}: {
pageSize: number;
onPageSizeChange: (pageSize: number) => void;
date: string;
dimensionType: 'enterprise' | 'channel';
dimensions: SignatureRetirementHeatmapDimension[];
items: SignatureActivityItem[];
title: string;
activity: SignatureActivityResponse | null;
filters: { tenantName: string; applicationName: string; signatureName: string; channelName: string };
onFilterChange: (value: typeof filters) => void;
onSearch: () => void;
onPageChange: (page: number) => void;
}) {
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.map((item) => ({
...item,
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
approvedAt: item.approvedAt?.slice(0, 10) ?? '',
total: (item as SignatureRetirementHeatmapDimension & { total: number }).total ?? 0,
}));
const totalPages = Math.max(1, Math.ceil((activity?.total ?? 0) / pageSize));
const currentPage = activity?.page ?? 1;
const pagedRows = rows;
const setPage = onPageChange;
const coverage = new Map(activity?.coverage.map((day) => [day.date, day]));
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="analytics-activity-filters">
{(
[
['tenantName', '企业'],
['applicationName', '企业应用'],
['signatureName', '签名'],
...(dimensionType === 'channel' ? [['channelName', '通道']] : []),
] as Array<[keyof typeof filters, string]>
).map(([key, label]) => (
<Input
key={key}
label={label}
placeholder={`搜索${label}`}
value={filters[key]}
onChange={(event) => onFilterChange({ ...filters, [key]: event.target.value })}
/>
))}
<Button onClick={onSearch} icon={<Search size={16} />}>
查询
</Button>
<Tag tone="info">所选日之前30</Tag>
</div>
</div>
{activity && !activity.complete ? (
<p className="form-error">部分日期报表尚未生成,合计仅包含已发布日期,不代表完整30日数据。</p>
) : null}
{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 = Boolean(coverage.get(dateKey)?.generationId && !item);
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数据来源:${coverage.get(dateKey)?.frozen ? '冻结日报' : '可刷新日报'}`
: '当日报表尚未生成';
return (
<td className={className} key={dateKey} title={titleText}>
{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<p className="empty-state">
{Object.values(filters).some((value) => value.trim())
? '没有匹配企业、企业应用或签名的热力图维度。'
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
</p>
)}
<Pagination
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
nextDisabled={currentPage >= totalPages}
onNext={() => setPage(currentPage + 1)}
onPageChange={setPage}
onPrevious={() => setPage(currentPage - 1)}
page={currentPage}
previousDisabled={currentPage <= 1}
total={activity?.total ?? 0}
totalPages={totalPages}
/>
</div>
);
}
function UnreportedSignaturesCard({
pageSize,
onPageSizeChange,
data,
keyword,
loading,
onKeywordChange,
onPageChange,
onSearch,
}: {
pageSize: number;
onPageSizeChange: (pageSize: number) => void;
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 ?? pageSize)));
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 ?? ''}`}
/>
<Pagination
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
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}
/>
</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}>
<CarrierTag carrier={carrier} />
</th>
))}
</tr>
) : (
<tr>
<th>通道名称</th>
<th>引流类型</th>
{majorCarrierOrder.map((carrier) => (
<th key={carrier}>
<CarrierTag carrier={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 signature-quality-matrix__metric--${successRateTone(successRate)}`}
>
<div>
<small>通道提交</small>
<strong>{total.toLocaleString('zh-CN')} </strong>
</div>
<div>
<small>成功率</small>
<span className={`signature-quality-matrix__rate ${successRateClassName(successRate)}`}>
{successRate.toFixed(1)}%
</span>
</div>
<div>
<small>平均到达</small>
<span>{formatDuration(metric?.averageArrivalMs)}</span>
</div>
{(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 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)),
);
}