fix: restore daily operations quality metrics

This commit is contained in:
hectorzhao
2026-07-24 18:13:22 +08:00
parent 91f04f5288
commit e12bdf010e
10 changed files with 364 additions and 65 deletions
+35
View File
@@ -350,6 +350,40 @@ export type DashboardResponse = {
recentRecharges: Array<RechargeOrder>;
};
export type ChannelQualityStat = {
channelId: string;
channelName: string;
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
unknownRate: number;
failureRate: number;
averageArrivalMs?: number | null;
};
export type SignatureQualityStat = {
id: string;
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
hasDrainage: boolean;
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs?: number | null;
};
export type SendQualityResponse = {
date: string;
channels: ChannelQualityStat[];
signatures: SignatureQualityStat[];
};
export type RechargeOrder = {
id: string;
tenantId: string;
@@ -1395,6 +1429,7 @@ export const adminApi = {
changeUserPassword: (id: string, password: string, operatorId?: string) =>
request<ManagedUser>(`/admin/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password, operatorId }) }),
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
getSendQuality: (date?: string) => request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; page?: number; pageSize?: number }) =>
+29 -8
View File
@@ -1,25 +1,26 @@
import { useEffect, useMemo, useState } from 'react';
import { BarChart3 } from 'lucide-react';
import { adminApi, type DashboardResponse } from '@/api/adminApi';
import { Breadcrumb, Button, Chart, Tag } from '@/components/ui';
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 | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>>([]);
const [channelStats, setChannelStats] = useState<Array<{ channelId: string; channelName: string; total: number }>>([]);
const [error, setError] = useState('');
function loadData() {
Promise.all([
adminApi.getDashboard(),
adminApi.listStatistics({ groupBy: 'tenantId' }),
adminApi.listStatistics({ groupBy: 'channelId' }),
adminApi.getSendQuality(statisticsDate),
])
.then(([dashboardData, tenantData, channelData]) => {
.then(([dashboardData, tenantData, qualityData]) => {
setDashboard(dashboardData);
setTenantStats((Array.isArray(tenantData) ? tenantData : []) as Array<{ tenantId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>);
setChannelStats((Array.isArray(channelData) ? channelData : []) as Array<{ channelId: string | null; _count: { _all: number }; _sum: { amountCents?: number | null } }>);
setChannelStats(qualityData.channels.map((item) => ({ channelId: item.channelId, channelName: item.channelName, total: item.total })));
setError('');
})
.catch((failure: Error) => setError(failure.message || '统计数据加载失败'));
@@ -35,7 +36,7 @@ export function AdminAnalyticsPage() {
}), [tenantStats]);
const channelOption = useMemo(() => createPieOption({
data: channelStats.map((item) => ({ name: item.channelId ?? '未分配通道', value: item._count._all })),
data: channelStats.map((item) => ({ name: item.channelName || item.channelId, value: item.total })),
}), [channelStats]);
return (
@@ -44,7 +45,16 @@ export function AdminAnalyticsPage() {
<div>
<Breadcrumb items={['数据统计']} />
</div>
<Button icon={<BarChart3 size={16} />} onClick={loadData} variant="ghost"></Button>
<div className="page-actions">
<Input
aria-label="通道占比统计日期"
max={shanghaiDateKey()}
onChange={(event) => setStatisticsDate(event.target.value)}
type="date"
value={statisticsDate}
/>
<Button icon={<BarChart3 size={16} />} onClick={loadData} variant="ghost"></Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
@@ -81,7 +91,7 @@ export function AdminAnalyticsPage() {
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
<p className="muted">{statisticsDate} </p>
</div>
<Tag tone="accent"></Tag>
</div>
@@ -91,3 +101,14 @@ export function AdminAnalyticsPage() {
</section>
);
}
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')}`;
}
+17 -12
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelQualityStat, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
@@ -141,7 +141,11 @@ function resolveChannelStatus(channel: AdminChannel, connections: CmppConnection
return 'connecting';
}
function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[] = channel.connectionStates ?? []): SmsChannel {
function mapApiChannel(
channel: AdminChannel,
connections: CmppConnectionState[] = channel.connectionStates ?? [],
quality?: ChannelQualityStat,
): SmsChannel {
return {
id: channel.id,
name: channel.name,
@@ -149,13 +153,13 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[]
sendRegion: channel.sendRegion ?? '全国',
unitPrice: channel.unitPrice,
status: resolveChannelStatus(channel, connections),
total: 0,
successRate: 0,
successCount: 0,
unknownRate: 0,
unknownCount: 0,
failureRate: 0,
failureCount: 0,
total: quality?.total ?? 0,
successRate: quality?.successRate ?? 0,
successCount: quality?.successCount ?? 0,
unknownRate: quality?.unknownRate ?? 0,
unknownCount: quality?.unknownCount ?? 0,
failureRate: quality?.failureRate ?? 0,
failureCount: quality?.failureCount ?? 0,
gatewayHost: channel.gatewayHost,
gatewayPort: String(channel.gatewayPort),
businessCode: String(channel.config?.serviceId ?? 'SMS'),
@@ -486,13 +490,14 @@ export function AdminChannelsPage() {
const pageSize = 10;
function loadChannels() {
adminApi.listChannels()
.then(async (items) => {
Promise.all([adminApi.listChannels(), adminApi.getSendQuality()])
.then(async ([items, quality]) => {
const visibleChannels = items.filter((item) => item.status !== 'deleted');
const connections = await Promise.all(visibleChannels.map((channel) =>
adminApi.listChannelConnections(channel.id).catch(() => [] as CmppConnectionState[]),
));
setChannels(visibleChannels.map((item, index) => mapApiChannel(item, connections[index])));
const qualityByChannel = new Map(quality.channels.map((item) => [item.channelId, item]));
setChannels(visibleChannels.map((item, index) => mapApiChannel(item, connections[index], qualityByChannel.get(item.id))));
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道列表加载失败'));
+48 -43
View File
@@ -3,9 +3,7 @@ import {
BarChart3,
DollarSign,
FileCheck2,
RadioTower,
ShieldCheck,
Users,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import {
@@ -17,7 +15,7 @@ import {
Tag,
type TableColumn,
} from '@/components/ui';
import { adminApi, type DashboardResponse } from '@/api/adminApi';
import { adminApi, type DashboardResponse, type SendQualityResponse, type SignatureQualityStat } from '@/api/adminApi';
import { createBarOption, createLineOption } from '@/theme/chartOptions';
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
@@ -46,15 +44,15 @@ function formatCount(value: number) {
export function AdminHome() {
const navigate = useNavigate();
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
const [error, setError] = useState('');
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
const [channels, setChannels] = useState<Array<{ id: string; name: string; status: string; rateLimitPerSecond: number }>>([]);
useEffect(() => {
Promise.all([adminApi.getDashboard(), adminApi.listChannels()])
.then(([nextDashboard, nextChannels]) => {
Promise.all([adminApi.getDashboard(), adminApi.getSendQuality()])
.then(([nextDashboard, nextQuality]) => {
setDashboard(nextDashboard);
setChannels(nextChannels);
setQuality(nextQuality);
})
.catch((err) => {
setError(err instanceof Error ? err.message : '运营看板加载失败');
@@ -81,7 +79,7 @@ export function AdminHome() {
const totalSend = dashboard?.today.sent ?? 0;
const averageSuccessRate = dashboard?.today.successRate ?? 0;
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
const activeSignatureCount = new Set(quality?.signatures.map((item) => item.signatureId) ?? []).size;
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0, total: 0 };
@@ -133,25 +131,32 @@ export function AdminHome() {
},
];
const channelColumns: Array<TableColumn<(typeof channels)[number]>> = [
{ key: 'name', title: '通道名称', render: (record) => <strong>{record.name}</strong> },
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status}</Tag> },
{ key: 'rateLimitPerSecond', title: '限速', align: 'right', render: (record) => `${record.rateLimitPerSecond}/s` },
const signatureColumns: Array<TableColumn<SignatureQualityStat>> = [
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
{ 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) },
{ key: 'unknownCount', title: '未知', align: 'right', render: (record) => formatCount(record.unknownCount) },
{ key: 'failureCount', title: '失败', align: 'right', render: (record) => formatCount(record.failureCount) },
{ key: 'successRate', title: '成功率', align: 'right', render: (record) => `${record.successRate.toFixed(1)}%` },
{ key: 'averageArrivalMs', title: '平均到达', align: 'right', render: (record) => record.averageArrivalMs === null || record.averageArrivalMs === undefined ? '-' : `${(record.averageArrivalMs / 1000).toFixed(1)}` },
];
const plainSignatureQuality = quality?.signatures.filter((item) => !item.hasDrainage) ?? [];
const drainageSignatureQuality = quality?.signatures.filter((item) => item.hasDrainage) ?? [];
return (
<section className="page-stack admin-dashboard">
<div className="overview-hero admin-dashboard-hero">
<div>
<Breadcrumb items={['数据概览']} />
<p className="muted"></p>
<p className="muted"></p>
</div>
<div className="page-actions">
<Button icon={<FileCheck2 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
</Button>
<Button icon={<RadioTower size={16} />} onClick={() => navigate('/admin/channels')}>
<Button onClick={() => navigate('/admin/monitor')}>
</Button>
</div>
</div>
@@ -173,9 +178,9 @@ export function AdminHome() {
<small></small>
</div>
<div className="surface metric-card">
<span>线</span>
<strong>{activeConnectionCount}</strong>
<small>Gateway </small>
<span></span>
<strong>{activeSignatureCount}</strong>
<small></small>
</div>
</div>
{error ? <div className="surface ui-table__empty">{error}</div> : null}
@@ -193,6 +198,29 @@ export function AdminHome() {
</div>
</div>
<div className="overview-grid admin-signature-rank-grid">
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2> - </h2>
<p className="muted"></p>
</div>
<Tag tone="info">{plainSignatureQuality.length} </Tag>
</div>
<Table columns={signatureColumns} data={plainSignatureQuality} emptyText="今日暂无不含引流的签名发送记录" rowKey="id" />
</div>
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2> - </h2>
<p className="muted"></p>
</div>
<Tag tone="accent">{drainageSignatureQuality.length} </Tag>
</div>
<Table columns={signatureColumns} data={drainageSignatureQuality} emptyText="今日暂无含引流的签名发送记录" rowKey="id" />
</div>
</div>
<div className="surface section-stack">
<div className="section-heading">
<div>
@@ -206,21 +234,7 @@ export function AdminHome() {
<Table columns={enterpriseColumns} data={enterpriseSpendRanks} rowKey="id" />
</div>
<div className="overview-grid">
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2></h2>
<p className="muted"></p>
</div>
<Button onClick={() => navigate('/admin/channels')} size="sm" variant="ghost">
</Button>
</div>
<Table columns={channelColumns} data={channels} rowKey="id" />
</div>
<div className="surface section-stack">
<div className="surface section-stack">
<div className="section-heading">
<div>
<h2></h2>
@@ -263,15 +277,7 @@ export function AdminHome() {
</div>
</div>
<div className="mini-status-card">
<Users size={22} />
<div>
<span></span>
<strong>{activeConnectionCount}</strong>
<small>线</small>
</div>
</div>
<div className="mini-status-card">
<RadioTower size={22} />
<ShieldCheck size={22} />
<div>
<span></span>
<strong>{downstreamAlertCount} </strong>
@@ -279,7 +285,6 @@ export function AdminHome() {
</div>
</div>
</div>
</div>
</div>
<Modal