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 (

签名质量检测

{ setActive(value); setVisited((current) => (current.includes(value) ? current : [...current, value])); }} items={analyticsTabs.map((tab) => ({ ...tab, content: null }))} /> {visited.map((kind) => ( ))}
); } function AnalyticsPanel({ kind }: { kind: string }) { const [pageSize, setPageSize] = useState(25); const [appliedDate, setAppliedDate] = useState(() => shanghaiDateKey()); const requestId = useRef(0); const abort = useRef(null); const [activity, setActivity] = useState(null); const [activityFilters, setActivityFilters] = useState({ tenantName: '', applicationName: '', signatureName: '', channelName: '', }); const [appliedActivityFilters, setAppliedActivityFilters] = useState(activityFilters); const [metadata, setMetadata] = useState(null); 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, 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> = [ { 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(), 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 (
tab.value === kind)?.label}>

日期、筛选和分页在当前页签内独立生效。

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

{error} {signatureQuality || activity || unreportedSignatures ? `;仍显示上次成功查询的数据,日期为 ${appliedDate}。` : ';尚无成功查询的数据。'}

) : null} {metadata ? (

{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')}` : ''}

) : null} {kind === 'quality' ? (

签名通道发送质量

已登记签名

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

setSignatureKeyword(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') queryStatistics(); }} placeholder="搜索签名、企业或应用" value={signatureKeyword} />
统计说明: 业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。
= 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)), )} /> ) : null} {kind === 'enterprise' ? ( void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)} dimensions={retirementDimensions} items={retirementHeatmap} title="企业签名活跃度热力图" /> ) : null} {kind === 'channel' ? ( void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)} dimensions={retirementDimensions} items={retirementHeatmap} title="通道签名活跃度热力图" /> ) : null} {kind === 'unreported' ? ( loadUnreportedSignatures(page)} onSearch={queryStatistics} /> ) : null} {selectedSignature ? ( setSelectedSignature(null)} /> ) : null} ); } 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 (

{title}

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

{( [ ['tenantName', '企业'], ['applicationName', '企业应用'], ['signatureName', '签名'], ...(dimensionType === 'channel' ? [['channelName', '通道']] : []), ] as Array<[keyof typeof filters, string]> ).map(([key, label]) => ( onFilterChange({ ...filters, [key]: event.target.value })} /> ))} 所选日之前30天
{activity && !activity.complete ? (

部分日期报表尚未生成,合计仅包含已发布日期,不代表完整30日数据。

) : null} {rows.length ? ( <>
{dates.map((dateKey) => ( ))} {pagedRows.map((row) => ( {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 ( ); })} ))}
签名维度 30日合计{dateKey.slice(5)}
{row.signatureName} {row.channelName ? {row.channelName} : null} {row.total.toLocaleString('zh-CN')} {beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}
) : (

{Object.values(filters).some((value) => value.trim()) ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}

)} = totalPages} onNext={() => setPage(currentPage + 1)} onPageChange={setPage} onPrevious={() => setPage(currentPage - 1)} page={currentPage} previousDisabled={currentPage <= 1} total={activity?.total ?? 0} totalPages={totalPages} /> ); } function UnreportedSignaturesCard({ pageSize, onPageSizeChange, data, keyword, loading, onKeywordChange, onPageChange, onSearch, }: { pageSize: number; onPageSizeChange: (pageSize: number) => void; 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 ?? pageSize))); return (

未报备签名

待处理

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

onKeywordChange(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') onSearch(); }} placeholder="搜索签名、企业或企业应用" value={keyword} />
统计说明:从短信正文开头识别签名;当前企业应用存在同名签名记录时不计入。
`${record.signatureId}:${record.applicationId ?? ''}`} /> = 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} /> ); } 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 ( ); })} )), )}
通道名称
通道名称 引流类型
{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 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)), ); }