feat: optimize routing and operations views

This commit is contained in:
hectorzhao
2026-07-29 22:32:28 +08:00
parent 500f43f673
commit c0a4317a7e
21 changed files with 718 additions and 86 deletions
+4 -2
View File
@@ -351,6 +351,8 @@ export type DashboardResponse = {
gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>;
pendingAuditCount: number;
pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number };
hourlySendTrend: Array<{ hour: number; label: string; submittedCount: number; successCount: number }>;
auditProcessingSpeed: Array<{ category: string; label: string; count: number; averageProcessingMs: number | null }>;
downstreamDeliverySummary?: {
pending: number;
failed: number;
@@ -1932,9 +1934,9 @@ export const adminApi = {
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
listMessageSegmentAudits: (query: { messageId?: string; messageRecordId?: string }) =>
request<SmsMessageSegmentAudit[]>(withQuery('/admin/operations/message-segment-audits', query)),
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<SmsMessageRecord>>(withQuery('/admin/operations/messages', query)),
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
requestBlob(withQuery('/admin/operations/messages/export', query)),
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
+16 -8
View File
@@ -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));
+22 -15
View File
@@ -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>
+34 -10
View File
@@ -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',
+16
View File
@@ -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
+17
View File
@@ -6437,6 +6437,23 @@ h3 {
padding: 0;
}
.sms-audit-cell-stack {
align-items: flex-start;
display: grid;
gap: var(--space-2);
min-width: 0;
}
.sms-audit-cell-stack small {
color: var(--color-text-muted);
line-height: var(--line-height-base);
}
.sms-audit-cell-stack .table-link {
justify-self: start;
text-align: left;
}
.sms-audit-head,
.sms-audit-row {
display: grid;
+36
View File
@@ -52,6 +52,42 @@ export function createBarOption(params: {
};
}
export function createDualAxisBarLineOption(params: {
labels: string[];
bar: { name: string; data: number[] };
line: { name: string; data: Array<number | null> };
}): EChartsOption {
return {
color: [...chartPalette],
grid: { left: 12, right: 18, top: 42, bottom: 8, containLabel: true },
legend: { top: 0, right: 0, textStyle: { color: themeColors.textMuted } },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: params.labels, ...axisStyle },
yAxis: [
{ type: 'value', name: '审核数量', minInterval: 1, ...axisStyle },
{ type: 'value', name: '平均分钟', ...axisStyle },
],
series: [
{
name: params.bar.name,
data: params.bar.data,
type: 'bar',
barMaxWidth: 28,
itemStyle: { borderRadius: [6, 6, 0, 0] },
},
{
name: params.line.name,
data: params.line.data,
type: 'line',
yAxisIndex: 1,
smooth: true,
symbolSize: 7,
lineStyle: { width: 3 },
},
],
};
}
export function createPieOption(params: {
data: Array<{ name: string; value: number }>;
}): EChartsOption {