fix: restore daily operations quality metrics
This commit is contained in:
@@ -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')}`;
|
||||
}
|
||||
|
||||
@@ -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 || '通道列表加载失败'));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user