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

124 lines
8.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react';
import { Download, 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('');
const [exporting, setExporting] = useState(false);
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);
}
}
async function exportData() {
setExporting(true); setError('');
try { downloadBlob(await adminApi.exportQualityReports({ 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 }), '发送质量报表.csv'); }
catch (failure) { setError(failure instanceof Error ? failure.message : '发送质量报表导出失败'); }
finally { setExporting(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 admin-report-filter-grid admin-report-filter-grid--quality">
<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><div className="page-actions"><Button disabled={exporting} icon={<Download size={16} />} onClick={() => void exportData()} size="sm" variant="secondary">{exporting ? '导出中...' : '导出报表'}</Button><Tag tone="info">剔除最慢 5% · 每日重算 T-4T-1</Tag></div></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')}`;
}
function downloadBlob(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = fileName; anchor.click(); URL.revokeObjectURL(url); }