fix: align daily operations statistics

This commit is contained in:
hectorzhao
2026-07-24 22:08:06 +08:00
parent c78ed1cc32
commit 53736c96e8
9 changed files with 433 additions and 77 deletions
+23 -35
View File
@@ -1,26 +1,18 @@
import { useEffect, useMemo, useState } from 'react';
import { BarChart3 } from 'lucide-react';
import { adminApi, type DashboardResponse } from '@/api/adminApi';
import { adminApi, type SendQualityResponse } from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Input, Tag } from '@/components/ui';
import { createBarOption, createPieOption } from '@/theme/chartOptions';
export function AdminAnalyticsPage() {
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
const [tenantStats, setTenantStats] = useState<Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>>([]);
const [channelStats, setChannelStats] = useState<Array<{ channelId: string; channelName: string; total: number }>>([]);
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
const [error, setError] = useState('');
function loadData() {
Promise.all([
adminApi.getDashboard(),
adminApi.listStatistics({ groupBy: 'tenantId' }),
adminApi.getSendQuality(statisticsDate),
])
.then(([dashboardData, tenantData, qualityData]) => {
setDashboard(dashboardData);
setTenantStats((Array.isArray(tenantData) ? tenantData : []) as Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>);
setChannelStats(qualityData.channels.map((item) => ({ channelId: item.channelId, channelName: item.channelName, total: item.total })));
adminApi.getSendQuality(statisticsDate)
.then((qualityData) => {
setQuality(qualityData);
setError('');
})
.catch((failure: Error) => setError(failure.message || '统计数据加载失败'));
@@ -30,14 +22,15 @@ export function AdminAnalyticsPage() {
loadData();
}, []);
const tenantOption = useMemo(() => createBarOption({
labels: tenantStats.map((item) => item.tenantId ?? '未绑定企业'),
series: [{ name: '发送量', data: tenantStats.map((item) => item._count._all) }],
}), [tenantStats]);
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: channelStats.map((item) => ({ name: item.channelName || item.channelId, value: item.total })),
}), [channelStats]);
data: quality?.channels.map((item) => ({ name: item.channelName || item.channelId, value: item.total })) ?? [],
}), [quality]);
const effectiveDate = quality?.date ?? statisticsDate;
return (
<section className="page-stack">
@@ -60,19 +53,14 @@ export function AdminAnalyticsPage() {
<div className="dashboard-grid">
<div className="surface metric-card">
<span></span>
<strong>{dashboard?.today.sent.toLocaleString('zh-CN') ?? 0}</strong>
<small></small>
<span>{effectiveDate} </span>
<strong>{quality?.summary.total.toLocaleString('zh-CN') ?? 0}</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{dashboard?.today.successRate ?? 0}%</strong>
<small></small>
</div>
<div className="surface metric-card">
<span></span>
<strong>{dashboard?.pendingAuditCount ?? 0}</strong>
<small>///</small>
<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>
@@ -80,18 +68,18 @@ export function AdminAnalyticsPage() {
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
<h2></h2>
<p className="muted">{effectiveDate} </p>
</div>
<Tag tone="info"></Tag>
<Tag tone="info"></Tag>
</div>
<Chart height={320} option={tenantOption} />
<Chart height={320} option={applicationOption} />
</div>
<div className="surface chart-card">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted">{statisticsDate} </p>
<p className="muted">{effectiveDate} </p>
</div>
<Tag tone="accent"></Tag>
</div>
+13 -6
View File
@@ -17,6 +17,8 @@ type SmsChannel = {
unitPrice: number;
status: ChannelStatus;
total: number;
submitFailureRate: number;
submitFailureCount: number;
successRate: number;
successCount: number;
unknownRate: number;
@@ -154,6 +156,8 @@ function mapApiChannel(
unitPrice: channel.unitPrice,
status: resolveChannelStatus(channel, connections),
total: quality?.total ?? 0,
submitFailureRate: quality?.submitFailureRate ?? 0,
submitFailureCount: quality?.submitFailureCount ?? 0,
successRate: quality?.successRate ?? 0,
successCount: quality?.successCount ?? 0,
unknownRate: quality?.unknownRate ?? 0,
@@ -256,6 +260,8 @@ function ChannelFormModal({
unitPrice: yuanToMoneyUnits(unitPrice),
status: channel?.status ?? 'connecting',
total: channel?.total ?? 0,
submitFailureRate: channel?.submitFailureRate ?? 0,
submitFailureCount: channel?.submitFailureCount ?? 0,
successRate: channel?.successRate ?? 0,
successCount: channel?.successCount ?? 0,
unknownRate: channel?.unknownRate ?? 0,
@@ -607,7 +613,7 @@ export function AdminChannelsPage() {
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
<Select label="当前状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
<div className="audit-filter-actions">
<Button icon={<Search size={16} />}></Button>
<Button icon={<Search size={16} />} onClick={() => void loadChannels()}></Button>
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); }} variant="ghost"></Button>
</div>
</div>
@@ -618,8 +624,8 @@ export function AdminChannelsPage() {
<span></span>
<span> / </span>
<span></span>
<span></span>
<span></span>
<span></span>
<span> / </span>
<span></span>
</div>
{visibleChannels.map((channel) => (
@@ -640,9 +646,10 @@ export function AdminChannelsPage() {
</div>
<strong className="sms-channel-total">{channel.total.toLocaleString('zh-CN')}</strong>
<div className="sms-channel-quality">
<RateBlock count={channel.successCount} label="成功" rate={channel.successRate} tone={channel.successRate >= 80 ? 'success' : 'warning'} />
<RateBlock count={channel.unknownCount} label="未知" rate={channel.unknownRate} />
<RateBlock count={channel.failureCount} label="失败" rate={channel.failureRate} tone={channel.failureRate >= 50 ? 'danger' : 'neutral'} />
<RateBlock count={channel.submitFailureCount} label="提交失败" rate={channel.submitFailureRate} tone={channel.submitFailureCount > 0 ? 'danger' : 'neutral'} />
<RateBlock count={channel.successCount} label="送达成功" rate={channel.successRate} tone={channel.successRate >= 80 ? 'success' : 'warning'} />
<RateBlock count={channel.unknownCount} label="回执未知" rate={channel.unknownRate} />
<RateBlock count={channel.failureCount} label="送达失败" rate={channel.failureRate} tone={channel.failureRate >= 50 ? 'danger' : 'neutral'} />
</div>
<div className="sms-channel-actions">
<button className="sms-channel-report-entry" onClick={() => navigate(`/admin/channels/${channel.id}/reports`)} type="button">
+49 -4
View File
@@ -11,6 +11,7 @@ import {
Button,
Chart,
Modal,
Pagination,
Table,
Tag,
type TableColumn,
@@ -27,6 +28,12 @@ type EnterpriseSpendRank = {
availableBalance: number;
};
type RankedSignatureQualityStat = SignatureQualityStat & {
rank: number;
};
const SIGNATURE_PAGE_SIZE = 10;
const balanceTone = {
: 'success',
: 'warning',
@@ -47,6 +54,8 @@ export function AdminHome() {
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
const [error, setError] = useState('');
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
const [plainSignaturePage, setPlainSignaturePage] = useState(1);
const [drainageSignaturePage, setDrainageSignaturePage] = useState(1);
useEffect(() => {
Promise.all([adminApi.getDashboard(), adminApi.getSendQuality()])
@@ -131,8 +140,8 @@ export function AdminHome() {
},
];
const signatureColumns: Array<TableColumn<SignatureQualityStat>> = [
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
const signatureColumns: Array<TableColumn<RankedSignatureQualityStat>> = [
{ key: 'rank', title: '排名', width: '72px', render: (record) => record.rank },
{ key: 'signatureName', title: '签名', render: (record) => <div><strong>{record.signatureName}</strong><p className="text-caption">{record.tenantName}</p></div> },
{ key: 'total', title: '发送总数', align: 'right', render: (record) => formatCount(record.total) },
{ key: 'successCount', title: '成功', align: 'right', render: (record) => formatCount(record.successCount) },
@@ -143,6 +152,22 @@ export function AdminHome() {
];
const plainSignatureQuality = quality?.signatures.filter((item) => !item.hasDrainage) ?? [];
const drainageSignatureQuality = quality?.signatures.filter((item) => item.hasDrainage) ?? [];
const plainSignatureTotalPages = Math.max(1, Math.ceil(plainSignatureQuality.length / SIGNATURE_PAGE_SIZE));
const drainageSignatureTotalPages = Math.max(1, Math.ceil(drainageSignatureQuality.length / SIGNATURE_PAGE_SIZE));
const pagedPlainSignatureQuality = plainSignatureQuality
.slice((plainSignaturePage - 1) * SIGNATURE_PAGE_SIZE, plainSignaturePage * SIGNATURE_PAGE_SIZE)
.map((item, index) => ({ ...item, rank: (plainSignaturePage - 1) * SIGNATURE_PAGE_SIZE + index + 1 }));
const pagedDrainageSignatureQuality = drainageSignatureQuality
.slice((drainageSignaturePage - 1) * SIGNATURE_PAGE_SIZE, drainageSignaturePage * SIGNATURE_PAGE_SIZE)
.map((item, index) => ({ ...item, rank: (drainageSignaturePage - 1) * SIGNATURE_PAGE_SIZE + index + 1 }));
useEffect(() => {
setPlainSignaturePage((page) => Math.min(page, plainSignatureTotalPages));
}, [plainSignatureTotalPages]);
useEffect(() => {
setDrainageSignaturePage((page) => Math.min(page, drainageSignatureTotalPages));
}, [drainageSignatureTotalPages]);
return (
<section className="page-stack admin-dashboard">
@@ -207,7 +232,17 @@ export function AdminHome() {
</div>
<Tag tone="info">{plainSignatureQuality.length} </Tag>
</div>
<Table columns={signatureColumns} data={plainSignatureQuality} emptyText="今日暂无不含引流的签名发送记录" rowKey="id" />
<Table columns={signatureColumns} data={pagedPlainSignatureQuality} emptyText="今日暂无不含引流的签名发送记录" rowKey="id" />
<Pagination
total={plainSignatureQuality.length}
page={plainSignaturePage}
totalPages={plainSignatureTotalPages}
previousDisabled={plainSignaturePage <= 1}
nextDisabled={plainSignaturePage >= plainSignatureTotalPages}
onPrevious={() => setPlainSignaturePage((page) => Math.max(1, page - 1))}
onNext={() => setPlainSignaturePage((page) => Math.min(plainSignatureTotalPages, page + 1))}
onPageChange={setPlainSignaturePage}
/>
</div>
<div className="surface section-stack">
<div className="section-heading">
@@ -217,7 +252,17 @@ export function AdminHome() {
</div>
<Tag tone="accent">{drainageSignatureQuality.length} </Tag>
</div>
<Table columns={signatureColumns} data={drainageSignatureQuality} emptyText="今日暂无含引流的签名发送记录" rowKey="id" />
<Table columns={signatureColumns} data={pagedDrainageSignatureQuality} emptyText="今日暂无含引流的签名发送记录" rowKey="id" />
<Pagination
total={drainageSignatureQuality.length}
page={drainageSignaturePage}
totalPages={drainageSignatureTotalPages}
previousDisabled={drainageSignaturePage <= 1}
nextDisabled={drainageSignaturePage >= drainageSignatureTotalPages}
onPrevious={() => setDrainageSignaturePage((page) => Math.max(1, page - 1))}
onNext={() => setDrainageSignaturePage((page) => Math.min(drainageSignatureTotalPages, page + 1))}
onPageChange={setDrainageSignaturePage}
/>
</div>
</div>
+57 -10
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, Download, MessageSquare, Search, Smartphone } from 'lucide-react';
import { AlertTriangle, Download, Info, MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit, type SmsReceiptRecord, type SmsSubmitRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Tag, type DateRangeValue } from '@/components/ui';
import { formatCents } from '@/utils/currency';
@@ -8,8 +8,9 @@ const statusLabelMap: Record<string, string> = {
delivered: '发送成功',
queued: '排队中',
submitted: '已提交',
submit_failed: '提交失败',
unknown: '未知',
failed: '失败',
failed: '送达失败',
rejected: '已拒绝',
};
@@ -17,6 +18,7 @@ const statusToneMap: Record<string, 'success' | 'neutral' | 'danger' | 'info'> =
delivered: 'success',
queued: 'info',
submitted: 'info',
submit_failed: 'danger',
unknown: 'neutral',
failed: 'danger',
rejected: 'danger',
@@ -26,6 +28,7 @@ const statusDotClassMap: Record<string, string> = {
delivered: 'is-success',
queued: 'is-unknown',
submitted: 'is-unknown',
submit_failed: 'is-failed',
unknown: 'is-unknown',
failed: 'is-failed',
rejected: 'is-failed',
@@ -85,6 +88,36 @@ function getStatusLabel(status?: string | null) {
return status ? (statusLabelMap[status] ?? status) : '-';
}
function isSubmitFailure(record: SmsMessageRecord) {
return record.status === 'submit_failed' || ['rejected', 'timeout'].includes(record.submitStatus ?? '');
}
function getRecordStatus(record: SmsMessageRecord) {
return isSubmitFailure(record) ? 'submit_failed' : record.status;
}
function getRecordStatusLabel(record: SmsMessageRecord) {
return getStatusLabel(getRecordStatus(record));
}
function getReceiptNotice(record: SmsMessageRecord) {
const hasPlatformFailureReceipt = (record.receiptRecords ?? []).some((receipt) =>
receipt.gatewayMessageId.startsWith('PLATFORM:') && receipt.rawStatus === 'REJECTD',
);
if (hasPlatformFailureReceipt) {
const deliveries = (record.downstreamDeliveries ?? []).filter((item) => item.deliveryType === 'receipt');
if (deliveries.some((item) => item.status === 'delivered')) {
return '平台已生成失败回执并通知企业';
}
const deliveryStatuses = Array.from(new Set(deliveries.map((item) => item.status)));
return `平台已生成失败回执,企业通知状态:${deliveryStatuses.join('、') || '待投递'}`;
}
if (!record.tenantId && !record.applicationId && record.messageId.startsWith('MSG-TEST-')) {
return '运营端通道测试,无需生成客户回执';
}
return null;
}
function getCarrierLabel(carrier?: string | null) {
return carrier ? (carrierLabelMap[carrier] ?? carrier) : '-';
}
@@ -121,7 +154,8 @@ function buildRouteRows(record: SmsMessageRecord): RouteRow[] {
}];
}
function StatusLine({ status }: { status: string }) {
function StatusLine({ record }: { record: SmsMessageRecord }) {
const status = getRecordStatus(record);
return (
<span className="admin-sms-record-status">
<i className={statusDotClassMap[status] ?? 'is-unknown'} />
@@ -149,7 +183,7 @@ function downloadCsv(records: SmsMessageRecord[]) {
record.billingUnits,
formatCents(record.amountCents),
record.channel?.name ?? record.channelId ?? '',
getStatusLabel(record.status),
getRecordStatusLabel(record),
getTime(record.deliveredAt),
record.content,
]),
@@ -176,6 +210,8 @@ function SendDetailModal({
}) {
const routeRows = buildRouteRows(record);
const sentAccessNumber = `${record.channel?.srcId ?? ''}${record.applicationExtension ?? ''}`;
const displayStatus = getRecordStatus(record);
const receiptNotice = getReceiptNotice(record);
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
@@ -188,7 +224,7 @@ function SendDetailModal({
<div className="admin-sms-detail-overview">
<div>
<span></span>
<Tag tone={record.status === 'delivered' ? 'success' : ['failed', 'rejected'].includes(record.status) ? 'danger' : 'info'}>{getStatusLabel(record.status)}</Tag>
<Tag tone={statusToneMap[displayStatus] ?? 'info'}>{getRecordStatusLabel(record)}</Tag>
</div>
<div>
<span></span>
@@ -219,6 +255,12 @@ function SendDetailModal({
<strong>{sentAccessNumber || '-'}</strong>
</div>
</div>
{receiptNotice ? (
<div className="admin-sms-detail-notice" role="status">
<Info size={20} />
<strong>{receiptNotice}</strong>
</div>
) : null}
<section>
<h3><MessageSquare size={18} /> </h3>
<p className="admin-sms-detail-content">{record.content}</p>
@@ -260,7 +302,7 @@ function SendDetailModal({
<h3></h3>
<div className="admin-sms-detail-status-grid">
<div><span></span><strong>{record.messageId}</strong></div>
<div><span></span><strong>{getStatusLabel(record.status)}</strong></div>
<div><span></span><strong>{getRecordStatusLabel(record)}</strong></div>
<div><span></span><strong>{record.submitStatus ?? '-'}</strong></div>
<div><span></span><strong>{record.receiptStatus ?? '-'}</strong></div>
</div>
@@ -377,7 +419,11 @@ export function AdminSmsRecordsPage() {
const enterpriseOptions = useMemo(() => {
const tenants = new Map<string, string>();
records.forEach((record) => tenants.set(record.tenantId, record.tenant?.name ?? record.tenantId));
records.forEach((record) => {
if (record.tenantId) {
tenants.set(record.tenantId, record.tenant?.name ?? record.tenantId);
}
});
return [{ label: '全部企业', value: 'all' }, ...Array.from(tenants, ([value, label]) => ({ label, value }))];
}, [records]);
@@ -441,7 +487,8 @@ export function AdminSmsRecordsPage() {
{ label: '全部', value: 'all' },
{ label: '发送成功', value: 'delivered' },
{ label: '未知', value: 'unknown' },
{ label: '失败', value: 'failed' },
{ label: '提交失败', value: 'submit_failed' },
{ label: '送达失败', value: 'failed' },
]}
value={status}
/>
@@ -460,10 +507,10 @@ export function AdminSmsRecordsPage() {
<article className="admin-sms-record-card" key={record.id}>
<header>
<div className="admin-sms-record-sender">
<strong>{record.tenant?.name ?? record.tenantId}</strong>
<strong>{record.tenant?.name ?? record.tenantId ?? '运营端通道测试'}</strong>
<span>{record.application?.name ?? record.applicationId ?? '-'}</span>
</div>
<StatusLine status={record.status} />
<StatusLine record={record} />
<time>{getDate(record.queuedAt)} {getClock(record.queuedAt)}</time>
</header>
<p className="admin-sms-record-content">{record.content}</p>