feat: add reconciliation and quality reporting
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { adminApi, type AdminChannel, type DailyQualityReport, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Pagination, Select, Tabs, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
type QualityDimension = 'application' | 'channel' | 'signature' | 'drainage';
|
||||
const pageSize = 20;
|
||||
const dimensionLabels: Record<QualityDimension, string> = {
|
||||
application: '企业应用', channel: '通道', signature: '签名', drainage: '引流信息',
|
||||
};
|
||||
|
||||
export function AdminQualityReportsPage() {
|
||||
const [dimension, setDimension] = useState<QualityDimension>('application');
|
||||
const [rows, setRows] = useState<DailyQualityReport[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultDateRange());
|
||||
const [tenantId, setTenantId] = useState('');
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listTenants(), adminApi.listEnterpriseApplications(), adminApi.listChannels()])
|
||||
.then(([nextTenants, nextApplications, nextChannels]) => { setTenants(nextTenants); setApplications(nextApplications); setChannels(nextChannels); })
|
||||
.catch(() => { setTenants([]); setApplications([]); setChannels([]); });
|
||||
}, []);
|
||||
useEffect(() => { void loadData(); }, [dimension, page, dateRange.start, dateRange.end, tenantId, applicationId, channelId]);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await adminApi.listQualityReports({
|
||||
dimensionType: dimension, dateFrom: dateRange.start, dateTo: dateRange.end,
|
||||
tenantId: dimension === 'channel' ? undefined : tenantId || undefined,
|
||||
applicationId: dimension === 'channel' ? undefined : applicationId || undefined,
|
||||
channelId: dimension === 'channel' ? channelId || undefined : undefined,
|
||||
page, pageSize,
|
||||
});
|
||||
setRows(response.items);
|
||||
setTotal(response.total);
|
||||
} catch (failure) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError(failure instanceof Error ? failure.message : '发送质量报表加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const availableApplications = useMemo(() => applications.filter((application) => !tenantId || application.tenantId === tenantId), [applications, tenantId]);
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
function changeDimension(value: string) {
|
||||
setDimension(value as QualityDimension);
|
||||
setTenantId(''); setApplicationId(''); setChannelId(''); setPage(1);
|
||||
}
|
||||
|
||||
const reportPanel = (
|
||||
<div className="page-stack" style={{ marginTop: 16 }}>
|
||||
<div className="surface" style={{ display: 'grid', gap: 16, gridTemplateColumns: 'minmax(280px, 1.4fr) minmax(210px, 1fr) minmax(220px, 1fr) auto', padding: 20, alignItems: 'end' }}>
|
||||
<DateRangeInput label="发送日期" onChange={(value) => { setDateRange(value); setPage(1); }} value={dateRange} />
|
||||
{dimension === 'channel' ? <Select label="短信通道" onChange={(event) => { setChannelId(event.target.value); setPage(1); }} options={[{ label: '全部通道', value: '' }, ...channels.map((channel) => ({ label: channel.name, value: channel.id }))]} value={channelId} /> : <Select label="企业" onChange={(event) => { setTenantId(event.target.value); setApplicationId(''); setPage(1); }} options={[{ label: '全部企业', value: '' }, ...tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))]} value={tenantId} />}
|
||||
{dimension === 'channel' ? <div /> : <Select label="企业应用" onChange={(event) => { setApplicationId(event.target.value); setPage(1); }} options={[{ label: '全部应用', value: '' }, ...availableApplications.map((application) => ({ label: application.name, value: application.id }))]} value={applicationId} />}
|
||||
<Button icon={<Search size={16} />} onClick={() => void loadData()}>查询</Button>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<thead><tr><th>发送日期</th><th>{dimensionLabels[dimension]}</th><th>发送条数</th><th>成功条数</th><th>成功率</th><th>平均到达时长</th><th>生成时间</th></tr></thead>
|
||||
<tbody>
|
||||
{error ? <tr><td className="ui-table__empty" colSpan={7}>{error}</td></tr>
|
||||
: loading ? <tr><td className="ui-table__empty" colSpan={7}>正在加载真实发送质量数据...</td></tr>
|
||||
: rows.length === 0 ? <tr><td className="ui-table__empty" colSpan={7}>暂无已生成的发送质量报表</td></tr>
|
||||
: rows.map((row) => <tr key={row.id}><td>{row.reportDate.slice(0, 10)}</td><td><strong>{row.dimensionName}</strong>{row.tenantName ? <div className="muted">{row.tenantName}</div> : null}</td><td>{row.sentUnits.toLocaleString('zh-CN')}</td><td>{row.successUnits.toLocaleString('zh-CN')}</td><td>{(row.successRateBps / 100).toFixed(2)}%</td><td>{formatDuration(row.avgArrivalMs)}</td><td>{formatDateTime(row.generatedAt)}</td></tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination page={page} totalPages={totalPages} total={total} onPageChange={setPage} onPrevious={() => setPage((value) => Math.max(1, value - 1))} onNext={() => setPage((value) => Math.min(totalPages, value + 1))} previousDisabled={page <= 1} nextDisabled={page >= totalPages} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading"><div><Breadcrumb items={['报表对账', '发送质量报表']} /><h1>发送质量报表</h1></div><Tag tone="info">剔除最慢 5% · 每日重算 T-4~T-1</Tag></div>
|
||||
<Tabs value={dimension} onChange={changeDimension} items={(Object.keys(dimensionLabels) as QualityDimension[]).map((value) => ({ value, label: dimensionLabels[value], content: reportPanel }))} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(milliseconds?: number | null) {
|
||||
if (milliseconds === null || milliseconds === undefined) return '-';
|
||||
if (milliseconds < 1000) return `${milliseconds} 毫秒`;
|
||||
if (milliseconds < 60_000) return `${(milliseconds / 1000).toFixed(2)} 秒`;
|
||||
return `${(milliseconds / 60_000).toFixed(2)} 分钟`;
|
||||
}
|
||||
|
||||
function defaultDateRange(): DateRangeValue {
|
||||
const end = new Date(); end.setDate(end.getDate() - 1);
|
||||
const start = new Date(end); start.setDate(start.getDate() - 29);
|
||||
return { start: localDate(start), end: localDate(end) };
|
||||
}
|
||||
|
||||
function localDate(value: Date) {
|
||||
return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(value.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user