Files
lislgosms/src/apps/admin/AdminHome.tsx
T

306 lines
12 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import {
BarChart3,
DollarSign,
FileCheck2,
ShieldCheck,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import {
Breadcrumb,
Button,
Modal,
MoneyText,
Table,
Tag,
type TableColumn,
} from '@/components/ui';
import { Chart } from '@/components/ui/Chart';
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
type EnterpriseSpendRank = {
id: string;
enterprise: string;
todaySpend: number;
balanceStatus: '充足' | '紧张' | '欠费';
availableBalance: number;
};
const balanceTone = {
充足: 'success',
紧张: 'warning',
欠费: 'danger',
} as const;
function formatCurrency(value: number) {
return formatAmount(value);
}
function formatCount(value: number) {
return value.toLocaleString('zh-CN');
}
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);
useEffect(() => {
Promise.all([adminApi.getDashboard(), adminApi.getSendQuality()])
.then(([nextDashboard, nextQuality]) => {
setDashboard(nextDashboard);
setQuality(nextQuality);
})
.catch((err) => {
setError(err instanceof Error ? err.message : '运营看板加载失败');
setDashboard(null);
});
}, []);
const enterpriseSpendRanks = useMemo<EnterpriseSpendRank[]>(() => {
return (dashboard?.enterpriseSpendRanks ?? []).map((account) => {
const todaySpend = moneyUnitsToYuan(account.todaySpendCents);
const availableBalance = moneyUnitsToYuan(account.balanceCents + account.creditCents);
return {
id: account.tenantId,
enterprise: account.tenantName,
todaySpend,
availableBalance,
balanceStatus: (availableBalance <= 0 ? '欠费' : availableBalance < 100 ? '紧张' : '充足') as EnterpriseSpendRank['balanceStatus'],
};
});
}, [dashboard]);
const totalSend = dashboard?.today.sent ?? 0;
const averageSuccessRate = dashboard?.today.successRate ?? 0;
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
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 };
const sendTrendOption = useMemo(
() => createLineOption({
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
series: [
{ name: '提交总条数', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
{ name: '成功条数', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
],
}),
[dashboard],
);
const auditSpeedOption = useMemo(
() => createDualAxisBarLineOption({
labels: dashboard?.auditProcessingSpeed.map((item) => item.label) ?? [],
bar: {
name: '审核数量',
data: dashboard?.auditProcessingSpeed.map((item) => item.count) ?? [],
},
line: {
name: '平均处理时长(分钟)',
data: dashboard?.auditProcessingSpeed.map((item) => (
item.averageProcessingMs == null ? null : Number((item.averageProcessingMs / 60_000).toFixed(1))
)) ?? [],
},
}),
[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) => <MoneyText>¥{formatCurrency(record.todaySpend)}</MoneyText> },
{ 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>
),
},
];
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 onClick={() => navigate('/admin/monitor')}>
查看发送监控
</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>{activeSignatureCount}</strong>
<small>当天有真实发送记录的签名</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={auditSpeedOption} />
</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="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">
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-audit')} variant="ghost">
<FileCheck2 size={22} />
<span>企业认证待审</span>
<strong>{pendingAudits.enterpriseCertifications} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/sms-audit')} variant="ghost">
<FileCheck2 size={22} />
<span>短信审核待审</span>
<strong>{pendingAudits.smsAudits} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/templates')} variant="ghost">
<FileCheck2 size={22} />
<span>模板待审</span>
<strong>{pendingAudits.templates} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/signatures')} variant="ghost">
<FileCheck2 size={22} />
<span>签名待审</span>
<strong>{pendingAudits.signatures} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/drainage-audits')} variant="ghost">
<FileCheck2 size={22} />
<span>引流信息待审</span>
<strong>{pendingAudits.drainageInfos} </strong>
</Button>
<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">
<ShieldCheck size={22} />
<div>
<span>下游投递告警</span>
<strong>{downstreamAlertCount} </strong>
<small>积压过久或近期失败。</small>
</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><MoneyText>¥{formatCurrency(selectedEnterprise.todaySpend)}</MoneyText></strong>
</div>
<div className="ui-detail-info-grid__item">
<span>可用余额</span>
<strong>{formatCount(selectedEnterprise.availableBalance)}</strong>
</div>
</div>
) : null}
</Modal>
</section>
);
}