fix: bound formatter memory and improve operations workflows

This commit is contained in:
hectorzhao
2026-09-08 12:46:15 +08:00
parent 50ae37242b
commit 2c228a94e1
27 changed files with 4013 additions and 1230 deletions
+443 -193
View File
@@ -1,4 +1,4 @@
import { useDeferredValue, useEffect, useState } from 'react';
import { useDeferredValue, useEffect, useRef, useState } from 'react';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
@@ -10,7 +10,19 @@ import {
type UnreportedSignatureItem,
type PagedResult,
} from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
import {
Breadcrumb,
Button,
CarrierTag,
Input,
Pagination,
Select,
Tabs,
Table,
Tag,
type TableColumn,
} from '@/components/ui';
import './AdminAnalyticsPage.css';
import { successRateClassName, successRateTone } from '@/utils/successRate';
const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown'];
@@ -20,19 +32,51 @@ const drainageStates = [
{ value: 'without', label: '不含引流' },
{ value: 'unknown', label: '未检测' },
] as const;
const carrierLabels: Record<string, string> = {
mobile: '移动',
unicom: '联通',
telecom: '电信',
unknown: '未知',
};
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 [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 [unreportedSignatures, setUnreportedSignatures] = useState<
(PagedResult<UnreportedSignatureItem> & { date: string }) | null
>(null);
const [signatureKeyword, setSignatureKeyword] = useState('');
const [appliedKeyword, setAppliedKeyword] = useState('');
const [unreportedKeyword, setUnreportedKeyword] = useState('');
@@ -41,37 +85,52 @@ export function AdminAnalyticsPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
async function loadData(page = 1, keyword = appliedKeyword) {
async function loadData(
page = 1,
keyword = appliedKeyword,
unreported = appliedUnreportedKeyword,
date = statisticsDate,
size = pageSize,
) {
const id = ++requestId.current;
setLoading(true);
setError('');
try {
const [signatureData, heatmapData, unreportedData] = await Promise.all([
adminApi.getSignatureQuality({
date: statisticsDate,
keyword: keyword || undefined,
if (kind === 'quality') {
const data = await adminApi.getSignatureQuality({ date, keyword: keyword || undefined, page, pageSize: size });
if (id !== requestId.current) return;
setSignatureQuality(data);
setAppliedKeyword(keyword);
setSelectedSignature(null);
} else if (kind === 'unreported') {
const data = await adminApi.getUnreportedSignatures({
date,
keyword: unreported || 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('');
pageSize: size,
});
if (id !== requestId.current) return;
setUnreportedSignatures(data);
setAppliedUnreportedKeyword(unreported);
} else {
const data = await adminApi.getSignatureRetirementHeatmap(date);
if (id !== requestId.current) return;
setRetirementHeatmap(data.items);
setRetirementDimensions(data.dimensions);
}
setAppliedDate(date);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '统计数据加载失败');
if (id === requestId.current) setError(failure instanceof Error ? failure.message : '统计数据加载失败');
} finally {
setLoading(false);
if (id === requestId.current) setLoading(false);
}
}
useEffect(() => {
void loadData(1, '');
return () => {
requestId.current += 1;
};
}, []);
useEffect(() => {
@@ -122,21 +181,29 @@ export function AdminAnalyticsPage() {
title: '送达成功',
width: '110px',
align: 'right',
render: (record) => <span className="quality-number quality-number--success">{record.successCount.toLocaleString('zh-CN')}</span>,
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>,
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>,
render: (record) => (
<span className="quality-number quality-number--warning">
{record.submitFailureCount.toLocaleString('zh-CN')}
</span>
),
},
{
key: 'successRate',
@@ -169,36 +236,30 @@ export function AdminAnalyticsPage() {
}
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));
void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate);
}
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));
void loadData(page, appliedKeyword, keyword, appliedDate);
}
return (
<section className="page-stack">
<section className="page-stack" aria-label={analyticsTabs.find((tab) => tab.value === kind)?.label}>
<div className="page-heading">
<div>
<Breadcrumb items={['签名质量检测']} />
<h1></h1>
<p className="muted"></p>
</div>
<div className="page-actions">
<Select
label="每页数量"
value={String(pageSize)}
options={[10, 25, 50, 100].map((value) => ({ value: String(value), label: `${value} 条/页` }))}
onChange={(event) => {
const size = Number(event.target.value);
setPageSize(size);
if (kind === 'quality' || kind === 'unreported')
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
}}
/>
<Input
aria-label="统计日期"
max={shanghaiDateKey()}
@@ -213,66 +274,93 @@ export function AdminAnalyticsPage() {
</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>
{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>
<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 className="signature-quality-card__note">
<strong></strong>
</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))}
<Table
columns={signatureColumns}
data={signatureQuality?.items ?? []}
emptyText={loading ? '正在加载签名统计…' : '所选日期暂无已登记签名发送数据'}
pagination={false}
rowKey="signatureId"
/>
) : null}
</div>
{(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>
) : null}
<RetirementHeatmap date={statisticsDate} dimensionType="enterprise" dimensions={retirementDimensions} items={retirementHeatmap} title="企业签名活跃度热力图" />
<RetirementHeatmap date={statisticsDate} dimensionType="channel" dimensions={retirementDimensions} items={retirementHeatmap} title="通道签名活跃度热力图" />
{kind === 'enterprise' ? (
<RetirementHeatmap
pageSize={pageSize}
date={appliedDate}
dimensionType="enterprise"
dimensions={retirementDimensions}
items={retirementHeatmap}
title="企业签名活跃度热力图"
/>
) : null}
{kind === 'channel' ? (
<RetirementHeatmap
pageSize={pageSize}
date={appliedDate}
dimensionType="channel"
dimensions={retirementDimensions}
items={retirementHeatmap}
title="通道签名活跃度热力图"
/>
) : null}
<UnreportedSignaturesCard
data={unreportedSignatures}
keyword={unreportedKeyword}
loading={loading}
onKeywordChange={setUnreportedKeyword}
onPageChange={(page) => loadUnreportedSignatures(page)}
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
/>
{kind === 'unreported' ? (
<UnreportedSignaturesCard
data={unreportedSignatures}
keyword={unreportedKeyword}
loading={loading}
onKeywordChange={setUnreportedKeyword}
onPageChange={(page) => loadUnreportedSignatures(page)}
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
/>
) : null}
{selectedSignature ? (
<SignatureQualityDrawer
@@ -285,18 +373,41 @@ export function AdminAnalyticsPage() {
);
}
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);
function RetirementHeatmap({
pageSize,
date,
dimensionType,
dimensions,
items,
title,
}: {
pageSize: number;
date: string;
dimensionType: 'enterprise' | 'channel';
dimensions: SignatureRetirementHeatmapDimension[];
items: SignatureRetirementHeatmapItem[];
title: string;
}) {
const [pageState, setPageState] = useState({ key: '', page: 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 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.channelName, item.tenantName, item.applicationName, item.signatureName]
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword)))
.filter(
(item) =>
!deferredKeyword ||
[item.channelName, 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,
@@ -305,17 +416,22 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
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),
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 paginationKey = JSON.stringify([date, deferredKeyword, dimensionType, dimensions.length, pageSize]);
const page = pageState.key === paginationKey ? pageState.page : 1;
const setPage = (value: number) => setPageState({ key: paginationKey, page: value });
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">
@@ -338,14 +454,22 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
<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>
<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>
<strong title={`企业:${row.tenantName}\n企业应用:${row.applicationName || '未绑定企业应用'}`}>
{row.signatureName}
</strong>
{row.channelName ? <small>{row.channelName}</small> : null}
</span>
<CarrierTag carrier={row.carrier} />
@@ -354,10 +478,26 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
{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>;
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>
))}
@@ -375,7 +515,13 @@ function RetirementHeatmap({ date, dimensionType, dimensions, items, title }: {
totalPages={totalPages}
/>
</>
) : <p className="empty-state">{deferredKeyword ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}</p>}
) : (
<p className="empty-state">
{deferredKeyword
? '没有匹配企业、企业应用或签名的热力图维度。'
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
</p>
)}
</div>
);
}
@@ -399,28 +545,43 @@ function UnreportedSignaturesCard({
{ 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')}` },
{
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>
<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(); }}
onKeyDown={(event) => {
if (event.key === 'Enter') onSearch();
}}
placeholder="搜索签名、企业或企业应用"
value={keyword}
/>
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary"></Button>
<Button disabled={loading} icon={<Search size={16} />} onClick={onSearch} variant="secondary">
</Button>
</div>
</div>
<div className="signature-quality-card__note"><strong></strong></div>
<div className="signature-quality-card__note">
<strong></strong>
</div>
<Table
columns={columns}
data={data?.items ?? []}
@@ -467,33 +628,52 @@ function SignatureQualityDrawer({
.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);
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));
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__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>
<span>
{date} · {item.tenantName} · {item.applicationNames || '全部企业应用'}
</span>
</div>
<button aria-label="关闭签名发送质量详情" onClick={onClose} type="button"><X size={20} /></button>
<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={`${item.successRate.toFixed(1)}%`}
valueClassName={successRateClassName(item.successRate)}
/>
<QualityMetric label="平均到达时间" value={formatDuration(item.averageArrivalMs)} />
</div>
@@ -506,15 +686,29 @@ function SignatureQualityDrawer({
</div>
<div className="signature-carrier-grid">
{carriers.map((carrier) => (
<article className={`signature-carrier-card signature-carrier-card--${normalizeCarrier(carrier.carrier)}`} key={carrier.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>
<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>
))}
@@ -525,11 +719,28 @@ function SignatureQualityDrawer({
<div className="signature-quality-section__heading">
<div>
<h3> × </h3>
<p>{matrixMode === 'overall'
? '整体口径展示该组合全部真实提交;“—”表示所选日期没有真实提交。'
: '固定按含引流、不含引流、未检测三行及移动、联通、电信三列展示;没有真实提交的组合显示 0。'}</p>
<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 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>
@@ -537,47 +748,67 @@ function SignatureQualityDrawer({
{matrixMode === 'overall' ? (
<tr>
<th></th>
{visibleCarriers.map((carrier) => <th key={carrier}><CarrierTag carrier={carrier} /></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>)}
{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>
)))}
<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>
@@ -601,16 +832,35 @@ function QualityMetric({ label, value, valueClassName }: { label: string; value:
);
}
function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureChannelCarrierQualityStat; zeroWhenEmpty?: boolean }) {
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>
<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>
);
@@ -619,7 +869,9 @@ function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureCha
function QualityRate({ value }: { value: number }) {
return (
<div className="signature-quality-rate">
<div><span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} /></div>
<div>
<span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} />
</div>
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
</div>
);
@@ -633,10 +885,6 @@ function normalizeCarrier(value: string) {
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)} 毫秒`;
@@ -656,5 +904,7 @@ function shanghaiDateKey(value = new Date()) {
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)));
return Array.from({ length: days }, (_, index) =>
shanghaiDateKey(new Date(end.getTime() - (index + 1) * 86_400_000)),
);
}