315 lines
11 KiB
TypeScript
315 lines
11 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
BarChart3,
|
|
DollarSign,
|
|
FileCheck2,
|
|
RadioTower,
|
|
ShieldCheck,
|
|
Users,
|
|
} from 'lucide-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
Breadcrumb,
|
|
Button,
|
|
Chart,
|
|
Modal,
|
|
Table,
|
|
Tag,
|
|
type TableColumn,
|
|
} from '@/components/ui';
|
|
import { adminApi, type DashboardResponse } from '@/api/adminApi';
|
|
import { createBarOption, createLineOption } from '@/theme/chartOptions';
|
|
|
|
type EnterpriseSpendRank = {
|
|
id: string;
|
|
enterprise: string;
|
|
todaySpend: number;
|
|
balanceStatus: '充足' | '紧张' | '欠费';
|
|
availableBalance: number;
|
|
};
|
|
|
|
const balanceTone = {
|
|
充足: 'success',
|
|
紧张: 'warning',
|
|
欠费: 'danger',
|
|
} as const;
|
|
|
|
function formatCurrency(value: number) {
|
|
return value.toLocaleString('zh-CN', {
|
|
minimumFractionDigits: 3,
|
|
maximumFractionDigits: 3,
|
|
});
|
|
}
|
|
|
|
function formatCount(value: number) {
|
|
return value.toLocaleString('zh-CN');
|
|
}
|
|
|
|
export function AdminHome() {
|
|
const navigate = useNavigate();
|
|
const [dashboard, setDashboard] = useState<DashboardResponse | 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]) => {
|
|
setDashboard(nextDashboard);
|
|
setChannels(nextChannels);
|
|
})
|
|
.catch((err) => {
|
|
setError(err instanceof Error ? err.message : '运营看板加载失败');
|
|
setDashboard(null);
|
|
});
|
|
}, []);
|
|
|
|
const enterpriseSpendRanks = useMemo<EnterpriseSpendRank[]>(() => {
|
|
return (dashboard?.accounts ?? []).map((account) => {
|
|
const todaySpend = Math.abs(dashboard?.recentRecharges
|
|
.filter((item) => item.tenantId === account.tenantId)
|
|
.reduce((sum, item) => sum + item.amountCents, 0) ?? 0) / 100;
|
|
const availableBalance = (account.balanceCents + account.creditCents) / 100;
|
|
return {
|
|
id: account.tenantId,
|
|
enterprise: account.tenant?.name ?? account.tenantId,
|
|
todaySpend,
|
|
availableBalance,
|
|
balanceStatus: (availableBalance <= 0 ? '欠费' : availableBalance < 100 ? '紧张' : '充足') as EnterpriseSpendRank['balanceStatus'],
|
|
};
|
|
}).sort((left, right) => right.todaySpend - left.todaySpend);
|
|
}, [dashboard]);
|
|
|
|
const totalSend = dashboard?.today.sent ?? 0;
|
|
const averageSuccessRate = dashboard?.today.successRate ?? 0;
|
|
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
|
|
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
|
|
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
|
|
|
|
const sendTrendOption = useMemo(
|
|
() => createLineOption({
|
|
labels: ['今日'],
|
|
series: [
|
|
{ name: '提交量', data: [dashboard?.today.sent ?? 0] },
|
|
{ name: '成功量', data: [dashboard?.today.delivered ?? 0] },
|
|
],
|
|
}),
|
|
[dashboard],
|
|
);
|
|
|
|
const auditTrendOption = useMemo(
|
|
() => createBarOption({
|
|
labels: ['待审核'],
|
|
series: [
|
|
{ name: '待审', data: [dashboard?.pendingAuditCount ?? 0] },
|
|
],
|
|
}),
|
|
[dashboard],
|
|
);
|
|
|
|
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
|
|
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
|
|
{
|
|
key: 'enterprise',
|
|
title: '企业名称',
|
|
render: (record) => (
|
|
<div>
|
|
<strong>{record.enterprise}</strong>
|
|
<p className="text-caption">{record.id}</p>
|
|
</div>
|
|
),
|
|
},
|
|
{ key: 'todaySpend', title: '今日消费(元)', align: 'right', render: (record) => `¥${formatCurrency(record.todaySpend)}` },
|
|
{ key: 'availableBalance', title: '可用余额', align: 'right', render: (record) => formatCount(record.availableBalance) },
|
|
{ key: 'balanceStatus', title: '余额状态', render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag> },
|
|
{
|
|
key: 'actions',
|
|
title: '操作',
|
|
align: 'right',
|
|
render: (record) => (
|
|
<Button onClick={() => setSelectedEnterprise(record)} size="sm" variant="ghost">
|
|
查看详情
|
|
</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
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` },
|
|
];
|
|
|
|
return (
|
|
<section className="page-stack admin-dashboard">
|
|
<div className="overview-hero admin-dashboard-hero">
|
|
<div>
|
|
<Breadcrumb items={['数据概览']} />
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="dashboard-grid admin-metric-grid">
|
|
<div className="surface metric-card">
|
|
<span>今日发送总量</span>
|
|
<strong>{formatCount(totalSend)} 条</strong>
|
|
<small>来自真实短信记录聚合</small>
|
|
</div>
|
|
<div className="surface metric-card">
|
|
<span>总体成功率</span>
|
|
<strong>{averageSuccessRate.toFixed(1)}%</strong>
|
|
<small>delivered / 今日总量</small>
|
|
</div>
|
|
<div className="surface metric-card">
|
|
<span>今日消费</span>
|
|
<strong>¥{formatCurrency(todaySpend)}</strong>
|
|
<small>来自今日消息金额聚合</small>
|
|
</div>
|
|
<div className="surface metric-card">
|
|
<span>通道在线连接</span>
|
|
<strong>{activeConnectionCount}</strong>
|
|
<small>Gateway 连接状态回写</small>
|
|
</div>
|
|
</div>
|
|
{error ? <div className="surface ui-table__empty">{error}</div> : null}
|
|
|
|
<div className="chart-grid">
|
|
<div className="surface chart-card">
|
|
<h2>今日发送趋势</h2>
|
|
<p className="muted">按真实后端今日聚合展示提交量和成功量。</p>
|
|
<Chart height={300} option={sendTrendOption} />
|
|
</div>
|
|
<div className="surface chart-card">
|
|
<h2>审核处理趋势</h2>
|
|
<p className="muted">待审核数量来自真实审核聚合。</p>
|
|
<Chart height={300} option={auditTrendOption} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="surface section-stack">
|
|
<div className="section-heading">
|
|
<div>
|
|
<h2>今日企业消费排行</h2>
|
|
<p className="muted">来自真实账户、充值和消息金额聚合。</p>
|
|
</div>
|
|
<Button icon={<DollarSign size={16} />} size="sm" variant="ghost">
|
|
导出排行
|
|
</Button>
|
|
</div>
|
|
<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="section-heading">
|
|
<div>
|
|
<h2>运营状态</h2>
|
|
<p className="muted">当日关键流程状态汇总。</p>
|
|
</div>
|
|
<BarChart3 size={20} className="status-info" />
|
|
</div>
|
|
<div className="overview-grid overview-grid--three">
|
|
<div className="mini-status-card">
|
|
<FileCheck2 size={22} />
|
|
<div>
|
|
<span>待审核</span>
|
|
<strong>{dashboard?.pendingAuditCount ?? 0} 条</strong>
|
|
<small>模板、签名和企业认证。</small>
|
|
</div>
|
|
</div>
|
|
<div className="mini-status-card">
|
|
<ShieldCheck size={22} />
|
|
<div>
|
|
<span>平均等待</span>
|
|
<strong>{dashboard?.taskCount ?? 0} 任务</strong>
|
|
<small>真实批量任务总数。</small>
|
|
</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} />
|
|
<div>
|
|
<span>下游投递告警</span>
|
|
<strong>{downstreamAlertCount} 条</strong>
|
|
<small>积压过久或近期失败。</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<Modal
|
|
footer={(
|
|
<>
|
|
<Button onClick={() => setSelectedEnterprise(null)} variant="ghost">关闭</Button>
|
|
<Button onClick={() => navigate('/admin/recharge-records')}>查看充值记录</Button>
|
|
</>
|
|
)}
|
|
onClose={() => setSelectedEnterprise(null)}
|
|
open={Boolean(selectedEnterprise)}
|
|
title={(
|
|
<div className="ui-detail-title">
|
|
<h2>企业消费详情</h2>
|
|
<p>{selectedEnterprise?.id}</p>
|
|
</div>
|
|
)}
|
|
>
|
|
{selectedEnterprise ? (
|
|
<div className="ui-detail-info-grid">
|
|
<div className="ui-detail-info-grid__item">
|
|
<span>企业名称</span>
|
|
<strong>{selectedEnterprise.enterprise}</strong>
|
|
</div>
|
|
<div className="ui-detail-info-grid__item">
|
|
<span>企业ID</span>
|
|
<strong>{selectedEnterprise.id}</strong>
|
|
</div>
|
|
<div className="ui-detail-info-grid__item">
|
|
<span>余额状态</span>
|
|
<strong>
|
|
<Tag tone={balanceTone[selectedEnterprise.balanceStatus]}>{selectedEnterprise.balanceStatus}</Tag>
|
|
</strong>
|
|
</div>
|
|
<div className="ui-detail-info-grid__item">
|
|
<span>今日消费</span>
|
|
<strong>¥{formatCurrency(selectedEnterprise.todaySpend)}</strong>
|
|
</div>
|
|
<div className="ui-detail-info-grid__item">
|
|
<span>可用余额</span>
|
|
<strong>{formatCount(selectedEnterprise.availableBalance)}</strong>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</Modal>
|
|
</section>
|
|
);
|
|
}
|