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 = { mobile: '移动', unicom: '联通', telecom: '电信', unknown: '未知', }; export function AdminAnalyticsPage() { const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey()); const [signatureQuality, setSignatureQuality] = useState(null); const [retirementHeatmap, setRetirementHeatmap] = useState([]); const [retirementDimensions, setRetirementDimensions] = useState([]); const [unreportedSignatures, setUnreportedSignatures] = useState<(PagedResult & { date: string }) | null>(null); const [signatureKeyword, setSignatureKeyword] = useState(''); const [appliedKeyword, setAppliedKeyword] = useState(''); const [unreportedKeyword, setUnreportedKeyword] = useState(''); const [appliedUnreportedKeyword, setAppliedUnreportedKeyword] = useState(''); const [selectedSignature, setSelectedSignature] = useState(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> = [ { key: 'signature', title: '短信签名', width: '260px', render: (record) => (
{record.signatureName} {record.tenantName} {record.applicationNames || '全部企业应用'}
), }, { 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) => {record.successCount.toLocaleString('zh-CN')}, }, { key: 'failureCount', title: '送达失败', width: '110px', align: 'right', render: (record) => {record.failureCount.toLocaleString('zh-CN')}, }, { key: 'submitFailureCount', title: '提交失败', width: '110px', align: 'right', render: (record) => {record.submitFailureCount.toLocaleString('zh-CN')}, }, { key: 'successRate', title: '成功率', width: '170px', render: (record) => , }, { key: 'averageArrivalMs', title: '平均到达时间', width: '140px', align: 'right', render: (record) => formatDuration(record.averageArrivalMs), }, { key: 'actions', title: '操作', width: '110px', align: 'right', render: (record) => ( ), }, ]; 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 (

签名质量检测

setStatisticsDate(event.target.value)} type="date" value={statisticsDate} />
{error ?

{error}

: null}

签名通道发送质量

已登记签名

{signatureQuality?.date ?? effectiveDate} 按签名查看业务结果,明细按真实通道提交尝试拆分运营商与通道。

setSignatureKeyword(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') queryStatistics(); }} placeholder="搜索签名、企业或应用" value={signatureKeyword} />
统计说明: 业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。
{(signatureQuality?.total ?? 0) > 0 ? ( = 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} loadUnreportedSignatures(page)} onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())} /> {selectedSignature ? ( setSelectedSignature(null)} /> ) : null} ); } 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 (

{title}

数字为真实受理业务短信数;零提交为灰色,非零按现有六档成功率色阶展示。

setKeyword(event.target.value)} placeholder="搜索企业、企业应用或签名" value={keyword} /> T-1 至 T-30
{rows.length ? ( <>
{dates.map((dateKey) => )} {pagedRows.map((row) => ( {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 ; })} ))}
签名维度30日合计{dateKey.slice(5)}
{row.signatureName} {row.channelName ? {row.channelName} : null} {row.total.toLocaleString('zh-CN')}{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}
= totalPages} onNext={() => setPage(currentPage + 1)} onPageChange={setPage} onPrevious={() => setPage(currentPage - 1)} page={currentPage} previousDisabled={currentPage <= 1} total={rows.length} totalPages={totalPages} /> ) :

{deferredKeyword ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}

} ); } function UnreportedSignaturesCard({ data, keyword, loading, onKeywordChange, onPageChange, onSearch, }: { data: (PagedResult & { date: string }) | null; keyword: string; loading: boolean; onKeywordChange: (value: string) => void; onPageChange: (page: number) => void; onSearch: () => void; }) { const columns: Array> = [ { key: 'signatureName', title: '短信签名', render: (record) => {record.signatureName} }, { 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 (

未报备签名

待处理

{data?.date ?? '所选日期'} 已进入平台、但系统签名库中没有对应记录的业务短信。

onKeywordChange(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') onSearch(); }} placeholder="搜索签名、企业或企业应用" value={keyword} />
统计说明:从短信正文开头识别签名;当前企业应用存在同名签名记录时不计入。
`${record.signatureId}:${record.applicationId ?? ''}`} /> {(data?.total ?? 0) > 0 ? ( = 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} ); } 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 (
{ if (event.target === event.currentTarget) onClose(); }}>
{matrixMode === 'overall' ? ( {visibleCarriers.map((carrier) => )} ) : ( {majorCarrierOrder.map((carrier) => )} )} {matrixMode === 'overall' ? channels.map((channel) => ( {visibleCarriers.map((carrier) => { const metric = item.breakdowns.find((entry) => ( entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier )); return ( ); })} )) : channels.flatMap((channel) => drainageStates.map((state, stateIndex) => ( {stateIndex === 0 ? : null} {majorCarrierOrder.map((carrier) => { const metric = item.drainageBreakdowns.find((entry) => ( entry.channelId === channel.channelId && normalizeCarrier(entry.carrier) === carrier && entry.drainageState === state.value )); return ; })} )))}
通道名称{carrierLabel(carrier)}
通道名称 引流类型{carrierLabel(carrier)}
{channel.channelName} {metric ? : }
{channel.channelName}{state.label}

平均到达时间从该通道提交受理开始计算,到该通道全部成功回执完成为止,仅统计成功送达的提交尝试。

); } function QualityMetric({ label, value, valueClassName }: { label: string; value: string; valueClassName?: string }) { return (
{label} {value}
); } function MatrixMetric({ metric, zeroWhenEmpty = false }: { metric?: SignatureChannelCarrierQualityStat; zeroWhenEmpty?: boolean }) { const total = metric?.total ?? 0; const successRate = metric?.successRate ?? 0; if (zeroWhenEmpty && total === 0) return 0; return (
{total.toLocaleString('zh-CN')} 次 {successRate.toFixed(1)}% {formatDuration(metric?.averageArrivalMs)} {(metric?.submitFailureCount ?? 0) > 0 ? 提交失败 {metric?.submitFailureCount} : null}
); } function QualityRate({ value }: { value: number }) { return (
{value.toFixed(1)}%
); } 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))); }