This commit is contained in:
+113
-61
@@ -1,87 +1,139 @@
|
||||
.admin-dashboard .admin-dashboard-hero {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric {
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
.admin-dashboard .overview-grid--three {
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-dashboard .mini-status-card {
|
||||
height: auto;
|
||||
min-height: 96px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.admin-dashboard .mini-status-card div {
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric-panel {
|
||||
min-width: 0;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric__heading {
|
||||
.admin-dashboard .home-panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric__category {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
min-height: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric__number {
|
||||
.admin-dashboard .home-panel-heading h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric-panel dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 5px 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric-row dt {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric-row dd {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin: 16px 0 10px;
|
||||
color: #111827;
|
||||
font-variant-numeric: tabular-nums;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric__number strong {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric__number span {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric__number.is-negative {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric p {
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (width <= 1400px) {
|
||||
.admin-dashboard .home-metrics {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
.admin-dashboard .home-metric-row strong {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric-row dd span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric-row.is-success {
|
||||
padding-inline: 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-success-soft);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric-row.is-success dt {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric-row.is-negative {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metrics-note {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.admin-dashboard .home-metric-panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-panel-heading {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric-row strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 700px) {
|
||||
@media (width <= 900px) {
|
||||
.admin-dashboard .home-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-dashboard .overview-grid--three {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric__category {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-dashboard .home-metric__number strong {
|
||||
font-size: 23px;
|
||||
}
|
||||
}
|
||||
|
||||
+118
-170
@@ -1,17 +1,18 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, 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 { HomeMetrics } from './home/HomeMetrics';
|
||||
import { homeApi } from '@/api/admin/home.api';
|
||||
import type { HomeSummary } from '@/api/types/home';
|
||||
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
||||
|
||||
type EnterpriseSpendRank = {
|
||||
id: string;
|
||||
enterprise: string;
|
||||
todaySpend: number;
|
||||
todayReturned: number;
|
||||
balanceStatus: '充足' | '紧张' | '欠费';
|
||||
availableBalance: number;
|
||||
};
|
||||
@@ -32,22 +33,45 @@ 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 [dashboard, setDashboard] = useState<HomeSummary | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const request = useRef<AbortController | null>(null);
|
||||
const dateRef = useRef<string | 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);
|
||||
});
|
||||
dateRef.current = dashboard?.businessDate ?? null;
|
||||
}, [dashboard]);
|
||||
const refresh = useCallback(async () => {
|
||||
request.current?.abort();
|
||||
const controller = new AbortController();
|
||||
request.current = controller;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await homeApi.summary(controller.signal);
|
||||
if (!controller.signal.aborted) setDashboard(result);
|
||||
} catch (e) {
|
||||
if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '运营看板加载失败');
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const timer = setInterval(() => {
|
||||
if (
|
||||
dateRef.current &&
|
||||
new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date()) !== dateRef.current
|
||||
)
|
||||
void refresh();
|
||||
}, 30_000);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
request.current?.abort();
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const enterpriseSpendRanks = useMemo<EnterpriseSpendRank[]>(() => {
|
||||
return (dashboard?.enterpriseSpendRanks ?? []).map((account) => {
|
||||
@@ -57,6 +81,7 @@ export function AdminHome() {
|
||||
id: account.tenantId,
|
||||
enterprise: account.tenantName,
|
||||
todaySpend,
|
||||
todayReturned: moneyUnitsToYuan(account.todayReturnedCents),
|
||||
availableBalance,
|
||||
balanceStatus: (availableBalance <= 0
|
||||
? '欠费'
|
||||
@@ -67,13 +92,6 @@ export function AdminHome() {
|
||||
});
|
||||
}, [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,
|
||||
@@ -84,41 +102,11 @@ export function AdminHome() {
|
||||
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',
|
||||
width: '260px',
|
||||
title: '企业名称',
|
||||
render: (record) => (
|
||||
<div>
|
||||
@@ -129,23 +117,34 @@ export function AdminHome() {
|
||||
},
|
||||
{
|
||||
key: 'todaySpend',
|
||||
width: '155px',
|
||||
title: '今日消费(元)',
|
||||
align: 'right',
|
||||
render: (record) => <MoneyText>¥{formatCurrency(record.todaySpend)}</MoneyText>,
|
||||
},
|
||||
{
|
||||
key: 'todayReturned',
|
||||
title: '今日返还金额(元)',
|
||||
width: '165px',
|
||||
align: 'right',
|
||||
render: (record) => <MoneyText>¥{formatCurrency(record.todayReturned)}</MoneyText>,
|
||||
},
|
||||
{
|
||||
key: 'availableBalance',
|
||||
width: '140px',
|
||||
title: '可用余额',
|
||||
align: 'right',
|
||||
render: (record) => formatCount(record.availableBalance),
|
||||
},
|
||||
{
|
||||
key: 'balanceStatus',
|
||||
width: '110px',
|
||||
title: '余额状态',
|
||||
render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
width: '100px',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
@@ -156,14 +155,46 @@ export function AdminHome() {
|
||||
},
|
||||
];
|
||||
|
||||
function exportRanks() {
|
||||
const escape = (value: string | number) =>
|
||||
'"' +
|
||||
String(value)
|
||||
.replace(/^(?:\s*[=+\-@]|[\t\r\n])/, "'$&")
|
||||
.replace(/"/g, '""') +
|
||||
'"';
|
||||
const rows = [
|
||||
['排名', '企业名称', '今日消费(元)', '今日返还金额(元)', '可用余额', '余额状态'],
|
||||
...enterpriseSpendRanks.map((r, i) => [
|
||||
i + 1,
|
||||
r.enterprise,
|
||||
formatCurrency(r.todaySpend),
|
||||
formatCurrency(r.todayReturned),
|
||||
formatCurrency(r.availableBalance),
|
||||
r.balanceStatus,
|
||||
]),
|
||||
];
|
||||
const url = URL.createObjectURL(
|
||||
new Blob(['\uFEFF' + rows.map((row) => row.map(escape).join(',')).join('\r\n')], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
}),
|
||||
);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `企业消费排行-${dashboard?.businessDate}.csv`;
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
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 onClick={() => void refresh()} disabled={loading}>
|
||||
{loading ? '刷新中…' : '刷新'}
|
||||
</Button>
|
||||
<Button icon={<FileCheck2 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
|
||||
处理审核
|
||||
</Button>
|
||||
@@ -171,114 +202,20 @@ export function AdminHome() {
|
||||
</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} />
|
||||
<p className="home-metrics-note" role="status">
|
||||
{dashboard
|
||||
? `${dashboard.businessDate} · 北京时间 · 统计更新于 ${new Date(dashboard.dataThrough).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}${dashboard.processing ? ' · 统计处理中,部分数据尚未更新' : ''}${dashboard.timeSourceCoverage.approximate || dashboard.timeSourceCoverage.incomplete ? ' · 含历史近似时间或待核对记录' : ''}`
|
||||
: loading
|
||||
? '正在加载运营数据…'
|
||||
: '数据暂不可用'}
|
||||
</p>
|
||||
{error && (
|
||||
<div className="surface" role="alert">
|
||||
{error}
|
||||
{dashboard ? ';仍显示上次成功查询的数据,请注意日期和更新时间。' : ''}
|
||||
</div>
|
||||
<div className="surface chart-card">
|
||||
<h2>审核处理速度</h2>
|
||||
<p className="muted">展示今日各项已处理审核数量,以及从提交到审核完成的平均时长。</p>
|
||||
<Chart height={300} option={auditSpeedOption} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HomeMetrics key={dashboard?.snapshotToken ?? 'empty'} data={dashboard} />
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading">
|
||||
@@ -286,11 +223,16 @@ export function AdminHome() {
|
||||
<h2>今日企业消费排行</h2>
|
||||
<p className="muted">来自真实账户、充值和消息金额聚合。</p>
|
||||
</div>
|
||||
<Button icon={<DollarSign size={16} />} size="sm" variant="ghost">
|
||||
<Button icon={<DollarSign size={16} />} size="sm" variant="ghost" disabled={!dashboard} onClick={exportRanks}>
|
||||
导出排行
|
||||
</Button>
|
||||
</div>
|
||||
<Table columns={enterpriseColumns} data={enterpriseSpendRanks} rowKey="id" />
|
||||
<Table
|
||||
columns={enterpriseColumns}
|
||||
data={enterpriseSpendRanks}
|
||||
rowKey="id"
|
||||
emptyText={dashboard ? '暂无企业消费记录' : '企业消费数据暂不可用'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
@@ -305,33 +247,33 @@ export function AdminHome() {
|
||||
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-audit')} variant="ghost">
|
||||
<FileCheck2 size={22} />
|
||||
<span>企业认证待审</span>
|
||||
<strong>{pendingAudits.enterpriseCertifications} 条</strong>
|
||||
<strong>{dashboard ? 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>
|
||||
<strong>{dashboard ? pendingAudits.smsAudits : '—'} 条</strong>
|
||||
</Button>
|
||||
<Button className="mini-status-card" onClick={() => navigate('/admin/templates')} variant="ghost">
|
||||
<FileCheck2 size={22} />
|
||||
<span>模板待审</span>
|
||||
<strong>{pendingAudits.templates} 条</strong>
|
||||
<strong>{dashboard ? pendingAudits.templates : '—'} 条</strong>
|
||||
</Button>
|
||||
<Button className="mini-status-card" onClick={() => navigate('/admin/signatures')} variant="ghost">
|
||||
<FileCheck2 size={22} />
|
||||
<span>签名待审</span>
|
||||
<strong>{pendingAudits.signatures} 条</strong>
|
||||
<strong>{dashboard ? 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>
|
||||
<strong>{dashboard ? pendingAudits.drainageInfos : '—'} 条</strong>
|
||||
</Button>
|
||||
<div className="mini-status-card">
|
||||
<ShieldCheck size={22} />
|
||||
<div>
|
||||
<span>平均等待</span>
|
||||
<strong>{dashboard?.taskCount ?? 0} 任务</strong>
|
||||
<strong>{dashboard ? dashboard.taskCount : '—'} 任务</strong>
|
||||
<small>真实批量任务总数。</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -339,7 +281,7 @@ export function AdminHome() {
|
||||
<ShieldCheck size={22} />
|
||||
<div>
|
||||
<span>下游投递告警</span>
|
||||
<strong>{downstreamAlertCount} 条</strong>
|
||||
<strong>{dashboard ? downstreamAlertCount : '—'} 条</strong>
|
||||
<small>积压过久或近期失败。</small>
|
||||
</div>
|
||||
</div>
|
||||
@@ -386,6 +328,12 @@ export function AdminHome() {
|
||||
<MoneyText>¥{formatCurrency(selectedEnterprise.todaySpend)}</MoneyText>
|
||||
</strong>
|
||||
</div>
|
||||
<div className="ui-detail-info-grid__item">
|
||||
<span>今日返还金额</span>
|
||||
<strong>
|
||||
<MoneyText>¥{formatCurrency(selectedEnterprise.todayReturned)}</MoneyText>
|
||||
</strong>
|
||||
</div>
|
||||
<div className="ui-detail-info-grid__item">
|
||||
<span>可用余额</span>
|
||||
<strong>{formatCount(selectedEnterprise.availableBalance)}</strong>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, MoneyText, Table, type TableColumn } from '@/components/ui';
|
||||
import { homeApi } from '@/api/admin/home.api';
|
||||
import type { HomeSummary, HomeReceiptRow, HomeRevenueRow } from '@/api/types/home';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
|
||||
const count = (value?: number) => (value === undefined ? '—' : value.toLocaleString('zh-CN'));
|
||||
const rate = (value?: number) => (value === undefined ? '—' : `${value.toFixed(1)}%`);
|
||||
const money = (value?: number) => (value === undefined ? '—' : `¥${formatCents(value)}`);
|
||||
|
||||
export function HomeMetrics({ data }: { data: HomeSummary | null }) {
|
||||
const [open, setOpen] = useState<'receipt' | 'revenue' | null>(null);
|
||||
const [receipts, setReceipts] = useState<HomeReceiptRow[] | null>(null);
|
||||
const [revenue, setRevenue] = useState<HomeRevenueRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const request = useRef<AbortController | null>(null);
|
||||
useEffect(() => () => request.current?.abort(), []);
|
||||
async function expand(kind: 'receipt' | 'revenue', retry = false) {
|
||||
if (!data) return;
|
||||
request.current?.abort();
|
||||
setError('');
|
||||
if (open === kind && !retry) {
|
||||
setOpen(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setOpen(kind);
|
||||
if (kind === 'receipt' ? receipts : revenue) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
request.current = controller;
|
||||
setLoading(true);
|
||||
try {
|
||||
if (kind === 'receipt') {
|
||||
const result = await homeApi.receipts(data.snapshotToken, controller.signal);
|
||||
if (!controller.signal.aborted) setReceipts(result.items);
|
||||
} else {
|
||||
const result = await homeApi.revenue(data.snapshotToken, controller.signal);
|
||||
if (!controller.signal.aborted) setRevenue(result.items);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '日期明细加载失败');
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
}
|
||||
const value = data?.today;
|
||||
const groups = [
|
||||
{
|
||||
title: '今日业务',
|
||||
rows: [
|
||||
['今日业务短信数量', count(value?.sent), '条'],
|
||||
['今日发送成功数量', count(value?.delivered), '条'],
|
||||
['总体成功率', rate(value?.successRate), ''],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '今日回执',
|
||||
kind: 'receipt' as const,
|
||||
rows: [
|
||||
['今日回执分片总数', count(value?.receiptUnits), '片'],
|
||||
['今日回执成功分片数', count(value?.successUnits), '片'],
|
||||
['今日回执成功率', rate(value?.receiptSuccessRate), ''],
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '今日营业状况',
|
||||
kind: 'revenue' as const,
|
||||
rows: [
|
||||
['今日营收金额', money(value?.revenueCents), ''],
|
||||
['今日利润', money(value?.profitCents), ''],
|
||||
['今日利润率', rate(value?.profitRate), ''],
|
||||
],
|
||||
},
|
||||
];
|
||||
const receiptColumns: TableColumn<HomeReceiptRow>[] = [
|
||||
{ key: 'date', title: '原提交日期', width: '300px', render: (r) => `${r.submitDate} 提交 → 今日回执` },
|
||||
{ key: 'total', title: '今日回执分片总数', width: '180px', align: 'right', render: (r) => `${count(r.total)} 片` },
|
||||
{
|
||||
key: 'success',
|
||||
title: '今日回执成功分片数',
|
||||
width: '190px',
|
||||
align: 'right',
|
||||
render: (r) => `${count(r.success)} 片`,
|
||||
},
|
||||
{ key: 'rate', title: '今日回执成功率', width: '150px', align: 'right', render: (r) => rate(r.rate) },
|
||||
];
|
||||
const revenueColumns: TableColumn<HomeRevenueRow>[] = [
|
||||
{ key: 'date', title: '原提交日期', width: '300px', render: (r) => `${r.submitDate} 提交 → 今日成功回执` },
|
||||
{
|
||||
key: 'revenue',
|
||||
title: '今日回执归属营收',
|
||||
width: '180px',
|
||||
align: 'right',
|
||||
render: (r) => <MoneyText>{money(r.revenueCents)}</MoneyText>,
|
||||
},
|
||||
{
|
||||
key: 'profit',
|
||||
title: '今日回执归属利润',
|
||||
width: '180px',
|
||||
align: 'right',
|
||||
render: (r) => <MoneyText>{money(r.profitCents)}</MoneyText>,
|
||||
},
|
||||
{ key: 'rate', title: '对应利润率', width: '150px', align: 'right', render: (r) => rate(r.rate) },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<div className="home-metrics" aria-busy={!data}>
|
||||
{groups.map((group) => (
|
||||
<section key={group.title} className="home-metric-panel" aria-label={group.title}>
|
||||
<div className="home-panel-heading">
|
||||
<h2>{group.title}</h2>
|
||||
{group.kind && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!data}
|
||||
aria-expanded={open === group.kind}
|
||||
onClick={() => void expand(group.kind)}
|
||||
>
|
||||
{open === group.kind ? '收起明细' : '按提交日查看'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<dl>
|
||||
{group.rows.map(([label, number, unit], index) => (
|
||||
<div
|
||||
key={label}
|
||||
className={`home-metric-row${group.kind === 'receipt' && index === 1 ? ' is-success' : ''}${number.startsWith('-') || number.startsWith('¥-') ? ' is-negative' : ''}`}
|
||||
>
|
||||
<dt>{label}</dt>
|
||||
<dd>
|
||||
<strong>{number}</strong>
|
||||
{unit && <span>{unit}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<p className="home-metrics-note">
|
||||
回执及营业指标按今日收到的回执统计,包含近四日提交的短信;长短信全部成功才计入成功分片。
|
||||
</p>
|
||||
{open && (
|
||||
<section className="surface section-stack" aria-label={open === 'receipt' ? '回执日期明细' : '营业日期明细'}>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>{open === 'receipt' ? '今日回执' : '今日营业状况'} · 按原提交日期查看</h2>
|
||||
<p className="muted">
|
||||
仅统计今日回执
|
||||
{open === 'revenue' ? '归属的收益,不是对应提交日的全天营业额。' : ',按短信最初提交日期分组。'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
request.current?.abort();
|
||||
setOpen(null);
|
||||
}}
|
||||
>
|
||||
收起明细
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p role="status">正在查询日期明细…</p>
|
||||
) : error ? (
|
||||
<div role="alert">
|
||||
{error}
|
||||
<Button size="sm" onClick={() => void expand(open, true)}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
) : open === 'receipt' ? (
|
||||
<Table columns={receiptColumns} data={receipts ?? []} rowKey="submitDate" pagination={false} />
|
||||
) : (
|
||||
<Table columns={revenueColumns} data={revenue ?? []} rowKey="submitDate" pagination={false} />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user