feat: complete cmpp gateway delivery recovery workflows
This commit is contained in:
+211
-5
@@ -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 }) }),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user