feat: optimize routing and operations views
This commit is contained in:
@@ -290,14 +290,22 @@ function SignatureQualityDrawer({
|
||||
item: SignatureChannelQualityItem;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const carriers = item.carrierOverview.map((carrier) => ({
|
||||
...carrier,
|
||||
channelCount: new Set(
|
||||
item.breakdowns
|
||||
.filter((entry) => normalizeCarrier(entry.carrier) === normalizeCarrier(carrier.carrier))
|
||||
.map((entry) => entry.channelId),
|
||||
).size,
|
||||
}));
|
||||
const carriers = item.carrierOverview
|
||||
.map((carrier) => ({
|
||||
...carrier,
|
||||
channelCount: new Set(
|
||||
item.breakdowns
|
||||
.filter((entry) => normalizeCarrier(entry.carrier) === normalizeCarrier(carrier.carrier))
|
||||
.map((entry) => entry.channelId),
|
||||
).size,
|
||||
}))
|
||||
// Database aggregation order is not a display contract; keep the three major carriers stable.
|
||||
.sort((left, right) => {
|
||||
const leftRank = carrierOrder.indexOf(normalizeCarrier(left.carrier));
|
||||
const rightRank = carrierOrder.indexOf(normalizeCarrier(right.carrier));
|
||||
return (leftRank < 0 ? carrierOrder.length : leftRank)
|
||||
- (rightRank < 0 ? carrierOrder.length : rightRank);
|
||||
});
|
||||
const channels = [...new Map(item.breakdowns.map((entry) => [entry.channelId, entry.channelName])).entries()]
|
||||
.map(([channelId, channelName]) => ({ channelId, channelName }));
|
||||
const visibleCarriers = carrierOrder.filter((carrier) => item.breakdowns.some((entry) => normalizeCarrier(entry.carrier) === carrier));
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
import { adminApi, type DashboardResponse, type SendQualityResponse, type SignatureQualityStat } from '@/api/adminApi';
|
||||
import { createBarOption, createLineOption } from '@/theme/chartOptions';
|
||||
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
|
||||
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
|
||||
|
||||
type EnterpriseSpendRank = {
|
||||
@@ -92,23 +92,30 @@ export function AdminHome() {
|
||||
|
||||
const sendTrendOption = useMemo(
|
||||
() => createLineOption({
|
||||
labels: ['今日'],
|
||||
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
|
||||
series: [
|
||||
{ name: '提交量', data: [dashboard?.today.sent ?? 0] },
|
||||
{ name: '成功量', data: [dashboard?.today.delivered ?? 0] },
|
||||
{ name: '提交总条数', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
|
||||
{ name: '成功条数', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
|
||||
],
|
||||
}),
|
||||
[dashboard],
|
||||
);
|
||||
|
||||
const auditTrendOption = useMemo(
|
||||
() => createBarOption({
|
||||
labels: ['企业认证', '短信审核', '模板', '签名', '引流信息'],
|
||||
series: [
|
||||
{ name: '待审', data: [pendingAudits.enterpriseCertifications, pendingAudits.smsAudits, pendingAudits.templates, pendingAudits.signatures, pendingAudits.drainageInfos] },
|
||||
],
|
||||
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))
|
||||
)) ?? [],
|
||||
},
|
||||
}),
|
||||
[pendingAudits],
|
||||
[dashboard],
|
||||
);
|
||||
|
||||
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
|
||||
@@ -212,13 +219,13 @@ export function AdminHome() {
|
||||
<div className="chart-grid">
|
||||
<div className="surface chart-card">
|
||||
<h2>今日发送趋势</h2>
|
||||
<p className="muted">按真实后端今日聚合展示提交量和成功量。</p>
|
||||
<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={auditTrendOption} />
|
||||
<h2>审核处理速度</h2>
|
||||
<p className="muted">展示今日各项已处理审核数量,以及从提交到审核完成的平均时长。</p>
|
||||
<Chart height={300} option={auditSpeedOption} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -133,17 +133,41 @@ export function AdminSmsAuditPage() {
|
||||
width: '54px',
|
||||
render: (record) => <input aria-label={`选择审核任务${record.taskNo}`} checked={selectedIds.includes(record.id)} disabled={record.status !== 'pending_review'} onChange={(event) => setSelectedIds((current) => event.target.checked ? [...new Set([...current, record.id])] : current.filter((id) => id !== record.id))} type="checkbox" />,
|
||||
},
|
||||
{ key: 'tenant', title: '发送企业', width: '180px', render: (record) => <strong>{record.tenant?.name ?? record.tenantId}</strong> },
|
||||
{ key: 'application', title: '企业应用', width: '180px', render: (record) => record.application?.name ?? record.applicationId ?? '-' },
|
||||
{ key: 'sourceType', title: '审核来源', width: '180px', render: (record) => <Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag> },
|
||||
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||
{ key: 'phoneTotal', title: '号码数量', width: '140px', render: (record) => <button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">{(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} · 查看列表</button> },
|
||||
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => formatDateTime(record.createdAt) },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
width: '130px',
|
||||
render: (record) => <Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag>,
|
||||
key: 'tenantApplication',
|
||||
title: '发送企业 / 企业应用',
|
||||
width: '220px',
|
||||
render: (record) => (
|
||||
<div className="sms-audit-cell-stack">
|
||||
<strong>{record.tenant?.name ?? record.tenantId}</strong>
|
||||
<small>{record.application?.name ?? record.applicationId ?? '-'}</small>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'content', title: '短信内容', width: '440px', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||
{
|
||||
key: 'submittedSource',
|
||||
title: '提交时间 / 审核来源',
|
||||
width: '200px',
|
||||
render: (record) => (
|
||||
<div className="sms-audit-cell-stack">
|
||||
<time dateTime={record.createdAt}>{formatDateTime(record.createdAt)}</time>
|
||||
<Tag tone={record.sourceType === 'cmpp_template_mismatch' ? 'warning' : 'info'}>{sourceLabel(record.sourceType)}</Tag>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'phoneStatus',
|
||||
title: '号码数量 / 状态',
|
||||
width: '160px',
|
||||
render: (record) => (
|
||||
<div className="sms-audit-cell-stack">
|
||||
<button className="table-link" onClick={() => { setPhoneTarget(record); setPhoneKeyword(''); setPhonePage(1); }} type="button">
|
||||
{(record._count?.messageRecords || record.phoneTotal).toLocaleString('zh-CN')} · 查看列表
|
||||
</button>
|
||||
<Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
|
||||
@@ -413,6 +413,7 @@ export function AdminSmsRecordsPage() {
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [channelKeyword, setChannelKeyword] = useState('');
|
||||
const [carrier, setCarrier] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null);
|
||||
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
|
||||
@@ -430,6 +431,7 @@ export function AdminSmsRecordsPage() {
|
||||
phoneNumber?: string;
|
||||
contentKeyword?: string;
|
||||
channelKeyword?: string;
|
||||
carrier?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
status?: string;
|
||||
@@ -442,6 +444,7 @@ export function AdminSmsRecordsPage() {
|
||||
phoneNumber: phoneKeyword || undefined,
|
||||
contentKeyword: contentKeyword || undefined,
|
||||
channelKeyword: channelKeyword || undefined,
|
||||
carrier: carrier === 'all' ? undefined : carrier,
|
||||
queuedAtFrom: dateRange.start,
|
||||
queuedAtTo: dateRange.end,
|
||||
status: status === 'all' ? undefined : status,
|
||||
@@ -512,6 +515,7 @@ export function AdminSmsRecordsPage() {
|
||||
setPhoneKeyword('');
|
||||
setContentKeyword('');
|
||||
setChannelKeyword('');
|
||||
setCarrier('all');
|
||||
setStatus('all');
|
||||
if (page !== 1) setPage(1);
|
||||
else loadData({ queuedAtFrom: defaultDateRange.start, queuedAtTo: defaultDateRange.end }, 1);
|
||||
@@ -554,6 +558,18 @@ export function AdminSmsRecordsPage() {
|
||||
<Select label="应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交日期" onChange={setDateRange} value={dateRange} />
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
<Select
|
||||
label="运营商"
|
||||
onChange={(event) => setCarrier(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '移动', value: 'mobile' },
|
||||
{ label: '联通', value: 'unicom' },
|
||||
{ label: '电信', value: 'telecom' },
|
||||
{ label: '未识别', value: 'unknown' },
|
||||
]}
|
||||
value={carrier}
|
||||
/>
|
||||
<Input label="短信内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
|
||||
<Input label="通道名称" onChange={(event) => setChannelKeyword(event.target.value)} value={channelKeyword} />
|
||||
<Select
|
||||
|
||||
Reference in New Issue
Block a user