feat: add carrier-aware signature retirement alerts
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user