feat: add carrier-aware signature retirement alerts

This commit is contained in:
hectorzhao
2026-08-10 20:54:05 +08:00
parent 232d1c22a3
commit 55aa054005
52 changed files with 3074 additions and 152 deletions
+214 -56
View File
@@ -1,15 +1,17 @@
import { useEffect, useMemo, useState } from 'react';
import { useDeferredValue, useEffect, useState } from 'react';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
type SendQualityResponse,
type SignatureChannelCarrierQualityStat,
type SignatureChannelQualityItem,
type SignatureChannelQualityResponse,
type SignatureRetirementHeatmapItem,
type SignatureRetirementHeatmapDimension,
type UnreportedSignatureItem,
type PagedResult,
} from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui';
import { createBarOption, createPieOption } from '@/theme/chartOptions';
import { successRateClassName } from '@/utils/successRate';
import { Breadcrumb, Button, 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;
@@ -27,10 +29,14 @@ const carrierLabels: Record<string, string> = {
export function AdminAnalyticsPage() {
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
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('');
@@ -38,17 +44,20 @@ export function AdminAnalyticsPage() {
async function loadData(page = 1, keyword = appliedKeyword) {
setLoading(true);
try {
const [qualityData, signatureData] = await Promise.all([
adminApi.getSendQuality(statisticsDate),
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 }),
]);
setQuality(qualityData);
setSignatureQuality(signatureData);
setRetirementHeatmap(heatmapData.items);
setRetirementDimensions(heatmapData.dimensions);
setUnreportedSignatures(unreportedData);
setAppliedKeyword(keyword);
setSelectedSignature((current) => current
? signatureData.items.find((item) => item.signatureId === current.signatureId) ?? null
@@ -79,15 +88,7 @@ export function AdminAnalyticsPage() {
};
}, [selectedSignature]);
const applicationOption = useMemo(() => createBarOption({
labels: quality?.applications.map((item) => item.applicationName) ?? [],
series: [{ name: '发送量', data: quality?.applications.map((item) => item.total) ?? [] }],
}), [quality]);
const channelOption = useMemo(() => createPieOption({
data: quality?.channels.map((item) => ({ name: item.channelName || item.channelId, value: item.total })) ?? [],
}), [quality]);
const effectiveDate = quality?.date ?? statisticsDate;
const effectiveDate = signatureQuality?.date ?? statisticsDate;
const signatureColumns: Array<TableColumn<SignatureChannelQualityItem>> = [
{
@@ -168,14 +169,34 @@ export function AdminAnalyticsPage() {
}
function changeSignaturePage(page: number) {
void loadData(page, appliedKeyword);
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={['数据统计']} />
<Breadcrumb items={['签名质量检测']} />
<h1></h1>
</div>
<div className="page-actions">
<Input
@@ -192,42 +213,6 @@ export function AdminAnalyticsPage() {
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface metric-card">
<span>{effectiveDate} </span>
<strong>{quality?.summary.total.toLocaleString('zh-CN') ?? 0}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span>{effectiveDate} </span>
<strong>{(quality?.summary.successRate ?? 0).toFixed(1)}%</strong>
<small>{quality?.summary.successCount.toLocaleString('zh-CN') ?? 0} / {quality?.summary.total.toLocaleString('zh-CN') ?? 0} </small>
</div>
</div>
<div className="chart-grid">
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted">{effectiveDate} </p>
</div>
<Tag tone="info"></Tag>
</div>
<Chart height={320} option={applicationOption} />
</div>
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted">{effectiveDate} </p>
</div>
<Tag tone="accent"></Tag>
</div>
<Chart height={320} option={channelOption} />
</div>
</div>
<div className="surface signature-quality-card">
<div className="signature-quality-card__heading">
<div>
@@ -277,6 +262,18 @@ export function AdminAnalyticsPage() {
) : 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}
@@ -288,6 +285,162 @@ 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);
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 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),
}));
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);
const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.detectionDate.slice(0, 10)}`, item]));
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>{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>
<Tag tone="neutral">{carrierLabels[row.carrier] ?? row.carrier}</Tag>
</th>
{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.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,
@@ -507,3 +660,8 @@ function shanghaiDateKey(value = new Date()) {
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)));
}