399 lines
14 KiB
TypeScript
399 lines
14 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 './AdminHome.css';
|
|
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 todayReturned = moneyUnitsToYuan(dashboard?.today.returnedCents);
|
|
const todayBilled = moneyUnitsToYuan(dashboard?.today.billedCents);
|
|
const todayProfit = moneyUnitsToYuan(dashboard?.today.profitCents);
|
|
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="home-metrics" aria-busy={!dashboard && !error}>
|
|
{[
|
|
{
|
|
label: '今日发送总量',
|
|
value: formatCount(totalSend),
|
|
unit: '条',
|
|
note: '业务短信',
|
|
group: '发送',
|
|
icon: <BarChart3 size={18} />,
|
|
},
|
|
{
|
|
label: '今日消息分片数',
|
|
value: formatCount(dashboard?.today.segmentCount ?? 0),
|
|
unit: '片',
|
|
note: '实际消息分片',
|
|
group: '发送',
|
|
},
|
|
{
|
|
label: '总体成功率',
|
|
value: averageSuccessRate.toFixed(1),
|
|
unit: '%',
|
|
note: '送达成功 / 今日总量',
|
|
group: '质量',
|
|
icon: <ShieldCheck size={18} />,
|
|
},
|
|
{
|
|
label: '今日到达率',
|
|
value: (dashboard?.today.arrivalRate ?? 0).toFixed(1),
|
|
unit: '%',
|
|
note: '到达分片 / 发送总分片',
|
|
group: '质量',
|
|
},
|
|
{
|
|
label: '今日活跃签名',
|
|
value: formatCount(activeSignatureCount),
|
|
unit: '个',
|
|
note: '今日有真实发送记录',
|
|
group: '发送',
|
|
},
|
|
{
|
|
label: '今日消费金额',
|
|
value: formatCurrency(todaySpend),
|
|
unit: '元',
|
|
note: '今日消息消费',
|
|
group: '经营',
|
|
icon: <DollarSign size={18} />,
|
|
},
|
|
{
|
|
label: '今日返还金额',
|
|
value: formatCurrency(todayReturned),
|
|
unit: '元',
|
|
note: '今日返还流水',
|
|
group: '经营',
|
|
},
|
|
{
|
|
label: '今日计收金额',
|
|
value: formatCurrency(todayBilled),
|
|
unit: '元',
|
|
note: '成功计费条数 × 客户价',
|
|
group: '经营',
|
|
},
|
|
{
|
|
label: '今日利润',
|
|
value: formatCurrency(todayProfit),
|
|
unit: '元',
|
|
note: '计收金额 − 成功分片通道成本',
|
|
group: '经营',
|
|
danger: todayProfit < 0,
|
|
},
|
|
{
|
|
label: '今日利润率',
|
|
value: (dashboard?.today.profitRate ?? 0).toFixed(1),
|
|
unit: '%',
|
|
note: '今日利润 / 今日计收金额',
|
|
group: '经营',
|
|
danger: (dashboard?.today.profitRate ?? 0) < 0,
|
|
},
|
|
].map((metric) => (
|
|
<article className="home-metric" key={metric.label}>
|
|
<div className="home-metric__heading">
|
|
<span>{metric.label}</span>
|
|
<span className="home-metric__category">
|
|
{metric.icon}
|
|
{metric.group}
|
|
</span>
|
|
</div>
|
|
<div className={metric.danger ? 'home-metric__number is-negative' : 'home-metric__number'}>
|
|
<strong>{dashboard ? metric.value : '—'}</strong>
|
|
<span>{metric.unit}</span>
|
|
</div>
|
|
<p>{dashboard ? metric.note : error ? '数据暂不可用' : '正在加载…'}</p>
|
|
</article>
|
|
))}
|
|
</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>
|
|
);
|
|
}
|