feat: complete cmpp gateway delivery recovery workflows

This commit is contained in:
hectorzhao
2026-07-08 16:30:06 +08:00
parent cc628d0214
commit 8144f08652
60 changed files with 8901 additions and 94 deletions
+211 -5
View File
@@ -24,6 +24,23 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
return response.json() as Promise<T>;
}
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
const headers = new Headers(options.headers);
const session = readSession();
if (session?.accessToken) {
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
}
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
if (tenantId) {
headers.set('x-tenant-id', tenantId);
}
const response = await fetch(`/api${path}`, { ...options, headers });
if (!response.ok) {
throw new Error(await response.text());
}
return response.blob();
}
export type AdminChannel = {
id: string;
code: string;
@@ -38,7 +55,7 @@ export type AdminChannel = {
rateLimitPerSecond: number;
unitPrice: number;
status: string;
config?: unknown;
config?: { desiredConnections?: number; windowSize?: number; [key: string]: unknown } | null;
connectionStates?: CmppConnectionState[];
};
@@ -151,6 +168,14 @@ export type DashboardResponse = {
transactions: { _count: { _all: number }; _sum: { amountCents?: number | null; smsUnits?: number | null } };
gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>;
pendingAuditCount: number;
downstreamDeliverySummary?: {
pending: number;
failed: number;
delivered: number;
stalledPending: number;
recentFailed: number;
alertCount: number;
};
accounts: Array<{ id: string; tenantId: string; balanceCents: number; smsUnits: number; creditCents: number; status: string; tenant?: TenantOption }>;
recentTasks: Array<Record<string, unknown>>;
recentRecharges: Array<RechargeOrder>;
@@ -298,19 +323,71 @@ export type SmsMessageRecord = {
receiptRecords?: Array<Record<string, unknown>>;
};
export type SmsMessageSegmentAudit = {
id: string;
tenantId: string;
batchTaskId?: string | null;
messageRecordId: string;
submitRecordId?: string | null;
channelId?: string | null;
submitId: string;
attempt: number;
segmentTotal: number;
segmentIndex: number;
sequenceId?: number | null;
gatewayMessageId?: string | null;
submitStatus: string;
receiptStatus?: string | null;
rawStatus?: string | null;
compensationType?: string | null;
errorCode?: string | null;
errorMessage?: string | null;
submittedAt?: string | null;
deliveredAt?: string | null;
createdAt: string;
updatedAt: string;
channel?: AdminChannel | null;
};
export type SmsUplinkMessage = {
id: string;
tenantId?: string | null;
channelId: string;
applicationId?: string | null;
messageRecordId?: string | null;
messageId?: string | null;
sequenceId?: number | null;
phoneNumber: string;
destId: string;
content: string;
matchStatus?: string;
matchReason?: string | null;
receivedAt: string;
createdAt: string;
tenant?: TenantOption | null;
application?: { id: string; name: string } | null;
messageRecord?: SmsMessageRecord | null;
channel?: AdminChannel | null;
matchCandidates?: SmsUplinkMatchCandidate[];
};
export type SmsUplinkMatchCandidate = {
id: string;
uplinkMessageId: string;
tenantId: string;
applicationId: string;
messageRecordId?: string | null;
matchSource: string;
confidence: number;
reason?: string | null;
status: string;
claimedAt?: string | null;
claimedById?: string | null;
createdAt: string;
updatedAt: string;
tenant?: TenantOption | null;
application?: { id: string; name: string } | null;
messageRecord?: SmsMessageRecord | null;
};
export type DictionaryItem = Record<string, unknown> & {
@@ -448,6 +525,13 @@ export type OperationLogResponse = {
modules: string[];
};
export type PagedResponse<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
export type EnterpriseApplication = {
id: string;
tenantId: string;
@@ -459,6 +543,9 @@ export type EnterpriseApplication = {
queuePriority?: 'normal' | 'priority' | string | null;
maxPhonesPerTask?: number | null;
templateMismatchMode?: string | null;
cmppAccount?: string | null;
cmppMaxConnections?: number | null;
cmppWindowSize?: number | null;
ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>;
tenant?: TenantOption;
sentToday?: number;
@@ -508,6 +595,107 @@ export type ApplicationCmppParams = {
protocolVersion: string;
};
export type DownstreamDeliveryRecord = {
id: string;
tenantId: string;
applicationId: string;
messageRecordId?: string | null;
messageId?: string | null;
deliveryType: string;
status: string;
payload: Record<string, unknown>;
retryCount: number;
nextRetryAt?: string | null;
deliveredAt?: string | null;
lastError?: string | null;
createdAt: string;
updatedAt: string;
tenant?: TenantOption | null;
application?: EnterpriseApplication | null;
messageRecord?: SmsMessageRecord | null;
};
export type BatchRequeueResponse = {
total: number;
successCount: number;
failedCount: number;
results: Array<{ id: string; status: 'success' | 'failed'; errorMessage?: string }>;
};
export type DownstreamDeliveryDashboard = {
summary: {
total: number;
pending: number;
delivered: number;
failed: number;
stalledPending: number;
recentFailed: number;
alertCount: number;
};
typeBreakdown: Array<{
deliveryType: string;
total: number;
pending: number;
delivered: number;
failed: number;
}>;
retryBuckets: Array<{
label: string;
count: number;
}>;
topApplications: Array<{
applicationId: string;
name: string;
pending: number;
failed: number;
delivered: number;
alertCount: number;
}>;
};
export type GatewayDownstreamRecoveryStatus = {
id: string;
account: string;
tenantId?: string | null;
applicationId?: string | null;
gatewayInstanceId?: string | null;
state: string;
lockOwner?: string | null;
lockExpiresAt?: string | null;
lastAttemptAt?: string | null;
lastSuccessAt?: string | null;
lastFailureAt?: string | null;
nextRetryAt?: string | null;
attemptCount: number;
failureCategory?: string | null;
lastError?: string | null;
lastSkipReason?: string | null;
createdAt: string;
updatedAt: string;
tenant?: TenantOption | null;
application?: EnterpriseApplication | null;
};
export type DownstreamRecoveryStatusResponse = PagedResponse<GatewayDownstreamRecoveryStatus> & {
summary: {
total: number;
running: number;
success: number;
failed: number;
waitingConnection: number;
backoff: number;
failureCategories: Array<{ category: string; count: number }>;
};
};
export type DownstreamRecoveryStatusExportQuery = {
tenantId?: string;
applicationId?: string;
state?: string;
failureCategory?: string;
keyword?: string;
};
function withQuery(path: string, query: Record<string, string | number | undefined>) {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => {
@@ -552,9 +740,9 @@ export const adminApi = {
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
getEnterpriseApplication: (id: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeApplicationStatus: (id: string, status: string, reason?: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
@@ -571,9 +759,9 @@ export const adminApi = {
getApplicationCmppParams: (applicationId: string) =>
request<ApplicationCmppParams>(`/admin/enterprise-applications/${applicationId}/cmpp-params`),
listChannels: () => request<AdminChannel[]>('/admin/channels'),
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string }) =>
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string }) =>
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number }) =>
request<AdminChannel>(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, {
method: 'POST',
@@ -664,12 +852,30 @@ export const adminApi = {
request<SmsBatchTask>(`/admin/send/batch-tasks/${id}/terminate`, { method: 'POST', body: JSON.stringify({}) }),
listAdminMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string } = {}) =>
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; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}) =>
request<SmsMessageRecord[]>(withQuery('/admin/operations/messages', query)),
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
claimUplinkMatchCandidate: (uplinkMessageId: string, body: { candidateId: string; operatorId?: string }) =>
request<SmsUplinkMessage>(`/admin/operations/uplink-messages/${uplinkMessageId}/claim`, { method: 'POST', body: JSON.stringify(body) }),
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request<Record<string, unknown>>(withQuery('/admin/operations/monitor', query)),
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
getDownstreamDeliveryDashboard: (query: { tenantId?: string; applicationId?: string; deliveryType?: string } = {}) =>
request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<DownstreamRecoveryStatusResponse>(withQuery('/admin/operations/downstream-recovery-statuses', query)),
getDownstreamRecoveryStatus: (id: string) =>
request<GatewayDownstreamRecoveryStatus>(`/admin/operations/downstream-recovery-statuses/${id}`),
exportDownstreamRecoveryStatuses: (query: DownstreamRecoveryStatusExportQuery = {}) =>
requestBlob(withQuery('/admin/operations/downstream-recovery-statuses/export', query)),
listDownstreamDeliveries: (query: { tenantId?: string; applicationId?: string; deliveryType?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResponse<DownstreamDeliveryRecord>>(withQuery('/admin/operations/downstream-deliveries', query)),
requeueDownstreamDelivery: (id: string) =>
request<DownstreamDeliveryRecord>(`/admin/operations/downstream-deliveries/${id}/requeue`, { method: 'POST', body: JSON.stringify({}) }),
batchRequeueDownstreamDeliveries: (ids: string[]) =>
request<BatchRequeueResponse>('/admin/operations/downstream-deliveries/requeue', { method: 'POST', body: JSON.stringify({ ids }) }),
listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
approveRiskReviewTask: (id: string, reason?: string) =>
request<RiskReviewTask>(`/admin/risk-review/tasks/${id}/approve`, { method: 'POST', body: JSON.stringify({ reason }) }),
+12
View File
@@ -26,6 +26,8 @@ type SmsChannel = {
corpCode: string;
account: string;
accessNo: string;
desiredConnections: number;
windowSize: number;
passwordCipher?: string;
};
@@ -143,6 +145,8 @@ function mapApiChannel(channel: AdminChannel, connections: CmppConnectionState[]
corpCode: channel.enterpriseCode ?? channel.code,
account: channel.account,
accessNo: channel.srcId,
desiredConnections: Number(channel.config?.desiredConnections ?? 1),
windowSize: Number(channel.config?.windowSize ?? 16),
};
}
@@ -163,6 +167,8 @@ function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
srcId: channel.accessNo,
rateLimitPerSecond: 100,
unitPrice: Math.round(channel.unitPrice),
desiredConnections: channel.desiredConnections,
windowSize: channel.windowSize,
};
}
@@ -199,6 +205,8 @@ function ChannelFormModal({
const [accessNo, setAccessNo] = useState(channel?.accessNo ?? '');
const [extensionDigits, setExtensionDigits] = useState('0');
const [flowLimit, setFlowLimit] = useState('1-2000');
const [desiredConnections, setDesiredConnections] = useState(String(channel?.desiredConnections ?? 1));
const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16));
function submit() {
onSubmit({
@@ -220,6 +228,8 @@ function ChannelFormModal({
corpCode,
account,
accessNo,
desiredConnections: Number(desiredConnections) || 1,
windowSize: Number(windowSize) || 16,
passwordCipher: password || undefined,
});
}
@@ -272,6 +282,8 @@ function ChannelFormModal({
<Select label="拓展位数" onChange={(event) => setExtensionDigits(event.target.value)} options={extensionOptions} value={extensionDigits} />
</div>
<Input label="* 通道流速" onChange={(event) => setFlowLimit(event.target.value)} suffix="条/秒" value={flowLimit} />
<Input label="* 期望连接数" onChange={(event) => setDesiredConnections(event.target.value)} placeholder="1" value={desiredConnections} />
<Input label="* 提交窗口" onChange={(event) => setWindowSize(event.target.value)} placeholder="16" value={windowSize} />
</div>
</section>
</div>
@@ -0,0 +1,377 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, BarChart3, CheckCircle2, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
import { adminApi, type DownstreamDeliveryDashboard, type DownstreamDeliveryRecord, type EnterpriseApplication } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'warning',
delivered: 'success',
failed: 'danger',
};
const deliveryTypeLabel: Record<string, string> = {
receipt: '状态回执',
uplink: '上行短信',
};
function DeliveryDetailModal({ record, onClose }: { record: DownstreamDeliveryRecord; onClose: () => void }) {
const payloadText = useMemo(() => JSON.stringify(record.payload ?? {}, null, 2), [record.payload]);
return (
<Modal
open
onClose={onClose}
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}
footer={<Button onClick={onClose}></Button>}
>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{record.tenant?.name ?? record.tenantId}</strong></div>
<div><span></span><strong>{record.application?.name ?? record.applicationId}</strong></div>
<div><span></span><strong>{deliveryTypeLabel[record.deliveryType] ?? record.deliveryType}</strong></div>
<div><span></span><strong>{record.status}</strong></div>
<div><span> ID</span><strong>{record.messageId ?? '-'}</strong></div>
<div><span></span><strong>{record.retryCount}</strong></div>
<div><span></span><strong>{record.nextRetryAt ?? '-'}</strong></div>
<div><span></span><strong>{record.deliveredAt ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.lastError ?? '-'}</strong></div>
</div>
<section className="report-history">
<h3>Payload</h3>
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', margin: 0 }}>{payloadText}</pre>
</section>
</div>
</Modal>
);
}
export function AdminDownstreamDeliveriesPage() {
const [records, setRecords] = useState<DownstreamDeliveryRecord[]>([]);
const [dashboard, setDashboard] = useState<DownstreamDeliveryDashboard | null>(null);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [deliveryType, setDeliveryType] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [detail, setDetail] = useState<DownstreamDeliveryRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const loadData = useCallback(() => {
setLoading(true);
Promise.all([
adminApi.getDownstreamDeliveryDashboard({
applicationId,
deliveryType,
}),
adminApi.listDownstreamDeliveries({
keyword,
status,
deliveryType,
applicationId,
page,
pageSize,
}),
adminApi.listEnterpriseApplications(),
])
.then(([dashboardResponse, response, apps]) => {
setDashboard(dashboardResponse);
setRecords(response.items);
setTotal(response.total);
setApplications(apps);
setSelectedIds((current) => current.filter((id) => response.items.some((item) => item.id === id)));
setError('');
})
.catch((failure: Error) => setError(failure.message || '下游投递记录加载失败'))
.finally(() => setLoading(false));
}, [applicationId, deliveryType, keyword, page, pageSize, status]);
useEffect(() => {
loadData();
}, [loadData]);
const selectableIds = useMemo(
() => records.filter((item) => item.status !== 'delivered').map((item) => item.id),
[records],
);
const allSelected = selectableIds.length > 0 && selectableIds.every((id) => selectedIds.includes(id));
const summary = dashboard?.summary;
const typeBreakdown = dashboard?.typeBreakdown ?? [];
const retryBuckets = dashboard?.retryBuckets ?? [];
const topApplications = dashboard?.topApplications ?? [];
const columns: Array<TableColumn<DownstreamDeliveryRecord>> = [
{
key: 'select',
title: '选择',
width: '52px',
align: 'center',
render: (record) => (
<input
type="checkbox"
disabled={record.status === 'delivered'}
checked={selectedIds.includes(record.id)}
onChange={(event) => {
setSelectedIds((current) =>
event.target.checked
? [...current, record.id]
: current.filter((item) => item !== record.id),
);
}}
aria-label={`选择${record.id}`}
/>
),
},
{ key: 'createdAt', title: '投递时间', width: '180px', render: (record) => record.createdAt },
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? '-' },
{ key: 'type', title: '类型', width: '110px', render: (record) => deliveryTypeLabel[record.deliveryType] ?? record.deliveryType },
{ key: 'messageId', title: '消息 ID', width: '180px', render: (record) => <strong className="admin-task-id">{record.messageId ?? '-'}</strong> },
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={statusTone[record.status] ?? 'info'}>{record.status}</Tag> },
{ key: 'retry', title: '重试', width: '90px', align: 'center', render: (record) => record.retryCount },
{ key: 'error', title: '最后错误', render: (record) => record.lastError ?? '-' },
{
key: 'actions',
title: '操作',
width: '170px',
align: 'right',
render: (record) => (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button>
<Button
icon={<RefreshCw size={14} />}
onClick={() => {
adminApi.requeueDownstreamDelivery(record.id)
.then(() => loadData())
.catch((failure: Error) => setError(failure.message || '人工重投失败'));
}}
size="sm"
variant="secondary"
>
</Button>
</div>
),
},
];
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<section className="page-stack admin-sms-task-page report-record-page">
<div className="page-heading">
<div>
<Breadcrumb items={['数据详单', '下游投递记录']} />
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface mini-status-card">
<BarChart3 size={22} />
<div>
<span></span>
<strong>{summary?.total ?? 0}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<TimerReset size={22} />
<div>
<span></span>
<strong>{summary?.pending ?? 0}</strong>
<small> {summary?.stalledPending ?? 0} </small>
</div>
</div>
<div className="surface mini-status-card">
<CheckCircle2 size={22} />
<div>
<span></span>
<strong>{summary?.delivered ?? 0}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<AlertTriangle size={22} />
<div>
<span></span>
<strong>{summary?.alertCount ?? 0}</strong>
<small> {summary?.recentFailed ?? 0} </small>
</div>
</div>
</div>
<div className="surface admin-task-filter">
<Input label="消息ID / 账号 / 手机号 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<Select
label="状态"
options={[
{ label: '全部状态', value: 'all' },
{ label: '待投递', value: 'pending' },
{ label: '已投递', value: 'delivered' },
{ label: '最终失败', value: 'failed' },
]}
value={status}
onChange={(event) => {
setStatus(event.target.value);
setPage(1);
}}
/>
<Select
label="类型"
options={[
{ label: '全部类型', value: 'all' },
{ label: '状态回执', value: 'receipt' },
{ label: '上行短信', value: 'uplink' },
]}
value={deliveryType}
onChange={(event) => {
setDeliveryType(event.target.value);
setPage(1);
}}
/>
<Select
label="应用"
options={[
{ label: '全部应用', value: 'all' },
...applications.map((item) => ({ label: item.name, value: item.id })),
]}
value={applicationId}
onChange={(event) => {
setApplicationId(event.target.value);
setPage(1);
}}
/>
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button
onClick={() => {
setKeyword('');
setStatus('all');
setDeliveryType('all');
setApplicationId('all');
setPage(1);
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="overview-grid">
<div className="surface">
<div className="section-heading">
<h2></h2>
</div>
<div className="downstream-breakdown-table">
<div className="downstream-breakdown-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{typeBreakdown.map((item) => (
<div className="downstream-breakdown-table__row" key={item.deliveryType}>
<strong>{deliveryTypeLabel[item.deliveryType] ?? item.deliveryType}</strong>
<span>{item.total}</span>
<span>{item.pending}</span>
<span>{item.delivered}</span>
<span>{item.failed}</span>
</div>
))}
</div>
</div>
<div className="surface">
<div className="section-heading">
<h2></h2>
</div>
<div className="downstream-bucket-list">
{retryBuckets.map((item) => (
<div className="downstream-bucket-item" key={item.label}>
<span>{item.label}</span>
<strong>{item.count}</strong>
</div>
))}
</div>
</div>
</div>
<div className="surface">
<div className="section-heading">
<h2></h2>
</div>
<div className="downstream-breakdown-table">
<div className="downstream-breakdown-table__head downstream-breakdown-table__head--apps">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{topApplications.length > 0 ? topApplications.map((item) => (
<div className="downstream-breakdown-table__row downstream-breakdown-table__row--apps" key={item.applicationId}>
<strong>{item.name}</strong>
<span>{item.pending}</span>
<span>{item.failed}</span>
<span>{item.delivered}</span>
<span>{item.alertCount}</span>
</div>
)) : (
<div className="downstream-breakdown-table__empty"></div>
)}
</div>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
{selectedIds.length} `pending/failed`
</p>
<div style={{ display: 'flex', gap: 8 }}>
<Button
disabled={selectableIds.length === 0}
onClick={() => setSelectedIds(allSelected ? [] : selectableIds)}
variant="ghost"
>
{allSelected ? '取消全选当前页' : '全选当前页'}
</Button>
<Button
icon={<RefreshCw size={14} />}
disabled={selectedIds.length === 0}
onClick={() => {
adminApi.batchRequeueDownstreamDeliveries(selectedIds)
.then((result) => {
setError(result.failedCount > 0 ? `批量重投完成,成功 ${result.successCount} 条,失败 ${result.failedCount}` : '');
loadData();
})
.catch((failure: Error) => setError(failure.message || '批量重投失败'));
}}
variant="secondary"
>
</Button>
</div>
</div>
<Table columns={columns} data={records} emptyText={loading ? '加载中...' : '暂无下游投递记录'} pagination={false} rowKey="id" />
<Pagination
total={total}
page={page}
previousDisabled={page <= 1}
nextDisabled={page >= totalPages}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
/>
</div>
{detail ? <DeliveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
</section>
);
}
@@ -0,0 +1,345 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Download, Eye, RefreshCw, Search, TimerReset } from 'lucide-react';
import { adminApi, type EnterpriseApplication, type GatewayDownstreamRecoveryStatus } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
const recoveryStatusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
running: 'info',
success: 'success',
failed: 'danger',
waiting_connection: 'warning',
partial: 'warning',
};
const recoveryStatusLabel: Record<string, string> = {
running: '恢复中',
success: '恢复成功',
failed: '恢复失败',
waiting_connection: '等待连接',
partial: '部分成功',
};
const failureCategoryTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
client_disconnected: 'warning',
backoff: 'warning',
lock_contended: 'info',
flush_failed: 'danger',
partial_delivery_failed: 'danger',
lock_lost: 'danger',
unknown: 'neutral',
};
const failureCategoryLabel: Record<string, string> = {
client_disconnected: '客户未连接',
backoff: '退避等待',
lock_contended: '恢复锁占用',
flush_failed: '恢复执行失败',
partial_delivery_failed: '部分投递失败',
lock_lost: '恢复锁丢失',
unknown: '未知原因',
};
function RecoveryDetailModal({ record, onClose }: { record: GatewayDownstreamRecoveryStatus; onClose: () => void }) {
return (
<Modal
open
onClose={onClose}
size="xl"
title={<div className="template-modal-title"><h2></h2><p>{record.account}</p></div>}
footer={<Button onClick={onClose}></Button>}
>
<div className="report-record-detail">
<div className="admin-detail-metric-grid admin-detail-metric-grid--compact">
<div className="surface mini-status-card">
<RefreshCw size={20} />
<div>
<span></span>
<strong>{recoveryStatusLabel[record.state] ?? record.state}</strong>
</div>
</div>
<div className="surface mini-status-card">
<TimerReset size={20} />
<div>
<span></span>
<strong>{record.attemptCount}</strong>
</div>
</div>
<div className="surface mini-status-card">
<CheckCircle2 size={20} />
<div>
<span></span>
<strong>{record.nextRetryAt ?? '-'}</strong>
</div>
</div>
</div>
<div className="detail-grid">
<div><span></span><strong>{record.tenant?.name ?? '-'}</strong></div>
<div><span></span><strong>{record.application?.name ?? '-'}</strong></div>
<div><span></span><strong>{record.account}</strong></div>
<div><span>Gateway </span><strong>{record.gatewayInstanceId ?? '-'}</strong></div>
<div><span></span><strong>{record.lockOwner ?? '-'}</strong></div>
<div><span></span><strong>{record.lockExpiresAt ?? '-'}</strong></div>
<div><span></span><strong>{record.failureCategory ? failureCategoryLabel[record.failureCategory] ?? record.failureCategory : '-'}</strong></div>
<div><span></span><strong>{record.lastAttemptAt ?? '-'}</strong></div>
<div><span></span><strong>{record.lastSuccessAt ?? '-'}</strong></div>
<div><span></span><strong>{record.lastFailureAt ?? '-'}</strong></div>
<div><span></span><strong>{record.createdAt}</strong></div>
<div><span></span><strong>{record.updatedAt}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.lastError ?? '-'}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{record.lastSkipReason ?? '-'}</strong></div>
</div>
</div>
</Modal>
);
}
export function AdminDownstreamRecoveryStatusesPage() {
const [items, setItems] = useState<GatewayDownstreamRecoveryStatus[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [summary, setSummary] = useState<{ total: number; running: number; success: number; failed: number; waitingConnection: number; backoff: number; failureCategories: Array<{ category: string; count: number }> } | null>(null);
const [keyword, setKeyword] = useState('');
const [state, setState] = useState('all');
const [failureCategory, setFailureCategory] = useState('all');
const [applicationId, setApplicationId] = useState('all');
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState('');
const [detail, setDetail] = useState<GatewayDownstreamRecoveryStatus | null>(null);
const loadData = useCallback(() => {
setLoading(true);
Promise.all([
adminApi.listDownstreamRecoveryStatuses({ keyword, state, failureCategory, applicationId, page, pageSize }),
adminApi.listEnterpriseApplications(),
])
.then(([response, apps]) => {
setItems(response.items);
setSummary(response.summary);
setTotal(response.total);
setApplications(apps);
setError('');
})
.catch((failure: Error) => {
setItems([]);
setTotal(0);
setSummary(null);
setError(failure.message || '恢复状态加载失败');
})
.finally(() => setLoading(false));
}, [applicationId, failureCategory, keyword, page, pageSize, state]);
useEffect(() => {
loadData();
}, [loadData]);
const columns = useMemo<Array<TableColumn<GatewayDownstreamRecoveryStatus>>>(() => [
{ key: 'updatedAt', title: '更新时间', width: '180px', render: (record) => record.updatedAt },
{ key: 'account', title: '账号', width: '120px', render: (record) => <strong className="admin-task-id">{record.account}</strong> },
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? '-' },
{ key: 'gateway', title: 'Gateway实例', width: '180px', render: (record) => <span className="muted">{record.gatewayInstanceId ?? '-'}</span> },
{ key: 'lockOwner', title: '锁持有', width: '150px', render: (record) => <span className="muted">{record.lockOwner ?? '-'}</span> },
{ key: 'state', title: '状态', width: '120px', render: (record) => <Tag tone={recoveryStatusTone[record.state] ?? 'info'}>{recoveryStatusLabel[record.state] ?? record.state}</Tag> },
{ key: 'failureCategory', title: '失败分类', width: '140px', render: (record) => record.failureCategory ? <Tag tone={failureCategoryTone[record.failureCategory] ?? 'neutral'}>{failureCategoryLabel[record.failureCategory] ?? record.failureCategory}</Tag> : '-' },
{ key: 'attemptCount', title: '尝试次数', width: '96px', align: 'center', render: (record) => record.attemptCount },
{ key: 'nextRetryAt', title: '下次恢复', width: '180px', render: (record) => record.nextRetryAt ?? '-' },
{ key: 'lastError', title: '最后错误/跳过原因', render: (record) => record.lastError ?? record.lastSkipReason ?? '-' },
{
key: 'actions',
title: '操作',
width: '110px',
align: 'right',
render: (record) => (
<Button
icon={<Eye size={14} />}
onClick={() => {
adminApi.getDownstreamRecoveryStatus(record.id)
.then((data) => {
setDetail(data);
setError('');
})
.catch((failure: Error) => setError(failure.message || '恢复状态详情加载失败'));
}}
size="sm"
variant="ghost"
>
</Button>
),
},
], []);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const failureCategoryBreakdown = summary?.failureCategories ?? [];
function resetFilters() {
setKeyword('');
setState('all');
setFailureCategory('all');
setApplicationId('all');
setPage(1);
}
async function exportCurrent() {
setExporting(true);
try {
const blob = await adminApi.exportDownstreamRecoveryStatuses({ keyword, state, failureCategory, applicationId });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
anchor.href = url;
anchor.download = `recovery-statuses-${timestamp}.csv`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '恢复状态导出失败');
} finally {
setExporting(false);
}
}
return (
<section className="page-stack admin-sms-task-page report-record-page">
<div className="page-heading">
<div>
<Breadcrumb items={['发送运维', '恢复状态管理']} />
<h1></h1>
</div>
<Button icon={<Download size={16} />} onClick={exportCurrent} variant="secondary" disabled={exporting}>
{exporting ? '导出中...' : '导出当前筛选'}
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="dashboard-grid">
<div className="surface mini-status-card">
<RefreshCw size={22} />
<div>
<span></span>
<strong>{summary?.total ?? 0}</strong>
<small> PostgreSQL </small>
</div>
</div>
<div className="surface mini-status-card">
<TimerReset size={22} />
<div>
<span></span>
<strong>{summary?.waitingConnection ?? 0}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<CheckCircle2 size={22} />
<div>
<span></span>
<strong>{summary?.success ?? 0}</strong>
<small></small>
</div>
</div>
<div className="surface mini-status-card">
<AlertTriangle size={22} />
<div>
<span>退</span>
<strong>{summary?.backoff ?? 0}</strong>
<small>退</small>
</div>
</div>
</div>
<div className="surface">
<div className="section-heading">
<h2></h2>
<p className="page-inline-hint"></p>
</div>
<div className="downstream-bucket-list downstream-bucket-list--wrap">
{failureCategoryBreakdown.length > 0 ? failureCategoryBreakdown.map((item) => (
<div className="downstream-bucket-item" key={item.category}>
<span>{failureCategoryLabel[item.category] ?? item.category}</span>
<strong>{item.count}</strong>
</div>
)) : (
<div className="downstream-breakdown-table__empty"></div>
)}
</div>
</div>
<div className="surface admin-task-filter">
<Input label="账号 / 企业 / 应用 / 错误" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<Select
label="状态"
options={[
{ label: '全部状态', value: 'all' },
{ label: '恢复中', value: 'running' },
{ label: '恢复成功', value: 'success' },
{ label: '恢复失败', value: 'failed' },
{ label: '等待连接', value: 'waiting_connection' },
{ label: '部分成功', value: 'partial' },
]}
value={state}
onChange={(event) => {
setState(event.target.value);
setPage(1);
}}
/>
<Select
label="失败分类"
options={[
{ label: '全部分类', value: 'all' },
{ label: '客户未连接', value: 'client_disconnected' },
{ label: '退避等待', value: 'backoff' },
{ label: '恢复锁占用', value: 'lock_contended' },
{ label: '恢复执行失败', value: 'flush_failed' },
{ label: '部分投递失败', value: 'partial_delivery_failed' },
{ label: '恢复锁丢失', value: 'lock_lost' },
{ label: '未知原因', value: 'unknown' },
]}
value={failureCategory}
onChange={(event) => {
setFailureCategory(event.target.value);
setPage(1);
}}
/>
<Select
label="应用"
options={[
{ label: '全部应用', value: 'all' },
...applications.map((item) => ({ label: item.name, value: item.id })),
]}
value={applicationId}
onChange={(event) => {
setApplicationId(event.target.value);
setPage(1);
}}
/>
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={resetFilters} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-task-table-card report-task-table-card">
<div className="section-heading">
<h2></h2>
<p className="page-inline-hint"></p>
</div>
<Table columns={columns} data={items} emptyText={loading ? '加载中...' : '暂无恢复状态'} pagination={false} rowKey="id" />
<Pagination
total={total}
page={page}
previousDisabled={page <= 1}
nextDisabled={page >= totalPages}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
onNext={() => setPage((current) => Math.min(totalPages, current + 1))}
/>
</div>
{detail ? <RecoveryDetailModal record={detail} onClose={() => setDetail(null)} /> : null}
</section>
);
}
+9
View File
@@ -84,6 +84,7 @@ export function AdminHome() {
const averageSuccessRate = dashboard?.today.successRate ?? 0;
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
const sendTrendOption = useMemo(
() => createLineOption({
@@ -253,6 +254,14 @@ export function AdminHome() {
<small>线</small>
</div>
</div>
<div className="mini-status-card">
<RadioTower size={22} />
<div>
<span></span>
<strong>{downstreamAlertCount} </strong>
<small></small>
</div>
</div>
</div>
</div>
</div>
@@ -22,6 +22,9 @@ export function AdminSmsApplicationFormPage() {
const [dailyLimit, setDailyLimit] = useState('100000');
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal');
const [cmppAccount, setCmppAccount] = useState('');
const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
const [cmppWindowSize, setCmppWindowSize] = useState('16');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
const [mismatchPolicy, setMismatchPolicy] = useState('manual_review');
const [ipAddress, setIpAddress] = useState('');
@@ -75,6 +78,9 @@ export function AdminSmsApplicationFormPage() {
setDailyLimit(application.dailyLimit ? String(application.dailyLimit) : '');
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(4));
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
setCmppAccount(application.cmppAccount ?? '');
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
setCmppWindowSize(String(application.cmppWindowSize ?? 16));
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
@@ -110,6 +116,9 @@ export function AdminSmsApplicationFormPage() {
dailyLimit: Number(dailyLimit) || undefined,
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
queuePriority,
cmppAccount: cmppAccount.trim() || undefined,
cmppMaxConnections: Number(cmppMaxConnections) || 1,
cmppWindowSize: Number(cmppWindowSize) || 16,
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
templateMismatchMode: mismatchPolicy,
ipAllowlist: parseIpAllowlist(ipAddress),
@@ -167,6 +176,9 @@ export function AdminSmsApplicationFormPage() {
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} />
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
<Input label="客户提交窗口" onChange={(event) => setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} />
<div className="admin-app-form-row admin-app-form-row--wide">
<span></span>
<div className="radio-row">
+40 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { MessageSquare, Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord } from '@/api/adminApi';
import { adminApi, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Tag, type DateRangeValue, type TableColumn, Table } from '@/components/ui';
const statusLabelMap: Record<string, string> = {
@@ -28,6 +28,8 @@ export function AdminSmsRecordsPage() {
const [contentKeyword, setContentKeyword] = useState('');
const [status, setStatus] = useState('all');
const [selectedRecord, setSelectedRecord] = useState<SmsMessageRecord | null>(null);
const [segmentAudits, setSegmentAudits] = useState<SmsMessageSegmentAudit[]>([]);
const [segmentLoading, setSegmentLoading] = useState(false);
const [error, setError] = useState('');
function loadData() {
@@ -43,6 +45,21 @@ export function AdminSmsRecordsPage() {
loadData();
}, []);
useEffect(() => {
if (!selectedRecord) {
setSegmentAudits([]);
return;
}
setSegmentLoading(true);
adminApi.listMessageSegmentAudits({ messageRecordId: selectedRecord.id })
.then((items) => {
setSegmentAudits(items);
setError('');
})
.catch((failure: Error) => setError(failure.message || '分片审计加载失败'))
.finally(() => setSegmentLoading(false));
}, [selectedRecord]);
const filteredRows = useMemo(
() => records.filter((item) => {
const submittedDate = item.queuedAt.slice(0, 10);
@@ -64,6 +81,18 @@ export function AdminSmsRecordsPage() {
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button onClick={() => setSelectedRecord(record)} size="sm" variant="ghost"></Button> },
];
const segmentColumns: Array<TableColumn<SmsMessageSegmentAudit>> = [
{ key: 'segment', title: '分片', width: '90px', render: (record) => `${record.segmentIndex}/${record.segmentTotal}` },
{ key: 'submitId', title: '提交ID', width: '190px', render: (record) => <strong className="admin-task-id">{record.submitId}</strong> },
{ key: 'channel', title: '通道', width: '150px', render: (record) => record.channel?.name ?? record.channelId ?? '-' },
{ key: 'sequenceId', title: 'Sequence', width: '110px', render: (record) => record.sequenceId ?? '-' },
{ key: 'gatewayMessageId', title: 'MsgId', width: '180px', render: (record) => record.gatewayMessageId ?? '-' },
{ key: 'submitStatus', title: '提交状态', width: '110px', render: (record) => <Tag tone={record.submitStatus === 'accepted' ? 'success' : record.submitStatus === 'queued' ? 'info' : 'danger'}>{record.submitStatus}</Tag> },
{ key: 'receiptStatus', title: '回执状态', width: '110px', render: (record) => record.receiptStatus ? <Tag tone={record.receiptStatus === 'delivered' ? 'success' : record.receiptStatus === 'unknown' ? 'neutral' : 'danger'}>{record.receiptStatus}</Tag> : '-' },
{ key: 'compensation', title: '补偿', width: '120px', render: (record) => record.compensationType ?? '-' },
{ key: 'error', title: '错误', render: (record) => record.errorMessage ?? record.errorCode ?? '-' },
];
function resetFilters() {
setDateRange({});
setPhoneKeyword('');
@@ -125,6 +154,16 @@ export function AdminSmsRecordsPage() {
<p>{statusLabelMap[selectedRecord.status] ?? selectedRecord.status}</p>
<p>{selectedRecord.errorMessage ?? '-'}</p>
</section>
<section>
<h3></h3>
<Table
columns={segmentColumns}
data={segmentAudits}
emptyText={segmentLoading ? '加载中...' : '暂无分片审计'}
pagination={false}
rowKey="id"
/>
</section>
</div>
) : null}
</Modal>
+105 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Search, Smartphone } from 'lucide-react';
import { adminApi, type SmsMessageRecord, type SmsUplinkMessage } from '@/api/adminApi';
import { adminApi, type SmsMessageRecord, type SmsUplinkMatchCandidate, type SmsUplinkMessage } from '@/api/adminApi';
import {
Breadcrumb,
Button,
@@ -20,19 +20,44 @@ function getTime(value?: string | null) {
return value ? `${value.slice(0, 10)} ${value.slice(11, 19)}` : '-';
}
function matchStatusText(status?: string | null) {
const map: Record<string, string> = {
matched: '已匹配',
ambiguous: '待认领',
unmatched: '未匹配',
};
return status ? (map[status] ?? status) : '-';
}
function candidateStatusText(status?: string | null) {
const map: Record<string, string> = {
pending: '待认领',
claimed: '已认领',
rejected: '已排除',
};
return status ? (map[status] ?? status) : '-';
}
function UplinkDetailModal({
claimError,
claimingId,
detailError,
matchedRecords,
matching,
message,
onClaim,
onClose,
}: {
claimError: string;
claimingId: string;
detailError: string;
matchedRecords: SmsMessageRecord[];
matching: boolean;
message: SmsUplinkMessage;
onClaim: (candidate: SmsUplinkMatchCandidate) => void;
onClose: () => void;
}) {
const candidates = message.matchCandidates ?? [];
return (
<Modal
footer={<Button onClick={onClose} variant="ghost"></Button>}
@@ -69,6 +94,10 @@ function UplinkDetailModal({
<span>ID</span>
<strong>{message.messageId || '-'}</strong>
</div>
<div>
<span></span>
<strong>{matchStatusText(message.matchStatus)}</strong>
</div>
<div className="admin-uplink-info-grid__full">
<span></span>
<strong>{message.content || '-'}</strong>
@@ -76,6 +105,58 @@ function UplinkDetailModal({
</div>
</section>
<section className="admin-uplink-match-section">
<h3></h3>
{claimError ? <p className="form-error">{claimError}</p> : null}
{candidates.length === 0 ? <div className="admin-uplink-empty-match"></div> : null}
{candidates.map((candidate) => (
<article className="admin-uplink-match-card" key={candidate.id}>
<div className="admin-uplink-match-grid">
<div>
<span></span>
<strong>{candidate.tenant?.name ?? candidate.tenantId}</strong>
</div>
<div>
<span></span>
<strong>{candidate.application?.name ?? candidate.applicationId}</strong>
</div>
<div>
<span></span>
<strong>{candidate.matchSource === 'access_number' ? '接入号' : '手机号时间窗'}</strong>
</div>
<div>
<span></span>
<strong>{candidate.confidence}</strong>
</div>
<div>
<span></span>
<strong>{candidateStatusText(candidate.status)}</strong>
</div>
<div>
<span></span>
<strong>{candidate.messageRecord?.messageId ?? '-'}</strong>
</div>
</div>
{candidate.messageRecord ? (
<div className="admin-uplink-match-content">
<span></span>
<p>{candidate.messageRecord.content}</p>
</div>
) : null}
<div className="admin-uplink-candidate-footer">
<span>{candidate.reason ?? '-'}</span>
<Button
disabled={claimingId === candidate.id || candidate.status === 'claimed' || candidate.status === 'rejected'}
onClick={() => onClaim(candidate)}
size="sm"
>
{candidate.status === 'claimed' ? '已认领' : claimingId === candidate.id ? '认领中...' : '认领并推送'}
</Button>
</div>
</article>
))}
</section>
<section className="admin-uplink-match-section">
<h3></h3>
{matching ? <p>...</p> : null}
@@ -125,8 +206,10 @@ export function AdminSmsUplinkRecordsPage() {
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
const [loading, setLoading] = useState(true);
const [matching, setMatching] = useState(false);
const [claimingId, setClaimingId] = useState('');
const [error, setError] = useState('');
const [detailError, setDetailError] = useState('');
const [claimError, setClaimError] = useState('');
function loadData() {
setLoading(true);
@@ -143,6 +226,7 @@ export function AdminSmsUplinkRecordsPage() {
setSelectedMessage(message);
setMatchedRecords([]);
setDetailError('');
setClaimError('');
if (!message.messageId) {
return;
@@ -177,6 +261,22 @@ export function AdminSmsUplinkRecordsPage() {
setContentKeyword('');
}
function handleClaim(candidate: SmsUplinkMatchCandidate) {
if (!selectedMessage) {
return;
}
setClaimingId(candidate.id);
setClaimError('');
adminApi.claimUplinkMatchCandidate(selectedMessage.id, { candidateId: candidate.id })
.then((updated) => {
setMessages((items) => items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)));
setSelectedMessage((current) => (current && current.id === updated.id ? { ...current, ...updated } : current));
loadData();
})
.catch((reason: Error) => setClaimError(reason.message || '上行认领失败'))
.finally(() => setClaimingId(''));
}
const columns: Array<TableColumn<SmsUplinkMessage>> = [
{
key: 'select',
@@ -190,6 +290,7 @@ export function AdminSmsUplinkRecordsPage() {
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
{ key: 'channel', title: '上行通道', width: '260px', render: (record) => <strong>{record.channel?.name ?? record.channelId}</strong> },
{ key: 'accessNo', title: '上行接入号', width: '180px', render: (record) => <strong>{record.destId}</strong> },
{ key: 'matchStatus', title: '匹配状态', width: '140px', render: (record) => <strong>{matchStatusText(record.matchStatus)}</strong> },
{
key: 'actions',
title: '操作',
@@ -228,10 +329,13 @@ export function AdminSmsUplinkRecordsPage() {
{selectedMessage ? (
<UplinkDetailModal
claimError={claimError}
claimingId={claimingId}
detailError={detailError}
matchedRecords={matchedRecords}
matching={matching}
message={selectedMessage}
onClaim={handleClaim}
onClose={() => setSelectedMessage(null)}
/>
) : null}
+13 -2
View File
@@ -17,6 +17,7 @@ import {
Layers3,
MessageSquare,
Phone,
RefreshCw,
TrendingUp,
RadioTower,
ReceiptText,
@@ -37,10 +38,17 @@ import { AppShell } from '@/layouts/AppShell';
export function AdminLayout() {
const session = readSession();
const [pendingAuditCount, setPendingAuditCount] = useState(0);
const [downstreamAlertCount, setDownstreamAlertCount] = useState(0);
const loadPendingAuditCount = useCallback(() => {
adminApi.getDashboard()
.then((dashboard) => setPendingAuditCount(dashboard.pendingAuditCount ?? 0))
.catch(() => setPendingAuditCount(0));
.then((dashboard) => {
setPendingAuditCount(dashboard.pendingAuditCount ?? 0);
setDownstreamAlertCount(dashboard.downstreamDeliverySummary?.alertCount ?? 0);
})
.catch(() => {
setPendingAuditCount(0);
setDownstreamAlertCount(0);
});
}, []);
useEffect(() => {
@@ -71,6 +79,7 @@ export function AdminLayout() {
userRole="平台管理员"
auditNotifications={[
{ label: '待处理审核', count: pendingAuditCount, to: '/admin/sms-audit' },
{ label: '下游投递告警', count: downstreamAlertCount, to: '/admin/downstream-deliveries' },
]}
navSections={[
{
@@ -134,6 +143,8 @@ export function AdminLayout() {
{ label: '短信记录', to: '/admin/sms-records', icon: MessageSquare },
{ label: '彩信记录', to: '/admin/mms-records', icon: ImageIcon, pending: true },
{ label: '短信上行记录', to: '/admin/sms-uplink-records', icon: MessageSquare },
{ label: '下游投递记录', to: '/admin/downstream-deliveries', icon: Send },
{ label: '恢复状态管理', to: '/admin/downstream-recovery-statuses', icon: RefreshCw },
{ label: '充值记录', to: '/admin/recharge-records', icon: ReceiptText },
],
},
+4
View File
@@ -8,6 +8,8 @@ import { AdminCustomerDetailPage } from '@/apps/admin/AdminCustomerDetailPage';
import { AdminCustomerFormPage } from '@/apps/admin/AdminCustomerFormPage';
import { AdminCustomersPage } from '@/apps/admin/AdminCustomersPage';
import { AdminDrainageFieldsPage } from '@/apps/admin/AdminDrainageFieldsPage';
import { AdminDownstreamDeliveriesPage } from '@/apps/admin/AdminDownstreamDeliveriesPage';
import { AdminDownstreamRecoveryStatusesPage } from '@/apps/admin/AdminDownstreamRecoveryStatusesPage';
import { AdminEnterpriseApplicationsPage } from '@/apps/admin/AdminEnterpriseApplicationsPage';
import { AdminEnterpriseBlacklistPage } from '@/apps/admin/AdminEnterpriseBlacklistPage';
import { AdminEnterpriseSignaturesPage } from '@/apps/admin/AdminEnterpriseSignaturesPage';
@@ -103,6 +105,8 @@ export function AppRoutes() {
<Route path="sms-records" element={<AdminSmsRecordsPage />} />
<Route path="mms-records" element={<PagePlaceholder />} />
<Route path="sms-uplink-records" element={<AdminSmsUplinkRecordsPage />} />
<Route path="downstream-deliveries" element={<AdminDownstreamDeliveriesPage />} />
<Route path="downstream-recovery-statuses" element={<AdminDownstreamRecoveryStatusesPage />} />
<Route path="recharge-records" element={<AdminRechargeRecordsPage />} />
<Route path="channels" element={<AdminChannelsPage />} />
<Route path="channels/:channelId/reports" element={<AdminChannelReportPage />} />
+102 -7
View File
@@ -1034,6 +1034,75 @@ h3 {
color: var(--color-text-muted);
}
.downstream-breakdown-table {
display: grid;
}
.downstream-breakdown-table__head,
.downstream-breakdown-table__row {
align-items: center;
display: grid;
gap: var(--space-3);
grid-template-columns: minmax(140px, 1.2fr) repeat(4, minmax(72px, 0.7fr));
min-height: 52px;
}
.downstream-breakdown-table__head {
border-bottom: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
}
.downstream-breakdown-table__head--apps,
.downstream-breakdown-table__row--apps {
grid-template-columns: minmax(180px, 1.4fr) repeat(4, minmax(72px, 0.65fr));
}
.downstream-breakdown-table__row {
border-bottom: 1px solid var(--color-border);
}
.downstream-breakdown-table__row strong,
.downstream-breakdown-table__row span {
min-width: 0;
}
.downstream-breakdown-table__empty {
color: var(--color-text-muted);
padding: var(--space-5) 0 var(--space-1);
}
.downstream-bucket-list {
display: grid;
gap: var(--space-3);
}
.downstream-bucket-list--wrap {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.downstream-bucket-item {
align-items: center;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: flex;
justify-content: space-between;
min-height: 56px;
padding: 0 var(--space-4);
}
.downstream-bucket-item span {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.downstream-bucket-item strong {
color: var(--color-text-strong);
font-size: var(--font-size-lg);
}
.sms-send-page {
display: grid;
gap: var(--space-6);
@@ -4875,6 +4944,18 @@ h3 {
margin-top: var(--space-3);
}
.admin-detail-metric-grid--compact {
display: grid;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.page-inline-hint {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: var(--line-height-base);
}
.admin-enterprise-profile {
display: grid;
gap: var(--space-6);
@@ -8311,13 +8392,18 @@ h3 {
padding-bottom: var(--space-5);
}
.admin-uplink-match-card button {
background: transparent;
border: 0;
color: var(--color-selected);
font-weight: var(--font-weight-semibold);
margin-top: var(--space-4);
padding: 0;
.admin-uplink-candidate-footer {
align-items: center;
display: flex;
gap: var(--space-4);
justify-content: space-between;
margin-top: var(--space-5);
}
.admin-uplink-candidate-footer span {
color: var(--color-text-muted);
line-height: 1.6;
min-width: 0;
}
.admin-uplink-empty-match {
@@ -8797,6 +8883,15 @@ h3 {
position: static;
}
.downstream-breakdown-table {
overflow-x: auto;
}
.downstream-breakdown-table__head,
.downstream-breakdown-table__row {
min-width: 560px;
}
.receiver-table__head,
.receiver-table__row {
grid-template-columns: 64px minmax(0, 1fr) 64px;