feat: 重构首页回执营业统计并增加企业返还金额
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-17 13:11:35 +08:00
parent 627fa7ec97
commit 5e4d644788
29 changed files with 1684 additions and 231 deletions
+14
View File
@@ -0,0 +1,14 @@
import { request, withQuery } from '../core/httpClient';
import type { HomeSummary, HomeBreakdown, HomeReceiptRow, HomeRevenueRow } from '../types/home';
export const homeApi = {
summary: (signal?: AbortSignal) => request<HomeSummary>('/admin/operations/home/summary', { signal }),
receipts: (snapshotToken: string, signal?: AbortSignal) =>
request<HomeBreakdown<HomeReceiptRow>>(withQuery('/admin/operations/home/receipt-breakdown', { snapshotToken }), {
signal,
}),
revenue: (snapshotToken: string, signal?: AbortSignal) =>
request<HomeBreakdown<HomeRevenueRow>>(withQuery('/admin/operations/home/revenue-breakdown', { snapshotToken }), {
signal,
}),
};
+39
View File
@@ -0,0 +1,39 @@
export type HomeSummary = {
snapshotToken: string;
businessDate: string;
asOf: string;
dataThrough: string;
processing: boolean;
timeSourceCoverage: { approximate: number; incomplete: number };
today: {
sent: number;
delivered: number;
successRate: number;
receiptUnits: number;
successUnits: number;
receiptSuccessRate: number;
revenueCents: number;
profitCents: number;
profitRate: number;
};
taskCount: number;
pendingAudits: {
enterpriseCertifications: number;
smsAudits: number;
templates: number;
signatures: number;
drainageInfos: number;
};
downstreamDeliverySummary: { alertCount: number };
enterpriseSpendRanks: Array<{
tenantId: string;
tenantName: string;
todaySpendCents: number;
todayReturnedCents: number;
balanceCents: number;
creditCents: number;
}>;
};
export type HomeReceiptRow = { submitDate: string; total: number; success: number; rate: number };
export type HomeRevenueRow = { submitDate: string; revenueCents: number; profitCents: number; rate: number };
export type HomeBreakdown<T> = { snapshotToken: string; businessDate: string; items: T[] };
+113 -61
View File
@@ -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
View File
@@ -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>
+187
View File
@@ -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>
)}
</>
);
}