973 lines
36 KiB
TypeScript
973 lines
36 KiB
TypeScript
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 (
|
||
<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 abort = useRef<AbortController | null>(null);
|
||
const [activity, setActivity] = useState<SignatureActivityResponse | null>(null);
|
||
const [activityFilters, setActivityFilters] = useState({
|
||
tenantName: '',
|
||
applicationName: '',
|
||
signatureName: '',
|
||
channelName: '',
|
||
});
|
||
const [appliedActivityFilters, setAppliedActivityFilters] = useState(activityFilters);
|
||
const [metadata, setMetadata] = useState<SignatureAnalyticsMetadata | null>(null);
|
||
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
|
||
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
|
||
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureActivityItem[]>([]);
|
||
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('');
|
||
|
||
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<TableColumn<SignatureChannelQualityItem>> = [
|
||
{
|
||
key: 'signature',
|
||
title: '短信签名',
|
||
width: '260px',
|
||
render: (record) => (
|
||
<div className="signature-quality-name">
|
||
<strong>{record.signatureName}</strong>
|
||
<span>{record.tenantName}</span>
|
||
<small>{record.applicationNames || '全部企业应用'}</small>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
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) => (
|
||
<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>
|
||
),
|
||
},
|
||
{
|
||
key: 'submitFailureCount',
|
||
title: '提交失败',
|
||
width: '110px',
|
||
align: 'right',
|
||
render: (record) => (
|
||
<span className="quality-number quality-number--warning">
|
||
{record.submitFailureCount.toLocaleString('zh-CN')}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
key: 'successRate',
|
||
title: '成功率',
|
||
width: '170px',
|
||
render: (record) => <QualityRate value={record.successRate} />,
|
||
},
|
||
{
|
||
key: 'averageArrivalMs',
|
||
title: '平均到达时间',
|
||
width: '140px',
|
||
align: 'right',
|
||
render: (record) => formatDuration(record.averageArrivalMs),
|
||
},
|
||
{
|
||
key: 'actions',
|
||
title: '操作',
|
||
width: '110px',
|
||
align: 'right',
|
||
render: (record) => (
|
||
<Button icon={<Eye size={15} />} onClick={() => setSelectedSignature(record)} size="sm" variant="ghost">
|
||
查看明细
|
||
</Button>
|
||
),
|
||
},
|
||
];
|
||
|
||
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 (
|
||
<section className="page-stack" aria-label={analyticsTabs.find((tab) => tab.value === kind)?.label}>
|
||
<div className="page-heading">
|
||
<div>
|
||
<p className="muted">日期、筛选和分页在当前页签内独立生效。</p>
|
||
</div>
|
||
<div className="page-actions">
|
||
<Input
|
||
aria-label="统计日期"
|
||
max={shanghaiDateKey()}
|
||
onChange={(event) => setStatisticsDate(event.target.value)}
|
||
type="date"
|
||
value={statisticsDate}
|
||
/>
|
||
<Button disabled={loading} icon={<BarChart3 size={16} />} onClick={queryStatistics} variant="ghost">
|
||
{loading ? '查询中' : '查询统计'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{error ? (
|
||
<p className="form-error">
|
||
{error}
|
||
{signatureQuality || activity || unreportedSignatures
|
||
? `;仍显示上次成功查询的数据,日期为 ${appliedDate}。`
|
||
: ';尚无成功查询的数据。'}
|
||
</p>
|
||
) : null}
|
||
{metadata ? (
|
||
<p className="muted" role="status">
|
||
{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')}` : ''}
|
||
</p>
|
||
) : null}
|
||
|
||
{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>
|
||
</div>
|
||
<div className="signature-quality-card__note">
|
||
<strong>统计说明:</strong>
|
||
业务短信按消息记录去重;发生补发时会产生多次通道提交,因此“通道提交”可能大于“业务短信”。
|
||
</div>
|
||
<Table
|
||
columns={signatureColumns}
|
||
data={signatureQuality?.items ?? []}
|
||
emptyText={loading ? '正在加载签名统计…' : '所选日期暂无已登记签名发送数据'}
|
||
pagination={false}
|
||
rowKey="signatureId"
|
||
/>
|
||
<Pagination
|
||
pageSize={pageSize}
|
||
onPageSizeChange={changePageSize}
|
||
nextDisabled={
|
||
(signatureQuality?.page ?? 1) >=
|
||
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)),
|
||
)}
|
||
/>
|
||
</div>
|
||
) : null}
|
||
|
||
{kind === 'enterprise' ? (
|
||
<RetirementHeatmap
|
||
pageSize={pageSize}
|
||
onPageSizeChange={changePageSize}
|
||
date={appliedDate}
|
||
dimensionType="enterprise"
|
||
activity={activity}
|
||
filters={activityFilters}
|
||
onFilterChange={setActivityFilters}
|
||
onSearch={queryStatistics}
|
||
onPageChange={(page) => void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)}
|
||
dimensions={retirementDimensions}
|
||
items={retirementHeatmap}
|
||
title="企业签名活跃度热力图"
|
||
/>
|
||
) : null}
|
||
{kind === 'channel' ? (
|
||
<RetirementHeatmap
|
||
pageSize={pageSize}
|
||
onPageSizeChange={changePageSize}
|
||
date={appliedDate}
|
||
dimensionType="channel"
|
||
activity={activity}
|
||
filters={activityFilters}
|
||
onFilterChange={setActivityFilters}
|
||
onSearch={queryStatistics}
|
||
onPageChange={(page) => void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)}
|
||
dimensions={retirementDimensions}
|
||
items={retirementHeatmap}
|
||
title="通道签名活跃度热力图"
|
||
/>
|
||
) : null}
|
||
|
||
{kind === 'unreported' ? (
|
||
<UnreportedSignaturesCard
|
||
pageSize={pageSize}
|
||
onPageSizeChange={changePageSize}
|
||
data={unreportedSignatures}
|
||
keyword={unreportedKeyword}
|
||
loading={loading}
|
||
onKeywordChange={setUnreportedKeyword}
|
||
onPageChange={(page) => loadUnreportedSignatures(page)}
|
||
onSearch={queryStatistics}
|
||
/>
|
||
) : null}
|
||
|
||
{selectedSignature ? (
|
||
<SignatureQualityDrawer
|
||
date={signatureQuality?.date ?? effectiveDate}
|
||
item={selectedSignature}
|
||
onClose={() => setSelectedSignature(null)}
|
||
/>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<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="analytics-activity-filters">
|
||
{(
|
||
[
|
||
['tenantName', '企业'],
|
||
['applicationName', '企业应用'],
|
||
['signatureName', '签名'],
|
||
...(dimensionType === 'channel' ? [['channelName', '通道']] : []),
|
||
] as Array<[keyof typeof filters, string]>
|
||
).map(([key, label]) => (
|
||
<Input
|
||
key={key}
|
||
label={label}
|
||
placeholder={`搜索${label}`}
|
||
value={filters[key]}
|
||
onChange={(event) => onFilterChange({ ...filters, [key]: event.target.value })}
|
||
/>
|
||
))}
|
||
<Button onClick={onSearch} icon={<Search size={16} />}>
|
||
查询
|
||
</Button>
|
||
<Tag tone="info">所选日之前30天</Tag>
|
||
</div>
|
||
</div>
|
||
{activity && !activity.complete ? (
|
||
<p className="form-error">部分日期报表尚未生成,合计仅包含已发布日期,不代表完整30日数据。</p>
|
||
) : null}
|
||
{rows.length ? (
|
||
<>
|
||
<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>
|
||
</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>
|
||
<CarrierTag carrier={row.carrier} />
|
||
</th>
|
||
<td className="signature-retirement-heatmap__total">{row.total.toLocaleString('zh-CN')}</td>
|
||
{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 (
|
||
<td className={className} key={dateKey} title={titleText}>
|
||
{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="empty-state">
|
||
{Object.values(filters).some((value) => value.trim())
|
||
? '没有匹配企业、企业应用或签名的热力图维度。'
|
||
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
|
||
</p>
|
||
)}
|
||
<Pagination
|
||
pageSize={pageSize}
|
||
onPageSizeChange={onPageSizeChange}
|
||
nextDisabled={currentPage >= totalPages}
|
||
onNext={() => setPage(currentPage + 1)}
|
||
onPageChange={setPage}
|
||
onPrevious={() => setPage(currentPage - 1)}
|
||
page={currentPage}
|
||
previousDisabled={currentPage <= 1}
|
||
total={activity?.total ?? 0}
|
||
totalPages={totalPages}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UnreportedSignaturesCard({
|
||
pageSize,
|
||
onPageSizeChange,
|
||
data,
|
||
keyword,
|
||
loading,
|
||
onKeywordChange,
|
||
onPageChange,
|
||
onSearch,
|
||
}: {
|
||
pageSize: number;
|
||
onPageSizeChange: (pageSize: number) => void;
|
||
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 ?? pageSize)));
|
||
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 ?? ''}`}
|
||
/>
|
||
<Pagination
|
||
pageSize={pageSize}
|
||
onPageSizeChange={onPageSizeChange}
|
||
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}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<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>
|
||
</div>
|
||
<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={formatDuration(item.averageArrivalMs)} />
|
||
</div>
|
||
|
||
<section className="signature-quality-section">
|
||
<div className="signature-quality-section__heading">
|
||
<div>
|
||
<h3>运营商概览</h3>
|
||
<p>按真实业务短信去重统计;补发不会重复计数,成功率取短信最终状态。</p>
|
||
</div>
|
||
</div>
|
||
<div className="signature-carrier-grid">
|
||
{carriers.map((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>
|
||
</dl>
|
||
</article>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="signature-quality-section">
|
||
<div className="signature-quality-section__heading">
|
||
<div>
|
||
<h3>通道 × 运营商矩阵</h3>
|
||
<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>
|
||
<div className="signature-quality-matrix">
|
||
<table>
|
||
<thead>
|
||
{matrixMode === 'overall' ? (
|
||
<tr>
|
||
<th>通道名称</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>
|
||
))}
|
||
</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>
|
||
)),
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<p className="signature-quality-drawer__footnote">
|
||
平均到达时间从该通道提交受理开始计算,到该通道全部成功回执完成为止,仅统计成功送达的提交尝试。
|
||
</p>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QualityMetric({ label, value, valueClassName }: { label: string; value: string; valueClassName?: string }) {
|
||
return (
|
||
<div className="signature-quality-metric">
|
||
<span>{label}</span>
|
||
<strong className={valueClassName}>{value}</strong>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
{(metric?.submitFailureCount ?? 0) > 0 ? <em>提交失败 {metric?.submitFailureCount}</em> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function QualityRate({ value }: { value: number }) {
|
||
return (
|
||
<div className="signature-quality-rate">
|
||
<div>
|
||
<span style={{ width: `${Math.min(100, Math.max(0, value))}%` }} />
|
||
</div>
|
||
<strong className={successRateClassName(value)}>{value.toFixed(1)}%</strong>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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)),
|
||
);
|
||
}
|