This commit is contained in:
+319
-57
@@ -1,87 +1,349 @@
|
||||
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
|
||||
import type { BatchRequeueResponse, BatchTaskMessagePage, DailyProfitReport, DailyQualityReport, DailyReconciliationReport, DashboardResponse, DownstreamDeliveryDashboard, DownstreamDeliveryRecord, DownstreamRecoveryStatusExportQuery, DownstreamRecoveryStatusResponse, DownstreamRequeueFilter, DownstreamRequeuePreview, DownstreamRequeueTask, DownstreamRequeueTaskItem, GatewayDownstreamRecoveryStatus, GatewaySubmitException, GatewaySubmitExceptionResponse, OperationLogResponse, PagedResponse, PagedResult, PendingAuditCounts, ProfitReportSummary, ProtocolInteractionLogResponse, QualityReportSummary, ReceiptAnomalyResponse, ReconciliationReportSummary, SendQualityResponse, SignatureChannelQualityResponse, SmsBatchTask, SmsMessageRecord, SmsMessageSegmentAudit, SmsUplinkMessage, SystemLogExportResult } from '../types';
|
||||
import { request, requestBlob, withQuery } from '../core/httpClient';
|
||||
import type {
|
||||
BatchRequeueResponse,
|
||||
BatchTaskMessagePage,
|
||||
DailyProfitReport,
|
||||
DailyQualityReport,
|
||||
DailyReconciliationReport,
|
||||
DashboardResponse,
|
||||
DownstreamDeliveryDashboard,
|
||||
DownstreamDeliveryRecord,
|
||||
DownstreamRecoveryStatusExportQuery,
|
||||
DownstreamRecoveryStatusResponse,
|
||||
DownstreamRequeueFilter,
|
||||
DownstreamRequeuePreview,
|
||||
DownstreamRequeueTask,
|
||||
DownstreamRequeueTaskItem,
|
||||
GatewayDownstreamRecoveryStatus,
|
||||
GatewaySubmitException,
|
||||
GatewaySubmitExceptionResponse,
|
||||
OperationLogResponse,
|
||||
PagedResponse,
|
||||
PagedResult,
|
||||
PendingAuditCounts,
|
||||
ProfitReportSummary,
|
||||
ProtocolInteractionLogResponse,
|
||||
QualityReportSummary,
|
||||
ReceiptAnomalyResponse,
|
||||
ReconciliationReportSummary,
|
||||
SendQualityResponse,
|
||||
SignatureChannelQualityResponse,
|
||||
SmsBatchTask,
|
||||
SmsMessageRecord,
|
||||
SmsMessageSegmentAudit,
|
||||
SmsUplinkMessage,
|
||||
SystemLogExportResult,
|
||||
} from '../types';
|
||||
|
||||
// Read-heavy operations endpoints are isolated from configuration mutations.
|
||||
export const adminOperationsApi = {
|
||||
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||
getPendingAudits: (tenantId?: string) => request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId })),
|
||||
getSendQuality: (date?: string) => request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
|
||||
getDashboard: (tenantId?: string) =>
|
||||
request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||
getPendingAudits: (tenantId?: string) =>
|
||||
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId })),
|
||||
getSendQuality: (date?: string) =>
|
||||
request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
|
||||
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query)),
|
||||
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }) =>
|
||||
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||
listProtocolInteractionLogs: (query: { protocol?: string; direction?: string; eventType?: string; status?: string; keyword?: string; range?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }) =>
|
||||
request<ProtocolInteractionLogResponse>(withQuery('/admin/system-logs/protocol-interactions', query)),
|
||||
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) =>
|
||||
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||
listReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyReconciliationReport> & { summary: ReconciliationReportSummary }>(withQuery('/admin/reports/reconciliation', query)),
|
||||
exportReconciliationReports: (query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/reports/reconciliation/export', query)),
|
||||
listProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyProfitReport> & { dimensionType: 'application' | 'channel'; summary: ProfitReportSummary }>(withQuery('/admin/reports/profit', query)),
|
||||
exportProfitReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/reports/profit/export', query)),
|
||||
listQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DailyQualityReport> & { dimensionType: DailyQualityReport['dimensionType']; summary: QualityReportSummary }>(withQuery('/admin/reports/quality', query)),
|
||||
exportQualityReports: (query: { dateFrom?: string; dateTo?: string; dimensionType?: 'application' | 'channel' | 'signature' | 'drainage'; tenantId?: string; applicationId?: string; channelId?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/reports/quality/export', query)),
|
||||
listSystemLogs: (query: {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
level?: string;
|
||||
module?: string;
|
||||
range?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) => request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||
listProtocolInteractionLogs: (query: {
|
||||
protocol?: string;
|
||||
direction?: string;
|
||||
eventType?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
range?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) => request<ProtocolInteractionLogResponse>(withQuery('/admin/system-logs/protocol-interactions', query)),
|
||||
exportSystemLogs: (query: {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
level?: string;
|
||||
module?: string;
|
||||
range?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
}) => request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||
listReconciliationReports: (
|
||||
query: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
) =>
|
||||
request<PagedResponse<DailyReconciliationReport> & { summary: ReconciliationReportSummary }>(
|
||||
withQuery('/admin/reports/reconciliation', query),
|
||||
),
|
||||
exportReconciliationReports: (
|
||||
query: { dateFrom?: string; dateTo?: string; tenantId?: string; applicationId?: string } = {},
|
||||
) => requestBlob(withQuery('/admin/reports/reconciliation/export', query)),
|
||||
listProfitReports: (
|
||||
query: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
dimensionType?: 'application' | 'channel';
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
) =>
|
||||
request<
|
||||
PagedResponse<DailyProfitReport> & { dimensionType: 'application' | 'channel'; summary: ProfitReportSummary }
|
||||
>(withQuery('/admin/reports/profit', query)),
|
||||
exportProfitReports: (
|
||||
query: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
dimensionType?: 'application' | 'channel';
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
} = {},
|
||||
) => requestBlob(withQuery('/admin/reports/profit/export', query)),
|
||||
listQualityReports: (
|
||||
query: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
dimensionType?: 'application' | 'channel' | 'signature' | 'drainage';
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
) =>
|
||||
request<
|
||||
PagedResponse<DailyQualityReport> & {
|
||||
dimensionType: DailyQualityReport['dimensionType'];
|
||||
summary: QualityReportSummary;
|
||||
}
|
||||
>(withQuery('/admin/reports/quality', query)),
|
||||
exportQualityReports: (
|
||||
query: {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
dimensionType?: 'application' | 'channel' | 'signature' | 'drainage';
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
} = {},
|
||||
) => requestBlob(withQuery('/admin/reports/quality/export', query)),
|
||||
listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) =>
|
||||
request<SmsBatchTask[]>(withQuery('/admin/send/batch-tasks', query)),
|
||||
listAdminBatchTasksPage: (query: { tenantId?: string; status?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
|
||||
request<PagedResult<SmsBatchTask>>(withQuery('/admin/send/batch-tasks', query)),
|
||||
listAdminBatchTasksPage: (query: {
|
||||
tenantId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
enterpriseKeyword?: string;
|
||||
applicationKeyword?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}) => request<PagedResult<SmsBatchTask>>(withQuery('/admin/send/batch-tasks', query)),
|
||||
listAdminBatchTaskMessages: (id: string, query: { phone?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<BatchTaskMessagePage>(withQuery(`/admin/send/batch-tasks/${id}/messages`, query)),
|
||||
terminateAdminBatchTask: (id: string) =>
|
||||
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)),
|
||||
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; channelKeyword?: string; taskId?: string; messageId?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResult<SmsMessageRecord>>(withQuery('/admin/operations/messages', query)),
|
||||
listOperationMessages: (
|
||||
query: {
|
||||
monitorSnapshotId?: string;
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
channelKeyword?: string;
|
||||
taskId?: string;
|
||||
messageId?: string;
|
||||
phoneNumber?: string;
|
||||
contentKeyword?: string;
|
||||
carrier?: string;
|
||||
status?: string;
|
||||
hasDrainage?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
) => request<PagedResult<SmsMessageRecord>>(withQuery('/admin/operations/messages', query)),
|
||||
getOperationMessage: (id: string) => request<SmsMessageRecord>(`/admin/operations/messages/${id}`),
|
||||
exportOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; channelKeyword?: string; phoneNumber?: string; contentKeyword?: string; carrier?: string; status?: string; hasDrainage?: string; queuedAtFrom?: string; queuedAtTo?: string } = {}) =>
|
||||
requestBlob(withQuery('/admin/operations/messages/export', query)),
|
||||
exportOperationMessages: (
|
||||
query: {
|
||||
monitorSnapshotId?: string;
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
channelKeyword?: string;
|
||||
phoneNumber?: string;
|
||||
contentKeyword?: string;
|
||||
carrier?: string;
|
||||
status?: string;
|
||||
hasDrainage?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
} = {},
|
||||
) => requestBlob(withQuery('/admin/operations/messages/export', query)),
|
||||
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
|
||||
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
|
||||
listAdminUplinkMessagesPage: (query: { tenantId?: string; channelId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }) =>
|
||||
request<PagedResult<SmsUplinkMessage>>(withQuery('/admin/operations/uplink-messages', query)),
|
||||
listAdminUplinkMessagesPage: (query: {
|
||||
tenantId?: string;
|
||||
channelId?: string;
|
||||
phoneNumber?: string;
|
||||
keyword?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}) => request<PagedResult<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)),
|
||||
listGatewaySubmitExceptions: (query: { tenantId?: string; applicationId?: string; channelId?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<GatewaySubmitExceptionResponse>(withQuery('/admin/operations/gateway-submit-dead-letters', query)),
|
||||
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)),
|
||||
listGatewaySubmitExceptions: (
|
||||
query: {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
) => request<GatewaySubmitExceptionResponse>(withQuery('/admin/operations/gateway-submit-dead-letters', query)),
|
||||
requeueGatewaySubmitException: (id: string, body: { confirmedNotSubmitted: boolean; reason: string }) =>
|
||||
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/requeue`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
resolveGatewaySubmitException: (id: string) =>
|
||||
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/resolve`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
listReceiptAnomalies: (query: { tenantId?: string; applicationId?: string; channelId?: string; anomalyType?: string; status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<ReceiptAnomalyResponse>(withQuery('/admin/operations/receipt-anomalies', 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; createdAtFrom?: string; createdAtTo?: string } = {}) =>
|
||||
request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
|
||||
listDownstreamRecoveryStatuses: (query: { tenantId?: string; applicationId?: string; state?: string; failureCategory?: string; keyword?: string; updatedAtFrom?: string; updatedAtTo?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<DownstreamRecoveryStatusResponse>(withQuery('/admin/operations/downstream-recovery-statuses', query)),
|
||||
request<GatewaySubmitException>(`/admin/operations/gateway-submit-dead-letters/${id}/resolve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
listReceiptAnomalies: (
|
||||
query: {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
anomalyType?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
) => request<ReceiptAnomalyResponse>(withQuery('/admin/operations/receipt-anomalies', 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;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
} = {},
|
||||
) => request<DownstreamDeliveryDashboard>(withQuery('/admin/operations/downstream-deliveries/dashboard', query)),
|
||||
listDownstreamRecoveryStatuses: (
|
||||
query: {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
state?: string;
|
||||
failureCategory?: string;
|
||||
keyword?: string;
|
||||
updatedAtFrom?: string;
|
||||
updatedAtTo?: 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; createdAtFrom?: string; createdAtTo?: string } = {}) =>
|
||||
request<PagedResponse<DownstreamDeliveryRecord>>(withQuery('/admin/operations/downstream-deliveries', query)),
|
||||
listDownstreamDeliveries: (
|
||||
query: {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
deliveryType?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
} = {},
|
||||
) => 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({}) }),
|
||||
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 }) }),
|
||||
request<BatchRequeueResponse>('/admin/operations/downstream-deliveries/requeue', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
previewDownstreamRequeueTask: (filter: DownstreamRequeueFilter) =>
|
||||
request<DownstreamRequeuePreview>('/admin/operations/downstream-requeue-tasks/preview', { method: 'POST', body: JSON.stringify({ filter }) }),
|
||||
createDownstreamRequeueTask: (body: { previewToken: string; reason: string; ratePerSecond: number; consecutiveFailureLimit: number }) =>
|
||||
request<DownstreamRequeueTask>('/admin/operations/downstream-requeue-tasks', { method: 'POST', body: JSON.stringify(body) }),
|
||||
request<DownstreamRequeuePreview>('/admin/operations/downstream-requeue-tasks/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filter }),
|
||||
}),
|
||||
createDownstreamRequeueTask: (body: {
|
||||
previewToken: string;
|
||||
reason: string;
|
||||
ratePerSecond: number;
|
||||
consecutiveFailureLimit: number;
|
||||
}) =>
|
||||
request<DownstreamRequeueTask>('/admin/operations/downstream-requeue-tasks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
listDownstreamRequeueTasks: (query: { status?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DownstreamRequeueTask>>(withQuery('/admin/operations/downstream-requeue-tasks', query)),
|
||||
getDownstreamRequeueTask: (id: string) => request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}`),
|
||||
listDownstreamRequeueTaskItems: (id: string, query: { status?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
|
||||
request<PagedResponse<DownstreamRequeueTaskItem>>(withQuery(`/admin/operations/downstream-requeue-tasks/${id}/items`, query)),
|
||||
getDownstreamRequeueTask: (id: string) =>
|
||||
request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}`),
|
||||
listDownstreamRequeueTaskItems: (
|
||||
id: string,
|
||||
query: { status?: string; keyword?: string; page?: number; pageSize?: number } = {},
|
||||
) =>
|
||||
request<PagedResponse<DownstreamRequeueTaskItem>>(
|
||||
withQuery(`/admin/operations/downstream-requeue-tasks/${id}/items`, query),
|
||||
),
|
||||
changeDownstreamRequeueTaskStatus: (id: string, action: 'pause' | 'resume' | 'terminate') =>
|
||||
request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}/${action}`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
request<DownstreamRequeueTask>(`/admin/operations/downstream-requeue-tasks/${id}/${action}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -7,14 +7,22 @@ import { ChannelFormModal } from './channels/ChannelFormModal';
|
||||
import { ChannelLogModal } from './channels/ChannelLogModal';
|
||||
import { ChannelTable } from './channels/ChannelTable';
|
||||
import { SmsTestModal } from './channels/SmsTestModal';
|
||||
import { buildChannelPayload, carrierOptions, mapApiChannel, mapUiStatusToApi, statusOptions } from './channels/channelModel';
|
||||
import {
|
||||
buildChannelPayload,
|
||||
carrierOptions,
|
||||
mapApiChannel,
|
||||
mapUiStatusToApi,
|
||||
statusOptions,
|
||||
} from './channels/channelModel';
|
||||
import type { ChannelConfirmAction, ChannelLogState, ChannelModalState, SmsChannel } from './channels/channelTypes';
|
||||
import './channels/AdminChannelsPage.css';
|
||||
import { ChannelEnrollmentPrompt } from './sending-monitor/MonitorConfiguration';
|
||||
|
||||
export function AdminChannelsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [channels, setChannels] = useState<SmsChannel[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [enrollment, setEnrollment] = useState<{ id: string; name: string } | null>(null);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [carrier, setCarrier] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
@@ -29,13 +37,23 @@ export function AdminChannelsPage() {
|
||||
|
||||
function loadChannels(targetPage = page, filters = { keyword, carrier, status }) {
|
||||
Promise.all([
|
||||
adminApi.listChannelsPage({ keyword: filters.keyword.trim() || undefined, carrier: filters.carrier, status: filters.status, page: targetPage, pageSize }),
|
||||
adminApi.listChannelsPage({
|
||||
keyword: filters.keyword.trim() || undefined,
|
||||
carrier: filters.carrier,
|
||||
status: filters.status,
|
||||
page: targetPage,
|
||||
pageSize,
|
||||
}),
|
||||
adminApi.getSendQuality(),
|
||||
])
|
||||
.then(([result, quality]) => {
|
||||
const visibleChannels = result.items;
|
||||
const qualityByChannel = new Map(quality.channels.map((item) => [item.channelId, item]));
|
||||
setChannels(visibleChannels.map((item) => mapApiChannel(item, item.connectionStates ?? [], qualityByChannel.get(item.id))));
|
||||
setChannels(
|
||||
visibleChannels.map((item) =>
|
||||
mapApiChannel(item, item.connectionStates ?? [], qualityByChannel.get(item.id)),
|
||||
),
|
||||
);
|
||||
setTotal(result.total);
|
||||
setError('');
|
||||
})
|
||||
@@ -54,11 +72,12 @@ export function AdminChannelsPage() {
|
||||
if (modal?.mode === 'edit' && modal.channel) {
|
||||
await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
|
||||
} else {
|
||||
await adminApi.createChannel({
|
||||
const created = await adminApi.createChannel({
|
||||
code: `CH-${Date.now()}`,
|
||||
...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'),
|
||||
status: 'active',
|
||||
});
|
||||
setEnrollment({ id: created.id, name: created.name });
|
||||
}
|
||||
loadChannels();
|
||||
setModal(null);
|
||||
@@ -74,7 +93,8 @@ export function AdminChannelsPage() {
|
||||
}
|
||||
|
||||
async function copyChannel(channel: SmsChannel) {
|
||||
await adminApi.copyChannel(channel.id);
|
||||
const created = await adminApi.copyChannel(channel.id);
|
||||
setEnrollment({ id: created.id, name: created.name });
|
||||
loadChannels();
|
||||
}
|
||||
|
||||
@@ -103,34 +123,72 @@ export function AdminChannelsPage() {
|
||||
setConfirmAction(null);
|
||||
}
|
||||
|
||||
const confirmTitle = confirmAction?.type === 'copy'
|
||||
? '确认复制通道'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '确认启用通道'
|
||||
: '确认停用通道';
|
||||
const confirmTitle =
|
||||
confirmAction?.type === 'copy'
|
||||
? '确认复制通道'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '确认启用通道'
|
||||
: '确认停用通道';
|
||||
|
||||
const confirmDescription = confirmAction?.type === 'copy'
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||
: '停用后该通道将不再承接新的发送任务。';
|
||||
const confirmDescription =
|
||||
confirmAction?.type === 'copy'
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||
: '停用后该通道将不再承接新的发送任务。';
|
||||
|
||||
return (
|
||||
<section className="page-stack sms-channel-page">
|
||||
<div className="page-heading">
|
||||
<Breadcrumb items={['短信通道管理']} />
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModal({ mode: 'create' })}>添加通道</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModal({ mode: 'create' })}>
|
||||
添加通道
|
||||
</Button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface sms-channel-filter">
|
||||
<div className="sms-channel-filter-grid">
|
||||
<Input label="通道名称" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入通道名称" value={keyword} />
|
||||
<Select label="运营商" onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
|
||||
<Select label="当前状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<Input
|
||||
label="通道名称"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="请输入通道名称"
|
||||
value={keyword}
|
||||
/>
|
||||
<Select
|
||||
label="运营商"
|
||||
onChange={(event) => setCarrier(event.target.value)}
|
||||
options={carrierOptions}
|
||||
value={carrier}
|
||||
/>
|
||||
<Select
|
||||
label="当前状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={statusOptions}
|
||||
value={status}
|
||||
/>
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else void loadChannels(1); }}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); if (page !== 1) setPage(1); else void loadChannels(1, { keyword: '', carrier: 'all', status: 'all' }); }} variant="ghost">重置</Button>
|
||||
<Button
|
||||
icon={<Search size={16} />}
|
||||
onClick={() => {
|
||||
if (page !== 1) setPage(1);
|
||||
else void loadChannels(1);
|
||||
}}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeyword('');
|
||||
setCarrier('all');
|
||||
setStatus('all');
|
||||
if (page !== 1) setPage(1);
|
||||
else void loadChannels(1, { keyword: '', carrier: 'all', status: 'all' });
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,6 +208,7 @@ export function AdminChannelsPage() {
|
||||
/>
|
||||
|
||||
{modal ? <ChannelFormModal modal={modal} onClose={() => setModal(null)} onSubmit={upsertChannel} /> : null}
|
||||
{enrollment && <ChannelEnrollmentPrompt channel={enrollment} onClose={() => setEnrollment(null)} />}
|
||||
{testChannel ? (
|
||||
<SmsTestModal
|
||||
channel={testChannel}
|
||||
@@ -163,12 +222,14 @@ export function AdminChannelsPage() {
|
||||
|
||||
{confirmAction ? (
|
||||
<Modal
|
||||
footer={(
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={() => setConfirmAction(null)} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submitConfirmAction}>确认</Button>
|
||||
</>
|
||||
)}
|
||||
}
|
||||
onClose={() => setConfirmAction(null)}
|
||||
open
|
||||
title={confirmTitle}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
.sending-monitor .sending-monitor__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__notice {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-selected-soft);
|
||||
color: var(--color-selected);
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__summary > div {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__summary strong {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 2fr) minmax(200px, 1fr) minmax(140px, 1fr);
|
||||
align-items: end;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__rate {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__rate--bad {
|
||||
color: var(--color-danger, #dc2626);
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__form,
|
||||
.sending-monitor .sending-monitor__scope {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__rule {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__trend-point {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__bars {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__bars > span {
|
||||
height: 5px;
|
||||
background: var(--color-selected);
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (width <= 767px) {
|
||||
.sending-monitor .sending-monitor__summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__filters,
|
||||
.sending-monitor .sending-monitor__form,
|
||||
.sending-monitor .sending-monitor__scope {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__desktop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__mobile {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__mobile > article {
|
||||
padding: 16px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sending-monitor .sending-monitor__mobile-metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,308 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { Breadcrumb, Button, CarrierTag, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type AdminChannel } from '@/api/adminApi';
|
||||
|
||||
const columns: Array<TableColumn<AdminChannel>> = [
|
||||
{ key: 'id', title: '通道编号', render: (record) => record.id },
|
||||
{ key: 'name', title: '通道名称', render: (record) => record.name },
|
||||
{ key: 'carrier', title: '运营商', render: (record) => {
|
||||
const carriers = record.carriers?.length ? record.carriers : record.carrier === 'all' ? ['mobile', 'unicom', 'telecom'] : record.carrier ? [record.carrier] : [];
|
||||
return carriers.length ? <span className="ui-carrier-tags">{carriers.map((carrier) => <CarrierTag carrier={carrier} key={carrier} />)}</span> : '-';
|
||||
} },
|
||||
{ key: 'gatewayHost', title: '网关地址', render: (record) => `${record.gatewayHost}:${record.gatewayPort}` },
|
||||
{ key: 'rateLimitPerSecond', title: '限速', render: (record) => `${record.rateLimitPerSecond} 条/秒` },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'danger'}>{record.status === 'active' ? '运行中' : '已停用'}</Tag>,
|
||||
},
|
||||
];
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Input, Select, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||
import { MonitorRuntimeOverview } from './MonitorRuntimeOverview';
|
||||
import { MonitorRulesModal, MonitorScopePicker, MonitorTargetsModal } from './sending-monitor/MonitorConfiguration';
|
||||
import { MonitorAlerts, MonitorHistory, MonitorPager, Rate } from './sending-monitor/MonitorDetails';
|
||||
import {
|
||||
monitorApi,
|
||||
names,
|
||||
ruleSource,
|
||||
states,
|
||||
time,
|
||||
title,
|
||||
type MonitorType,
|
||||
type Snapshot,
|
||||
} from './sending-monitor/monitorApi';
|
||||
import './AdminMonitorPage.css';
|
||||
|
||||
export function AdminMonitorPage() {
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [monitor, setMonitor] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
Promise.all([adminApi.listChannels(), adminApi.listMonitor()])
|
||||
.then(([channelItems, monitorData]) => {
|
||||
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
|
||||
setMonitor(monitorData);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '监控数据加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab = params.get('tab') ?? 'industry';
|
||||
const type: MonitorType = tab === 'overall' || tab === 'verification' ? tab : 'industry';
|
||||
const [data, setData] = useState<Awaited<ReturnType<typeof monitorApi.rows>> | null>(null);
|
||||
const [summary, setSummary] = useState<Awaited<ReturnType<typeof monitorApi.overview>> | null>(null);
|
||||
const [error, setError] = useState(''),
|
||||
[loading, setLoading] = useState(false),
|
||||
[dialog, setDialog] = useState<'rules' | 'targets' | null>(null),
|
||||
[detail, setDetail] = useState<Snapshot | null>(null);
|
||||
const lastLoad = useRef(0),
|
||||
busy = useRef(false);
|
||||
const page = Math.max(1, Number(params.get('page')) || 1),
|
||||
queryKey = params.toString();
|
||||
const update = (values: Record<string, string>) => {
|
||||
const next = new URLSearchParams(params);
|
||||
Object.entries(values).forEach(([key, value]) => (value ? next.set(key, value) : next.delete(key)));
|
||||
setParams(next);
|
||||
};
|
||||
const load = useCallback(
|
||||
async (force = false, signal?: AbortSignal) => {
|
||||
if (
|
||||
tab === 'alerts' ||
|
||||
tab === 'runtime' ||
|
||||
document.hidden ||
|
||||
(busy.current && !force) ||
|
||||
(!force && Date.now() - lastLoad.current < 1500)
|
||||
)
|
||||
return;
|
||||
busy.current = true;
|
||||
lastLoad.current = Date.now();
|
||||
setLoading(true);
|
||||
const q = new URLSearchParams(queryKey);
|
||||
try {
|
||||
const [rows, overview] = await Promise.all([
|
||||
monitorApi.rows(
|
||||
{
|
||||
type,
|
||||
page: q.get('page') ?? '1',
|
||||
status: q.get('status') ?? '',
|
||||
keyword: q.get('keyword') ?? '',
|
||||
tenantId: q.get('tenantId') ?? '',
|
||||
applicationId: q.get('applicationId') ?? '',
|
||||
signatureId: q.get('signatureId') ?? '',
|
||||
},
|
||||
signal,
|
||||
),
|
||||
monitorApi.overview(type),
|
||||
]);
|
||||
if (!signal?.aborted) {
|
||||
setData(rows);
|
||||
setSummary(overview);
|
||||
setError('');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!signal?.aborted) setError(e instanceof Error ? e.message : '监控加载失败');
|
||||
} finally {
|
||||
if (!signal?.aborted) {
|
||||
setLoading(false);
|
||||
busy.current = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
[queryKey, tab, type],
|
||||
);
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const enabledChannels = channels.filter((item) => item.status === 'active').length;
|
||||
const statusGroups = Array.isArray(monitor.byStatus) ? monitor.byStatus as Array<{ status: string; _count: { _all: number } }> : [];
|
||||
const totalMessages = useMemo(() => statusGroups.reduce((sum, item) => sum + (item._count?._all ?? 0), 0), [statusGroups]);
|
||||
const deliveredMessages = useMemo(() => statusGroups.filter((item) => item.status === 'delivered').reduce((sum, item) => sum + (item._count?._all ?? 0), 0), [statusGroups]);
|
||||
const successRate = totalMessages > 0 ? ((deliveredMessages / totalMessages) * 100).toFixed(1) : '0.0';
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
const controller = new AbortController();
|
||||
void load(true, controller.signal);
|
||||
const refresh = () => void load(false, controller.signal);
|
||||
const timer = setInterval(refresh, 30000);
|
||||
window.addEventListener('focus', refresh);
|
||||
document.addEventListener('visibilitychange', refresh);
|
||||
return () => {
|
||||
controller.abort();
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('focus', refresh);
|
||||
document.removeEventListener('visibilitychange', refresh);
|
||||
};
|
||||
}, [load]);
|
||||
const health = data?.health?.data;
|
||||
const stale = !health || !health.complete || Date.now() - new Date(health.checkedAt).getTime() > 30000;
|
||||
const rows = data?.items ?? [];
|
||||
const columns: TableColumn<Snapshot>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: type === 'industry' ? '通道 / 运营商' : '企业 / 应用 / 签名',
|
||||
width: '260px',
|
||||
render: (row) => (
|
||||
<div className="ui-table__long-text">
|
||||
<strong>{title(row.dimensions)}</strong>
|
||||
<p>{row.dimensions.channelId ?? row.dimensions.signatureId}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'total', title: '窗口提交量', width: '110px', render: (row) => row.metrics.total.toLocaleString() },
|
||||
...(type === 'overall' ? [60, 300, 1200] : [5, 20, 60]).map((seconds, i): TableColumn<Snapshot> => ({
|
||||
key: `rate${seconds}`,
|
||||
title: `${seconds < 60 ? `${seconds}秒` : `${seconds / 60}分钟`}到达率`,
|
||||
width: '165px',
|
||||
render: (row) => <Rate value={row.metrics.metrics[i]} />,
|
||||
})),
|
||||
{
|
||||
key: 'state',
|
||||
title: '状态 / 规则',
|
||||
width: '140px',
|
||||
render: (row) => (
|
||||
<>
|
||||
<Tag tone={!stale && row.status === 'abnormal' ? 'danger' : 'neutral'}>
|
||||
{stale ? '数据延迟' : states[row.status]}
|
||||
</Tag>
|
||||
<p>
|
||||
{ruleSource(row.rule)}
|
||||
{row.rule ? ` v${row.rule.version}` : ''}
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'detail',
|
||||
title: '操作',
|
||||
width: '110px',
|
||||
render: (row) => (
|
||||
<Button variant="ghost" size="sm" onClick={() => setDetail(row)}>
|
||||
趋势 / 详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
const counts = summary?.rows ?? [];
|
||||
const cards = [
|
||||
['监控维度', counts.reduce((n, r) => n + r.dimensions, 0)],
|
||||
['异常维度', counts.filter((r) => r.status === 'abnormal').reduce((n, r) => n + r.dimensions, 0)],
|
||||
[
|
||||
'样本不足',
|
||||
counts
|
||||
.filter((r) => ['sample_insufficient', 'unassessable'].includes(r.status))
|
||||
.reduce((n, r) => n + r.dimensions, 0),
|
||||
],
|
||||
['窗口提交量', counts.reduce((n, r) => n + Number(r.total), 0)],
|
||||
];
|
||||
const content = (
|
||||
<div className="page-stack">
|
||||
<div className="sending-monitor__summary">
|
||||
{cards.map(([label, value]) => (
|
||||
<div className="surface" key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{data ? Number(value).toLocaleString() : '—'}</strong>
|
||||
<small>{type === 'industry' ? '按通道发送尝试' : '按唯一业务短信'}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="surface sending-monitor__filters">
|
||||
<div>
|
||||
<Breadcrumb items={['发送监控']} />
|
||||
<strong>评估时刻:{time(counts[0]?.evaluationAt)}</strong>
|
||||
<p>
|
||||
每{type === 'overall' ? '10' : '5'}分钟评估,提交窗口最近{type === 'overall' ? '30' : '5'}分钟
|
||||
</p>
|
||||
<small>
|
||||
采集检查:{time(health?.checkedAt)}
|
||||
{stale ? ' · 数据延迟或等待首次计算,暂停告警判断' : ' · 已完成采集'}
|
||||
</small>
|
||||
</div>
|
||||
<Button icon={<Activity size={16} />} onClick={loadData} variant="ghost">实时刷新</Button>
|
||||
<Input
|
||||
aria-label="搜索监控对象"
|
||||
placeholder="搜索通道、企业、应用或签名"
|
||||
value={params.get('keyword') ?? ''}
|
||||
onChange={(e) => update({ keyword: e.target.value, page: '1' })}
|
||||
/>
|
||||
<Select
|
||||
label="状态"
|
||||
value={params.get('status') ?? ''}
|
||||
options={[
|
||||
{ value: '', label: '全部' },
|
||||
...['abnormal', 'normal', 'sample_insufficient', 'stale', 'unassessable', 'unconfigured', 'no_data'].map(
|
||||
(value) => ({ value, label: states[value] }),
|
||||
),
|
||||
]}
|
||||
onChange={(e) => update({ status: e.target.value, page: '1' })}
|
||||
/>
|
||||
</div>
|
||||
{loading ? <p className="muted">正在加载监控数据...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface metric-card">
|
||||
<span>运行通道</span>
|
||||
<strong>{enabledChannels}</strong>
|
||||
<small>共 {channels.length} 条通道</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>平均成功率</span>
|
||||
<strong>{successRate}%</strong>
|
||||
<small>真实消息记录</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>消息总量</span>
|
||||
<strong>{totalMessages.toLocaleString('zh-CN')}</strong>
|
||||
<small>按当前查询聚合</small>
|
||||
{type !== 'industry' && (
|
||||
<details className="surface">
|
||||
<summary>按企业、应用、签名筛选</summary>
|
||||
<MonitorScopePicker
|
||||
scope={{
|
||||
tenantId: params.get('tenantId') ?? undefined,
|
||||
applicationId: params.get('applicationId') ?? undefined,
|
||||
signatureId: params.get('signatureId') ?? undefined,
|
||||
}}
|
||||
onChange={(scope) =>
|
||||
update({
|
||||
tenantId: scope.tenantId ?? '',
|
||||
applicationId: scope.applicationId ?? '',
|
||||
signatureId: scope.signatureId ?? '',
|
||||
page: '1',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
{loading && <p role="status">正在读取监控快照…</p>}
|
||||
{error && (
|
||||
<p role="alert" className="form-error">
|
||||
{error},保留上次结果,请重试。
|
||||
</p>
|
||||
)}
|
||||
<div className="surface sending-monitor__desktop">
|
||||
<Table columns={columns} data={rows} rowKey="id" />
|
||||
</div>
|
||||
<div className="sending-monitor__mobile">
|
||||
{rows.map((row) => (
|
||||
<article className="surface" key={row.id}>
|
||||
<h3>{title(row.dimensions)}</h3>
|
||||
<Tag tone="neutral">{stale ? '数据延迟' : states[row.status]}</Tag>
|
||||
<p>
|
||||
窗口提交 {row.metrics.total} · {ruleSource(row.rule)}
|
||||
</p>
|
||||
{row.metrics.metrics.map((m) => (
|
||||
<div className="sending-monitor__mobile-metric" key={m.seconds}>
|
||||
<span>{m.seconds}秒到达率</span>
|
||||
<Rate value={m} />
|
||||
</div>
|
||||
))}
|
||||
<Button variant="ghost" onClick={() => setDetail(row)}>
|
||||
趋势 / 详情
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{!loading && !rows.length && !error && (
|
||||
<p className="surface">
|
||||
暂无监控快照。行业通道需先加入监控;无流量不会生成应用签名维度。历史记录缺少发包时间时不制造到达率。
|
||||
</p>
|
||||
)}
|
||||
<MonitorPager page={page} total={data?.total ?? 0} onChange={(p) => update({ page: String(p) })} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<section className="sending-monitor page-stack">
|
||||
<div className="page-heading">
|
||||
<Breadcrumb items={['运营概览', '发送监控']} />
|
||||
<div className="sending-monitor__actions">
|
||||
<Button variant="secondary" disabled={loading} onClick={() => void load()}>
|
||||
刷新
|
||||
</Button>
|
||||
{!['alerts', 'runtime'].includes(tab) && (
|
||||
<Button variant="secondary" onClick={() => setDialog('rules')}>
|
||||
阈值设置
|
||||
</Button>
|
||||
)}
|
||||
{tab === 'industry' && <Button onClick={() => setDialog('targets')}>监控通道</Button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={channels} rowKey="id" />
|
||||
</div>
|
||||
<p className="sending-monitor__notice">
|
||||
以平台收到完整成功回执为准;未满观察时长的短信不参与该时效评估。各指标分别展示成功数 / 成熟分母。
|
||||
</p>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(value) => {
|
||||
setData(null);
|
||||
setSummary(null);
|
||||
setParams({ tab: value });
|
||||
}}
|
||||
items={[
|
||||
...Object.entries(names).map(([value, label]) => ({ value, label, content })),
|
||||
{ value: 'alerts', label: '告警记录', content: <MonitorAlerts /> },
|
||||
{ value: 'runtime', label: '运行概况', content: <MonitorRuntimeOverview /> },
|
||||
]}
|
||||
/>
|
||||
{dialog === 'rules' && (
|
||||
<MonitorRulesModal
|
||||
type={type}
|
||||
onClose={() => {
|
||||
setDialog(null);
|
||||
void load(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{dialog === 'targets' && (
|
||||
<MonitorTargetsModal
|
||||
onClose={() => {
|
||||
setDialog(null);
|
||||
void load(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{detail && <MonitorHistory row={detail} onClose={() => setDetail(null)} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ReportNotificationsPage } from '../report-notifications/ReportNotificationsPage';
|
||||
import { Clock3, Eye, Search } from 'lucide-react';
|
||||
import { adminApi, type ReportRecord } from '@/api/adminApi';
|
||||
import {
|
||||
@@ -147,6 +149,34 @@ function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose:
|
||||
}
|
||||
|
||||
export function AdminReportRecordsPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const readiness = params.get('tab') === 'readiness';
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading" role="tablist" aria-label="状态记录类型">
|
||||
<Button
|
||||
role="tab"
|
||||
aria-selected={!readiness}
|
||||
variant={readiness ? 'secondary' : 'primary'}
|
||||
onClick={() => setParams({})}
|
||||
>
|
||||
状态记录
|
||||
</Button>
|
||||
<Button
|
||||
role="tab"
|
||||
aria-selected={readiness}
|
||||
variant={readiness ? 'primary' : 'secondary'}
|
||||
onClick={() => setParams({ tab: 'readiness' })}
|
||||
>
|
||||
报备状态变化消息
|
||||
</Button>
|
||||
</div>
|
||||
{readiness ? <ReportNotificationsPage portal="admin" /> : <ReportStatusRecords />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportStatusRecords() {
|
||||
const [records, setRecords] = useState<ReportRecord[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
@@ -159,7 +189,15 @@ export function AdminReportRecordsPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [appliedFilters, setAppliedFilters] = useState({ keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', batchNo: '', operatorKeyword: '', statusAfter: 'all', sourceEntry: 'all' });
|
||||
const [appliedFilters, setAppliedFilters] = useState({
|
||||
keyword: '',
|
||||
dateRange: {} as DateRangeValue,
|
||||
reportType: 'all',
|
||||
batchNo: '',
|
||||
operatorKeyword: '',
|
||||
statusAfter: 'all',
|
||||
sourceEntry: 'all',
|
||||
});
|
||||
const pageSize = 10;
|
||||
|
||||
function loadData(targetPage = page, filters = appliedFilters) {
|
||||
@@ -193,7 +231,11 @@ export function AdminReportRecordsPage() {
|
||||
key: 'task',
|
||||
title: '报备任务号',
|
||||
width: '160px',
|
||||
render: (record) => <strong className="admin-task-id admin-report-record-id" title={record.taskId}>{record.taskId}</strong>,
|
||||
render: (record) => (
|
||||
<strong className="admin-task-id admin-report-record-id" title={record.taskId}>
|
||||
{record.taskId}
|
||||
</strong>
|
||||
),
|
||||
},
|
||||
{ key: 'channel', title: '通道名称', width: '130px', render: (record) => record.channel?.name ?? '-' },
|
||||
{
|
||||
@@ -328,7 +370,15 @@ export function AdminReportRecordsPage() {
|
||||
<Button
|
||||
icon={<Search size={16} />}
|
||||
onClick={() => {
|
||||
const filters = { keyword: keyword.trim(), dateRange, reportType, batchNo: batchNo.trim(), operatorKeyword: operatorKeyword.trim(), statusAfter, sourceEntry };
|
||||
const filters = {
|
||||
keyword: keyword.trim(),
|
||||
dateRange,
|
||||
reportType,
|
||||
batchNo: batchNo.trim(),
|
||||
operatorKeyword: operatorKeyword.trim(),
|
||||
statusAfter,
|
||||
sourceEntry,
|
||||
};
|
||||
setAppliedFilters(filters);
|
||||
if (page !== 1) setPage(1);
|
||||
else loadData(1, filters);
|
||||
@@ -345,7 +395,15 @@ export function AdminReportRecordsPage() {
|
||||
setOperatorKeyword('');
|
||||
setStatusAfter('all');
|
||||
setSourceEntry('all');
|
||||
const filters = { keyword: '', dateRange: {} as DateRangeValue, reportType: 'all', batchNo: '', operatorKeyword: '', statusAfter: 'all', sourceEntry: 'all' };
|
||||
const filters = {
|
||||
keyword: '',
|
||||
dateRange: {} as DateRangeValue,
|
||||
reportType: 'all',
|
||||
batchNo: '',
|
||||
operatorKeyword: '',
|
||||
statusAfter: 'all',
|
||||
sourceEntry: 'all',
|
||||
};
|
||||
setAppliedFilters(filters);
|
||||
if (page !== 1) setPage(1);
|
||||
else loadData(1, filters);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type SmsMessageRecord, type SmsMessageSegmentAudit } from '@/api/adminApi';
|
||||
import { Breadcrumb, type DateRangeValue } from '@/components/ui';
|
||||
import { SendDetailModal } from './sms-records/SendDetailModal';
|
||||
@@ -11,10 +12,14 @@ import './sms-records/AdminSmsRecordsPage.css';
|
||||
const pageSize = 25;
|
||||
|
||||
export function AdminSmsRecordsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const monitorSnapshotId = searchParams.get('monitorSnapshotId') ?? undefined;
|
||||
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
||||
const [enterprise, setEnterprise] = useState('all');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>(defaultSmsRecordDateRange);
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>(() =>
|
||||
monitorSnapshotId ? {} : defaultSmsRecordDateRange(),
|
||||
);
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [channel, setChannel] = useState('all');
|
||||
@@ -27,10 +32,11 @@ export function AdminSmsRecordsPage() {
|
||||
const [error, setError] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterTenants, setFilterTenants] = useState<TenantOption[]>([]);
|
||||
const [filterApplications, setFilterApplications] = useState<ApplicationOption[]>([]);
|
||||
const [filterChannels, setFilterChannels] = useState<AdminChannel[]>([]);
|
||||
const listRequestSequence = useRef(0);
|
||||
const detailRequestSequence = useRef(0);
|
||||
|
||||
function currentFilters(): MessageFilters {
|
||||
@@ -49,31 +55,44 @@ export function AdminSmsRecordsPage() {
|
||||
}
|
||||
|
||||
function loadData(filters = currentFilters(), targetPage = page) {
|
||||
setLoading(true);
|
||||
adminApi.listOperationMessages({ ...filters, page: targetPage, pageSize })
|
||||
const sequence = ++listRequestSequence.current;
|
||||
adminApi
|
||||
.listOperationMessages({ ...filters, monitorSnapshotId, page: targetPage, pageSize })
|
||||
.then((result) => {
|
||||
if (sequence !== listRequestSequence.current) return;
|
||||
setRecords(result.items);
|
||||
setTotal(result.total);
|
||||
setSelectedRecord((current) => current ? result.items.find((item) => item.id === current.id) ?? null : null);
|
||||
setSelectedRecord((current) =>
|
||||
current ? (result.items.find((item) => item.id === current.id) ?? null) : null,
|
||||
);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '短信记录加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
.catch((failure: Error) => {
|
||||
if (sequence === listRequestSequence.current) setError(failure.message || '短信记录加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (sequence === listRequestSequence.current) setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData(currentFilters(), page);
|
||||
}, [page]);
|
||||
return () => {
|
||||
listRequestSequence.current++;
|
||||
};
|
||||
}, [page, monitorSnapshotId]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([adminApi.listTenantOptions(), adminApi.listEnterpriseApplicationOptions(), adminApi.listChannels()])
|
||||
.then(([tenants, applications, channels]) => {
|
||||
setFilterTenants(tenants
|
||||
.filter((item) => item.status !== 'deleted')
|
||||
.map((item) => ({ id: item.id, name: item.name })));
|
||||
setFilterApplications(applications
|
||||
.filter((item) => item.status !== 'deleted')
|
||||
.map((item) => ({ id: item.id, tenantId: item.tenantId, name: item.name })));
|
||||
setFilterTenants(
|
||||
tenants.filter((item) => item.status !== 'deleted').map((item) => ({ id: item.id, name: item.name })),
|
||||
);
|
||||
setFilterApplications(
|
||||
applications
|
||||
.filter((item) => item.status !== 'deleted')
|
||||
.map((item) => ({ id: item.id, tenantId: item.tenantId, name: item.name })),
|
||||
);
|
||||
setFilterChannels(channels.filter((item) => item.status !== 'deleted'));
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '短信记录筛选项加载失败'));
|
||||
@@ -116,17 +135,23 @@ export function AdminSmsRecordsPage() {
|
||||
);
|
||||
|
||||
const applicationOptions = useMemo(
|
||||
() => [{ label: '全部应用', value: 'all' }, ...filterApplications
|
||||
.filter((item) => enterprise === 'all' || item.tenantId === enterprise)
|
||||
.map((item) => ({ label: item.name, value: item.id }))],
|
||||
() => [
|
||||
{ label: '全部应用', value: 'all' },
|
||||
...filterApplications
|
||||
.filter((item) => enterprise === 'all' || item.tenantId === enterprise)
|
||||
.map((item) => ({ label: item.name, value: item.id })),
|
||||
],
|
||||
[enterprise, filterApplications],
|
||||
);
|
||||
|
||||
const channelOptions = useMemo(
|
||||
() => [{ label: '全部通道', value: 'all' }, ...filterChannels.map((item) => ({
|
||||
label: item.code ? `${item.name}(${item.code})` : item.name,
|
||||
value: item.id,
|
||||
}))],
|
||||
() => [
|
||||
{ label: '全部通道', value: 'all' },
|
||||
...filterChannels.map((item) => ({
|
||||
label: item.code ? `${item.name}(${item.code})` : item.name,
|
||||
value: item.id,
|
||||
})),
|
||||
],
|
||||
[filterChannels],
|
||||
);
|
||||
|
||||
@@ -134,6 +159,7 @@ export function AdminSmsRecordsPage() {
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
|
||||
function resetFilters() {
|
||||
setLoading(true);
|
||||
const defaultDateRange = defaultSmsRecordDateRange();
|
||||
setEnterprise('all');
|
||||
setApplication('all');
|
||||
@@ -150,7 +176,7 @@ export function AdminSmsRecordsPage() {
|
||||
|
||||
async function exportRecords() {
|
||||
try {
|
||||
const blob = await adminApi.exportOperationMessages(currentFilters());
|
||||
const blob = await adminApi.exportOperationMessages({ ...currentFilters(), monitorSnapshotId });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
@@ -170,6 +196,7 @@ export function AdminSmsRecordsPage() {
|
||||
<h1>短信记录</h1>
|
||||
</div>
|
||||
</div>
|
||||
{monitorSnapshotId && <p className="muted">当前仅显示所选监控窗口的样本;筛选和导出均限定在该范围内。</p>}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<SmsRecordFilter
|
||||
@@ -197,6 +224,7 @@ export function AdminSmsRecordsPage() {
|
||||
onHasDrainageChange={setHasDrainage}
|
||||
onPhoneKeywordChange={setPhoneKeyword}
|
||||
onQuery={() => {
|
||||
setLoading(true);
|
||||
if (page !== 1) setPage(1);
|
||||
else loadData(currentFilters(), 1);
|
||||
}}
|
||||
@@ -212,7 +240,10 @@ export function AdminSmsRecordsPage() {
|
||||
totalPages={totalPages}
|
||||
onExport={() => void exportRecords()}
|
||||
onOpenDetail={openDetail}
|
||||
onPageChange={setPage}
|
||||
onPageChange={(next) => {
|
||||
setLoading(true);
|
||||
setPage(next);
|
||||
}}
|
||||
/>
|
||||
|
||||
{selectedRecord ? (
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Activity } from 'lucide-react';
|
||||
import { Breadcrumb, Button, CarrierTag, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type AdminChannel } from '@/api/adminApi';
|
||||
|
||||
const columns: Array<TableColumn<AdminChannel>> = [
|
||||
{ key: 'id', title: '通道编号', render: (record) => record.id },
|
||||
{ key: 'name', title: '通道名称', render: (record) => record.name },
|
||||
{
|
||||
key: 'carrier',
|
||||
title: '运营商',
|
||||
render: (record) => {
|
||||
const carriers = record.carriers?.length
|
||||
? record.carriers
|
||||
: record.carrier === 'all'
|
||||
? ['mobile', 'unicom', 'telecom']
|
||||
: record.carrier
|
||||
? [record.carrier]
|
||||
: [];
|
||||
return carriers.length ? (
|
||||
<span className="ui-carrier-tags">
|
||||
{carriers.map((carrier) => (
|
||||
<CarrierTag carrier={carrier} key={carrier} />
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'gatewayHost', title: '网关地址', render: (record) => `${record.gatewayHost}:${record.gatewayPort}` },
|
||||
{ key: 'rateLimitPerSecond', title: '限速', render: (record) => `${record.rateLimitPerSecond} 条/秒` },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
render: (record) => (
|
||||
<Tag tone={record.status === 'active' ? 'success' : 'danger'}>
|
||||
{record.status === 'active' ? '已启用' : '已停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function MonitorRuntimeOverview() {
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [monitor, setMonitor] = useState<Record<string, unknown>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
Promise.all([adminApi.listChannels(), adminApi.listMonitor()])
|
||||
.then(([channelItems, monitorData]) => {
|
||||
setChannels(channelItems.filter((item) => item.status !== 'deleted'));
|
||||
setMonitor(monitorData);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '监控数据加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const enabledChannels = channels.filter((item) => item.status === 'active').length;
|
||||
const statusGroups = Array.isArray(monitor.byStatus)
|
||||
? (monitor.byStatus as Array<{ status: string; _count: { _all: number } }>)
|
||||
: [];
|
||||
const totalMessages = statusGroups.reduce((sum, item) => sum + (item._count?._all ?? 0), 0);
|
||||
const deliveredMessages = statusGroups
|
||||
.filter((item) => item.status === 'delivered')
|
||||
.reduce((sum, item) => sum + (item._count?._all ?? 0), 0);
|
||||
const successRate = totalMessages ? ((deliveredMessages / totalMessages) * 100).toFixed(1) + '%' : '—';
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['发送监控']} />
|
||||
</div>
|
||||
<Button
|
||||
icon={<Activity size={16} />}
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
loadData();
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
实时刷新
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? <p className="muted">正在加载监控数据...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="dashboard-grid">
|
||||
<div className="surface metric-card">
|
||||
<span>启用通道</span>
|
||||
<strong>{enabledChannels}</strong>
|
||||
<small>共 {channels.length} 条通道</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>平均成功率</span>
|
||||
<strong>{successRate}</strong>
|
||||
<small>消息当前状态口径,与时效到达率不同</small>
|
||||
</div>
|
||||
<div className="surface metric-card">
|
||||
<span>消息总量</span>
|
||||
<strong>{totalMessages.toLocaleString('zh-CN')}</strong>
|
||||
<small>按当前查询聚合</small>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<Link to="/admin/system-monitoring">查看实时连接、队列与基础设施状态</Link> · 通道启用状态不代表连接正常。
|
||||
</p>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={channels} rowKey="id" />
|
||||
</div>
|
||||
{[
|
||||
['recentMessages', '最近短信', '/admin/sms-records'],
|
||||
['recentReceipts', '最近状态报告', '/admin/sms-records'],
|
||||
['recentUplinks', '最近上行', '/admin/sms-uplink-records'],
|
||||
].map(([key, label, to]) => (
|
||||
<div className="surface" key={key}>
|
||||
<h2>{label}</h2>
|
||||
<Link to={to}>查看全部</Link>
|
||||
<Table
|
||||
rowKey="id"
|
||||
data={Array.isArray(monitor[key]) ? (monitor[key] as Record<string, unknown>[]) : []}
|
||||
columns={[
|
||||
{ key: 'messageId', title: '消息编号', render: (r) => String(r.messageId ?? r.gatewayMessageId ?? r.id) },
|
||||
{ key: 'status', title: '状态', render: (r) => String(r.status ?? r.receiptStatus ?? '—') },
|
||||
{
|
||||
key: 'time',
|
||||
title: '时间',
|
||||
render: (r) => new Date(String(r.queuedAt ?? r.deliveredAt ?? r.createdAt)).toLocaleString('zh-CN'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { MonitorRulesModal, ChannelEnrollmentPrompt } from './MonitorConfiguration';
|
||||
const api = vi.hoisted(() => ({
|
||||
rules: vi.fn(),
|
||||
effective: vi.fn(),
|
||||
saveRule: vi.fn(),
|
||||
targets: vi.fn(),
|
||||
target: vi.fn(),
|
||||
}));
|
||||
vi.mock('./monitorApi', async (importOriginal) => ({ ...(await importOriginal<object>()), monitorApi: api }));
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.rules.mockResolvedValue([]);
|
||||
api.effective.mockResolvedValue([]);
|
||||
});
|
||||
it('leaves default thresholds empty and preserves input on actual save failure', async () => {
|
||||
const user = userEvent.setup(),
|
||||
close = vi.fn();
|
||||
api.saveRule.mockRejectedValue(new Error('规则已被修改,请刷新后重试'));
|
||||
render(<MonitorRulesModal type="industry" onClose={close} />);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '保存规则' })).toBeEnabled());
|
||||
expect(screen.getByLabelText('5秒到达率下限(%)')).toHaveValue(null);
|
||||
expect(screen.getByLabelText('启用告警')).not.toBeChecked();
|
||||
await user.type(screen.getByLabelText('最低成熟样本量'), '100');
|
||||
await user.type(screen.getByLabelText('5秒到达率下限(%)'), '90');
|
||||
await user.click(screen.getByRole('button', { name: '保存规则' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('规则已被修改');
|
||||
expect(screen.getByLabelText('最低成熟样本量')).toHaveValue(100);
|
||||
expect(api.saveRule).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
version: 0,
|
||||
scope: {},
|
||||
config: expect.objectContaining({ enabled: false, minSamples: 100, thresholds: [90, null, null] }),
|
||||
}),
|
||||
);
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
it('retries enrollment against the same saved channel without creating another channel', async () => {
|
||||
const user = userEvent.setup(),
|
||||
close = vi.fn();
|
||||
const channel = { id: 'saved-channel', name: '已保存通道', version: 0, enabled: false };
|
||||
api.targets.mockResolvedValue([channel]);
|
||||
api.target.mockRejectedValueOnce(new Error('网络中断')).mockResolvedValueOnce({ success: true });
|
||||
render(<ChannelEnrollmentPrompt channel={channel} onClose={close} />);
|
||||
await user.click(screen.getByRole('button', { name: '加入监控' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('无需重新创建通道');
|
||||
await user.click(screen.getByRole('button', { name: '加入监控' }));
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
expect(api.target.mock.calls).toEqual([
|
||||
[channel, true],
|
||||
[channel, true],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,430 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import {
|
||||
monitorApi,
|
||||
names,
|
||||
ruleSource,
|
||||
time,
|
||||
type Config,
|
||||
type MonitorType,
|
||||
type Rule,
|
||||
type Scope,
|
||||
type Target,
|
||||
} from './monitorApi';
|
||||
|
||||
export function MonitorScopePicker({ scope, onChange }: { scope: Scope; onChange: (scope: Scope) => void }) {
|
||||
const [kind, setKind] = useState<'tenant' | 'application' | 'signature'>('tenant');
|
||||
const [keyword, setKeyword] = useState(''),
|
||||
[page, setPage] = useState(1);
|
||||
const [options, setOptions] = useState<{ id: string; name: string }[]>([]),
|
||||
[error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const timer = setTimeout(() => {
|
||||
if (kind !== 'tenant' && !scope.tenantId) {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
monitorApi
|
||||
.options(kind, scope, keyword, page)
|
||||
.then((items) => {
|
||||
if (live) {
|
||||
setOptions(items);
|
||||
setError('');
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(e.message);
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
live = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [kind, scope.tenantId, scope.applicationId, keyword, page]);
|
||||
return (
|
||||
<div className="sending-monitor__scope">
|
||||
<Select
|
||||
label="查找维度"
|
||||
value={kind}
|
||||
options={[
|
||||
{ value: 'tenant', label: '企业' },
|
||||
{ value: 'application', label: '应用' },
|
||||
{ value: 'signature', label: '签名' },
|
||||
]}
|
||||
onChange={(e) => {
|
||||
setKind(e.target.value as typeof kind);
|
||||
setPage(1);
|
||||
setKeyword('');
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
label="远程搜索"
|
||||
value={keyword}
|
||||
placeholder="输入名称查询"
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
label="搜索结果"
|
||||
value=""
|
||||
options={[{ label: '请选择', value: '' }, ...options.map((o) => ({ label: o.name, value: o.id }))]}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) return;
|
||||
const id = e.target.value;
|
||||
onChange(
|
||||
kind === 'tenant'
|
||||
? { tenantId: id }
|
||||
: kind === 'application'
|
||||
? { tenantId: scope.tenantId, applicationId: id }
|
||||
: { ...scope, signatureId: id },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="sending-monitor__actions">
|
||||
<Button size="sm" variant="secondary" disabled={page === 1} onClick={() => setPage(page - 1)}>
|
||||
上页
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" disabled={options.length < 20} onClick={() => setPage(page + 1)}>
|
||||
下页
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onChange({})}>
|
||||
清空维度
|
||||
</Button>
|
||||
</div>
|
||||
{Object.keys(scope).length > 0 && (
|
||||
<p className="muted">
|
||||
已选:{scope.tenantId} {scope.applicationId} {scope.signatureId}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="form-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitorRulesModal({ type, onClose }: { type: MonitorType; onClose: () => void }) {
|
||||
const dirtyRef = useRef(false);
|
||||
const [rules, setRules] = useState<Rule[]>([]),
|
||||
[error, setError] = useState(''),
|
||||
[busy, setBusy] = useState(false),
|
||||
[loaded, setLoaded] = useState(false);
|
||||
const [scope, setScope] = useState<Scope>({}),
|
||||
[custom, setCustom] = useState(false),
|
||||
[dirty, setDirty] = useState(false);
|
||||
const [version, setVersion] = useState(0),
|
||||
[matched, setMatched] = useState<Rule[]>([]);
|
||||
const [min, setMin] = useState(''),
|
||||
[thresholds, setThresholds] = useState(['', '', '']);
|
||||
const [enabled, setEnabled] = useState(false),
|
||||
[bad, setBad] = useState('1'),
|
||||
[good, setGood] = useState('2');
|
||||
useEffect(() => {
|
||||
monitorApi
|
||||
.rules()
|
||||
.then((r) => {
|
||||
setRules(r);
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch((e) => setError(e.message));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const current = rules.find(
|
||||
(r) =>
|
||||
r.type === type &&
|
||||
JSON.stringify(Object.entries(r.scope).sort()) === JSON.stringify(Object.entries(scope).sort()),
|
||||
);
|
||||
setVersion(current?.version ?? 0);
|
||||
setMin(current ? String(current.config.minSamples) : '');
|
||||
setThresholds(current?.config.thresholds.map((v) => (v === null ? '' : String(v))) ?? ['', '', '']);
|
||||
setEnabled(current?.config.enabled ?? false);
|
||||
setBad(String(current?.config.consecutiveBad ?? 1));
|
||||
setGood(String(current?.config.consecutiveGood ?? 2));
|
||||
setDirty(false);
|
||||
dirtyRef.current = false;
|
||||
let live = true;
|
||||
monitorApi
|
||||
.effective(type, scope)
|
||||
.then((r) => {
|
||||
if (live) {
|
||||
setMatched(r);
|
||||
const inherited = r[0]?.config;
|
||||
if ((!current || current.config.deleted) && inherited && !dirtyRef.current) {
|
||||
setMin(String(inherited.minSamples));
|
||||
setThresholds(inherited.thresholds.map((v) => (v === null ? '' : String(v))));
|
||||
setEnabled(inherited.enabled);
|
||||
setBad(String(inherited.consecutiveBad));
|
||||
setGood(String(inherited.consecutiveGood));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [rules, scope, type]);
|
||||
useEffect(() => {
|
||||
const before = (event: BeforeUnloadEvent) => {
|
||||
if (dirty) event.preventDefault();
|
||||
};
|
||||
window.addEventListener('beforeunload', before);
|
||||
return () => window.removeEventListener('beforeunload', before);
|
||||
}, [dirty]);
|
||||
const close = () => {
|
||||
if (!dirty || window.confirm('有未保存的规则,确认放弃修改?')) onClose();
|
||||
};
|
||||
const changeScope = (next: Scope, nextCustom = custom) => {
|
||||
if (dirty && !window.confirm('有未保存的规则,确认切换并放弃修改?')) return;
|
||||
setCustom(nextCustom);
|
||||
setScope(next);
|
||||
};
|
||||
async function save(deleted = false) {
|
||||
const config: Config = {
|
||||
enabled,
|
||||
minSamples: Number(min),
|
||||
thresholds: thresholds.map((v) => (v.trim() === '' ? null : Number(v))),
|
||||
consecutiveBad: Number(bad),
|
||||
consecutiveGood: Number(good),
|
||||
deleted,
|
||||
};
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await monitorApi.saveRule({ type, scope: custom ? scope : {}, config, version });
|
||||
setDirty(false);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '保存失败,请重试');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={`${names[type]} · 阈值设置`}
|
||||
size="xl"
|
||||
onClose={close}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={close}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || !loaded || (custom && !scope.applicationId && !scope.signatureId)}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
保存规则
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="sending-monitor">
|
||||
<p>规则下一周期生效。未配置时不告警;空白下限代表不启用该项,0%下限不会因低比率触发。</p>
|
||||
{type === 'overall' && (
|
||||
<>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={custom}
|
||||
onChange={(e) => {
|
||||
changeScope({}, e.target.checked);
|
||||
}}
|
||||
/>{' '}
|
||||
配置个性规则
|
||||
</label>
|
||||
{custom && <MonitorScopePicker scope={scope} onChange={changeScope} />}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p className="form-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!loaded && <p>正在加载规则…</p>}
|
||||
<div
|
||||
className="sending-monitor__form"
|
||||
onChange={() => {
|
||||
dirtyRef.current = true;
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} /> 启用告警
|
||||
</label>
|
||||
<Input label="最低成熟样本量" type="number" min="1" value={min} onChange={(e) => setMin(e.target.value)} />
|
||||
{(type === 'overall' ? ['1分钟', '5分钟', '20分钟'] : ['5秒', '20秒', '1分钟']).map((label, i) => (
|
||||
<Input
|
||||
key={label}
|
||||
label={`${label}到达率下限(%)`}
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.01"
|
||||
value={thresholds[i]}
|
||||
onChange={(e) => setThresholds((values) => values.map((v, index) => (index === i ? e.target.value : v)))}
|
||||
/>
|
||||
))}
|
||||
<Input
|
||||
label="连续异常次数"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
value={bad}
|
||||
onChange={(e) => setBad(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="连续恢复次数"
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
value={good}
|
||||
onChange={(e) => setGood(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<p>当前编辑版本:{version || '新规则'}。生效顺序:应用×签名 → 签名 → 应用 → 通用,整套覆盖。</p>
|
||||
<p>
|
||||
当前生效:
|
||||
{matched.length
|
||||
? matched.map((r) => `${ruleSource(r)} v${r.version}(${time(r.effectiveAt)})`).join(' → ')
|
||||
: '尚未配置'}
|
||||
</p>
|
||||
{custom && version > 0 && (
|
||||
<Button variant="ghost" disabled={busy} onClick={() => void save(true)}>
|
||||
恢复继承
|
||||
</Button>
|
||||
)}
|
||||
{type === 'overall' &&
|
||||
rules
|
||||
.filter((r) => r.type === type && Object.keys(r.scope).length && !r.config.deleted)
|
||||
.map((r) => (
|
||||
<div key={r.id} className="sending-monitor__rule">
|
||||
<span>
|
||||
{ruleSource(r)} · {Object.values(r.scope).join(' / ')} · v{r.version}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
changeScope(r.scope, true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitorTargetsModal({ onClose }: { onClose: () => void }) {
|
||||
const [targets, setTargets] = useState<Target[]>([]),
|
||||
[error, setError] = useState(''),
|
||||
[busy, setBusy] = useState(false),
|
||||
[keyword, setKeyword] = useState('');
|
||||
useEffect(() => {
|
||||
monitorApi
|
||||
.targets()
|
||||
.then(setTargets)
|
||||
.catch((e) => setError(e.message));
|
||||
}, []);
|
||||
async function toggle(t: Target) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await monitorApi.target(t, !t.enabled);
|
||||
setTargets(await monitorApi.targets());
|
||||
setError('');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '监控范围保存失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal open title="监控通道" size="xl" onClose={onClose} footer={<Button onClick={onClose}>关闭</Button>}>
|
||||
<div className="sending-monitor">
|
||||
<p>加入和移除仅影响行业质量监控,不改变通道路由或发送配置。下一周期生效。</p>
|
||||
<Input placeholder="搜索通道名称" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
|
||||
{error && (
|
||||
<p role="alert" className="form-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{targets
|
||||
.filter((t) => t.name.includes(keyword))
|
||||
.map((t) => (
|
||||
<div className="sending-monitor__rule" key={t.id}>
|
||||
<div>
|
||||
<strong>{t.name}</strong>
|
||||
<p>
|
||||
{t.status === 'active' ? '业务启用' : '业务停用'} · {time(t.effectiveFrom)}
|
||||
</p>
|
||||
</div>
|
||||
<Tag tone={t.enabled ? 'info' : 'neutral'}>{t.enabled ? '已纳管' : '未纳管'}</Tag>
|
||||
<Button disabled={busy} size="sm" variant="secondary" onClick={() => void toggle(t)}>
|
||||
{t.enabled ? '移除' : '加入'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelEnrollmentPrompt({
|
||||
channel,
|
||||
onClose,
|
||||
}: {
|
||||
channel: { id: string; name: string };
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [error, setError] = useState(''),
|
||||
[busy, setBusy] = useState(false);
|
||||
async function enroll() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const targets = await monitorApi.targets();
|
||||
const current = targets.find((t) => t.id === channel.id);
|
||||
if (!current) throw new Error('通道已保存,但当前没有监控管理权限或通道不可用');
|
||||
if (!current.enabled) await monitorApi.target(current, true);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '通道已保存,加入监控失败,可重试');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="通道已保存"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
暂不加入
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void enroll()}>
|
||||
加入监控
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p>{channel.name}已保存。是否加入行业通道监控?</p>
|
||||
<p>关闭后仍可在发送监控中加入。复制通道不会继承原通道的监控设置。</p>
|
||||
{error && (
|
||||
<p role="alert" className="form-error">
|
||||
{error},可重试加入,无需重新创建通道。
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import {
|
||||
monitorApi,
|
||||
names,
|
||||
ruleSource,
|
||||
states,
|
||||
time,
|
||||
title,
|
||||
type Alert,
|
||||
type Metric,
|
||||
type Page,
|
||||
type Snapshot,
|
||||
} from './monitorApi';
|
||||
|
||||
const TrendChart = lazy(() => import('@/components/ui/Chart').then((m) => ({ default: m.Chart })));
|
||||
const closeReasons: Record<string, string> = {
|
||||
recovered: '已连续恢复',
|
||||
data_corrected: '迟到数据修正',
|
||||
disabled: '规则已停用',
|
||||
rule_changed: '规则版本变化',
|
||||
enrollment_removed: '已移出监控',
|
||||
};
|
||||
export function Rate({ value }: { value: Metric }) {
|
||||
return (
|
||||
<div className={`sending-monitor__rate${value.bad ? ' sending-monitor__rate--bad' : ''}`}>
|
||||
<strong>{value.rate === null ? '—' : `${value.rate.toFixed(2)}%`}</strong>
|
||||
<span>
|
||||
{value.success.toLocaleString()} / {value.mature.toLocaleString()} 成熟
|
||||
</span>
|
||||
<small>
|
||||
{value.observing} 条观察中{value.insufficient ? ' · 样本不足' : ''}
|
||||
{value.bad ? ' · 低于下限' : ''}
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function MonitorHistory({ row, onClose, alert }: { row: Snapshot; onClose: () => void; alert?: Alert }) {
|
||||
const [range, setRange] = useState('2h'),
|
||||
[items, setItems] = useState<Snapshot[]>([]),
|
||||
[error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
monitorApi
|
||||
.history(row, range)
|
||||
.then((r) => {
|
||||
if (live) {
|
||||
setItems(r);
|
||||
setError('');
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (live) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [row, range]);
|
||||
const chartOption = useMemo<EChartsOption>(
|
||||
() => ({
|
||||
tooltip: { trigger: 'axis', confine: true },
|
||||
legend: { type: 'scroll', bottom: 0 },
|
||||
grid: { left: 45, right: 20, top: 20, bottom: 65 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: items.map((item) =>
|
||||
new Date(item.evaluationAt).toLocaleTimeString('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}),
|
||||
),
|
||||
},
|
||||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
series: row.metrics.metrics.flatMap((m, i) => [
|
||||
{
|
||||
name: `${m.seconds}秒到达率`,
|
||||
type: 'line' as const,
|
||||
connectNulls: false,
|
||||
data: items.map((item) => item.metrics.metrics[i]?.rate ?? null),
|
||||
showSymbol: false,
|
||||
},
|
||||
{
|
||||
name: `${m.seconds}秒下限`,
|
||||
type: 'line' as const,
|
||||
step: 'end' as const,
|
||||
connectNulls: false,
|
||||
data: items.map((item) => (item.rule?.config.enabled ? (item.metrics.metrics[i]?.threshold ?? null) : null)),
|
||||
showSymbol: false,
|
||||
lineStyle: { type: 'dashed' as const },
|
||||
},
|
||||
]),
|
||||
}),
|
||||
[items, row.metrics.metrics],
|
||||
);
|
||||
return (
|
||||
<Modal open title="发送质量趋势与详情" size="xl" onClose={onClose} footer={<Button onClick={onClose}>关闭</Button>}>
|
||||
<div className="sending-monitor">
|
||||
<h3>{title(row.dimensions)}</h3>
|
||||
{alert && (
|
||||
<div>
|
||||
<p>
|
||||
告警开始:{time(alert.openedAt)} · 最近评估:{time(alert.lastEvaluatedAt)} · {states[alert.state]}{' '}
|
||||
{closeReasons[alert.closeReason ?? ''] ?? ''}
|
||||
</p>
|
||||
<p>
|
||||
最差快照:{time(alert.worst.evaluationAt)} ·{' '}
|
||||
{alert.worst.metrics.metrics
|
||||
.map(
|
||||
(m) =>
|
||||
`${m.seconds}秒 ${m.rate === null ? '—' : m.rate.toFixed(2) + '%'}(${m.success}/${m.mature})`,
|
||||
)
|
||||
.join(';')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Link to={`/admin/sms-records?monitorSnapshotId=${encodeURIComponent(row.id)}`}>
|
||||
查看该窗口样本(沿用短信详情权限,样本保留72小时)
|
||||
</Link>
|
||||
<p>
|
||||
统计窗口:{time(row.windowFrom)} 至 {time(row.evaluationAt)}(不含结束时刻)
|
||||
</p>
|
||||
<p>
|
||||
观察截止:{time(row.observedUntil)} · {row.stage === 'final' ? '定稿' : '初评'} · 修订 {row.revision}
|
||||
</p>
|
||||
<p>
|
||||
{ruleSource(row.rule)}{' '}
|
||||
{row.rule
|
||||
? `v${row.rule.version},最低成熟 ${row.rule.config.minSamples} 条,下限 ${row.rule.config.thresholds.map((v) => (v === null ? '未启用' : `${v}%`)).join(' / ')}`
|
||||
: '等待配置阈值'}
|
||||
</p>
|
||||
<p>
|
||||
不可评估 {row.metrics.unassessable} 条。{row.completeness.reason}
|
||||
</p>
|
||||
<Select
|
||||
label="历史范围"
|
||||
value={range}
|
||||
options={[
|
||||
{ value: '2h', label: '最近2小时' },
|
||||
{ value: '24h', label: '最近24小时' },
|
||||
]}
|
||||
onChange={(e) => setRange(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="form-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<Suspense fallback={<p>正在加载趋势图…</p>}>
|
||||
<TrendChart option={chartOption} height={280} />
|
||||
</Suspense>
|
||||
)}
|
||||
<div className="sending-monitor__trend" aria-label="时效到达率历史趋势">
|
||||
{items.map((item) => (
|
||||
<div className="sending-monitor__trend-point" key={item.id}>
|
||||
<time>{time(item.evaluationAt)}</time>
|
||||
<span>
|
||||
{item.metrics.metrics
|
||||
.map(
|
||||
(m) =>
|
||||
`${m.seconds}秒:${m.rate === null ? '—' : `${m.rate.toFixed(2)}%`} (${m.success}/${m.mature})`,
|
||||
)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
<small>
|
||||
{states[item.status]} · {ruleSource(item.rule)} v{item.rule?.version ?? '—'} · revision {item.revision}
|
||||
</small>
|
||||
<div className="sending-monitor__bars">
|
||||
{item.metrics.metrics.map((m) => (
|
||||
<span
|
||||
key={m.seconds}
|
||||
title={`${m.seconds}秒 ${m.rate ?? '不可评估'}% / 下限${m.threshold ?? '未配置'}`}
|
||||
style={{ width: `${m.rate ?? 0}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!items.length && !error && <p>暂无该维度历史快照</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
export function MonitorPager({
|
||||
page,
|
||||
total,
|
||||
onChange,
|
||||
}: {
|
||||
page: number;
|
||||
total: number;
|
||||
onChange: (page: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="sending-monitor__actions">
|
||||
<span>
|
||||
共 {total} 条 · 第 {page} 页
|
||||
</span>
|
||||
<Button variant="secondary" disabled={page === 1} onClick={() => onChange(page - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={page * 20 >= total} onClick={() => onChange(page + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function MonitorAlerts() {
|
||||
const [data, setData] = useState<Page<Alert>>({ items: [], total: 0, page: 1, pageSize: 20 }),
|
||||
[page, setPage] = useState(1),
|
||||
[state, setState] = useState(''),
|
||||
[error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<Alert | null>(null),
|
||||
[busy, setBusy] = useState(false);
|
||||
const load = useCallback(
|
||||
() =>
|
||||
monitorApi
|
||||
.alerts(page, state)
|
||||
.then((r) => {
|
||||
setData(r);
|
||||
setError('');
|
||||
})
|
||||
.catch((e) => setError(e.message)),
|
||||
[page, state],
|
||||
);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = setInterval(() => {
|
||||
if (!document.hidden) void load();
|
||||
}, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
async function read(row: Alert) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await monitorApi.read(row.id);
|
||||
window.dispatchEvent(new Event('cmpp-monitor-alert-refresh'));
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '标记已读失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const columns: TableColumn<Alert>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: '异常对象',
|
||||
width: '300px',
|
||||
render: (a) => (
|
||||
<div className="ui-table__long-text">
|
||||
{title(a.dimensions)}
|
||||
<p>{names[a.type]}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
title: '状态',
|
||||
width: '120px',
|
||||
render: (a) => (
|
||||
<Tag tone={a.state === 'active' ? 'danger' : 'neutral'}>
|
||||
{states[a.state]}
|
||||
{a.unread ? ' · 未读' : ''}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
title: '开始 / 最近评估',
|
||||
width: '230px',
|
||||
render: (a) => (
|
||||
<>
|
||||
{time(a.openedAt)}
|
||||
<p>{time(a.lastEvaluatedAt)}</p>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'metrics',
|
||||
title: '命中指标 / 关闭原因',
|
||||
width: '280px',
|
||||
render: (a) => (
|
||||
<div className="ui-table__long-text">
|
||||
{a.latest.metrics.metrics
|
||||
.filter((m) => m.bad)
|
||||
.map((m) => `${m.seconds}秒 ${m.rate?.toFixed(2)}% < ${m.threshold}%`)
|
||||
.join(';') ||
|
||||
closeReasons[a.closeReason ?? ''] ||
|
||||
'暂停评估或等待恢复'}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '180px',
|
||||
render: (a) => (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => setDetail(a)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled={busy || !a.unread} onClick={() => void read(a)}>
|
||||
标记已读
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Select
|
||||
label="告警状态"
|
||||
value={state}
|
||||
options={[
|
||||
{ value: '', label: '全部' },
|
||||
...['active', 'recovered', 'closed'].map((value) => ({ value, label: states[value] })),
|
||||
]}
|
||||
onChange={(e) => {
|
||||
setState(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="form-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Table columns={columns} data={data.items} rowKey="id" />
|
||||
<MonitorPager page={page} total={data.total} onChange={setPage} />
|
||||
{detail && <MonitorHistory alert={detail} row={detail.latest} onClose={() => setDetail(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { request, withQuery } from '@/api/core/httpClient';
|
||||
export type MonitorType = 'industry' | 'verification' | 'overall';
|
||||
export type Scope = { tenantId?: string; applicationId?: string; signatureId?: string };
|
||||
export type Dimensions = Scope & {
|
||||
tenantName?: string;
|
||||
applicationName?: string;
|
||||
signatureName?: string;
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
carrier?: string;
|
||||
};
|
||||
export type Config = {
|
||||
enabled: boolean;
|
||||
minSamples: number;
|
||||
thresholds: (number | null)[];
|
||||
consecutiveBad: number;
|
||||
consecutiveGood: number;
|
||||
deleted?: boolean;
|
||||
};
|
||||
export type Rule = {
|
||||
id: string;
|
||||
ruleId?: string;
|
||||
type: MonitorType;
|
||||
scope: Scope;
|
||||
config: Config;
|
||||
version: number;
|
||||
effectiveAt: string;
|
||||
};
|
||||
export type Metric = {
|
||||
seconds: number;
|
||||
success: number;
|
||||
mature: number;
|
||||
observing: number;
|
||||
rate: number | null;
|
||||
threshold: number | null;
|
||||
insufficient: boolean;
|
||||
bad: boolean;
|
||||
};
|
||||
export type Snapshot = {
|
||||
id: string;
|
||||
type: MonitorType;
|
||||
dimensionKey: string;
|
||||
dimensions: Dimensions;
|
||||
evaluationAt: string;
|
||||
windowFrom: string;
|
||||
observedUntil: string;
|
||||
stage: string;
|
||||
revision: number;
|
||||
metrics: { total: number; unassessable: number; metrics: Metric[] };
|
||||
rule: Rule | null;
|
||||
status: string;
|
||||
completeness: { complete: boolean; reason?: string };
|
||||
computedAt: string;
|
||||
};
|
||||
export type Alert = {
|
||||
id: string;
|
||||
type: MonitorType;
|
||||
dimensionKey: string;
|
||||
dimensions: Dimensions;
|
||||
state: string;
|
||||
openedAt: string;
|
||||
lastEvaluatedAt: string;
|
||||
closeReason?: string;
|
||||
latest: Snapshot;
|
||||
worst: Snapshot;
|
||||
unread: boolean;
|
||||
};
|
||||
export type Page<T> = { items: T[]; total: number; page: number; pageSize: number };
|
||||
export type Target = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
version: number;
|
||||
status: string;
|
||||
effectiveFrom?: string;
|
||||
};
|
||||
export const names = { industry: '行业通道', verification: '验证码', overall: '整体兜底' };
|
||||
export const states: Record<string, string> = {
|
||||
abnormal: '异常',
|
||||
normal: '正常',
|
||||
sample_insufficient: '样本不足',
|
||||
stale: '数据延迟',
|
||||
unassessable: '不可评估',
|
||||
unconfigured: '未配置',
|
||||
disabled: '规则停用',
|
||||
no_data: '无发送',
|
||||
active: '活动',
|
||||
recovered: '已恢复',
|
||||
closed: '已关闭',
|
||||
};
|
||||
export const time = (value?: string) =>
|
||||
value ? new Date(value).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false }) : '—';
|
||||
export const title = (d: Dimensions) =>
|
||||
d.channelName
|
||||
? `${d.channelName} · ${{ mobile: '移动', unicom: '联通', telecom: '电信' }[d.carrier ?? ''] ?? '未知运营商'}`
|
||||
: `${d.tenantName ?? '未识别企业'} / ${d.applicationName ?? '未识别应用'} / ${d.signatureName ?? '未识别签名'}`;
|
||||
export const ruleSource = (r: Rule | null) =>
|
||||
!r
|
||||
? '尚未配置'
|
||||
: r.scope.signatureId && r.scope.applicationId
|
||||
? '应用×签名'
|
||||
: r.scope.signatureId
|
||||
? '签名规则'
|
||||
: r.scope.applicationId
|
||||
? '应用规则'
|
||||
: '通用规则';
|
||||
export const monitorApi = {
|
||||
rows: (q: Record<string, string | number | undefined>, signal?: AbortSignal) =>
|
||||
request<
|
||||
Page<Snapshot> & { health: { data: { complete: boolean; checkedAt: string; pendingReceipts: number } } | null }
|
||||
>(withQuery('/admin/sending-monitor/rows', q), { signal }),
|
||||
overview: (type: MonitorType) =>
|
||||
request<{
|
||||
rows: { status: string; dimensions: number; total: string; evaluationAt: string; computedAt: string }[];
|
||||
}>(withQuery('/admin/sending-monitor/overview', { type })),
|
||||
history: (row: Snapshot, range: string) =>
|
||||
request<Snapshot[]>(
|
||||
withQuery('/admin/sending-monitor/history', { type: row.type, dimensionId: row.dimensionKey, range }),
|
||||
),
|
||||
targets: () => request<Target[]>('/admin/sending-monitor/targets'),
|
||||
target: (t: Target, enabled: boolean) =>
|
||||
request(`/admin/sending-monitor/targets/${t.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ enabled, version: t.version }),
|
||||
}),
|
||||
rules: () => request<Rule[]>('/admin/sending-monitor/rules'),
|
||||
saveRule: (r: Pick<Rule, 'type' | 'scope' | 'config' | 'version'>) =>
|
||||
request('/admin/sending-monitor/rules', { method: 'POST', body: JSON.stringify(r) }),
|
||||
effective: (type: MonitorType, scope: Scope) =>
|
||||
request<Rule[]>(withQuery('/admin/sending-monitor/effective-rule', { type, ...scope })),
|
||||
options: (kind: string, scope: Scope, keyword: string, page: number) =>
|
||||
request<{ id: string; name: string }[]>(
|
||||
withQuery('/admin/sending-monitor/options', { kind, ...scope, keyword, page }),
|
||||
),
|
||||
alerts: (page: number, state: string, signal?: AbortSignal) =>
|
||||
request<Page<Alert>>(withQuery('/admin/sending-monitor/alerts', { page, state }), { signal }),
|
||||
read: (id: string) => request(`/admin/sending-monitor/alerts/${id}/read`, { method: 'POST' }),
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { ReportNotificationsPage } from './ReportNotificationsPage';
|
||||
const api = vi.hoisted(() => ({ request: vi.fn() }));
|
||||
vi.mock('@/api/core/httpClient', () => api);
|
||||
const hour = {
|
||||
id: 'hour-1',
|
||||
tenantName: '验收企业',
|
||||
hour: '2026-09-06T04:00:00Z',
|
||||
revision: 3,
|
||||
signatureCount: 2,
|
||||
drainageCount: 1,
|
||||
unread: true,
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
api.request.mockImplementation((path: string) =>
|
||||
path.includes('hour-1')
|
||||
? Promise.resolve({
|
||||
hour,
|
||||
items: [
|
||||
{
|
||||
id: 'event-1',
|
||||
reportType: 'signature',
|
||||
signatureName: '验收签名',
|
||||
targetName: '验收签名',
|
||||
createdAt: hour.hour,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
})
|
||||
: Promise.resolve({ items: [hour], total: 1, page: 1, pageSize: 20 }),
|
||||
);
|
||||
});
|
||||
it('uses the client API and explicitly marks the displayed revision read', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ReportNotificationsPage />);
|
||||
await user.click(await screen.findByRole('button', { name: '查看消息' }));
|
||||
await user.click(await screen.findByRole('button', { name: '标记已读' }));
|
||||
await waitFor(() =>
|
||||
expect(api.request).toHaveBeenCalledWith('/client/report-notifications/hour-1/read', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ revision: 3 }),
|
||||
}),
|
||||
);
|
||||
expect(api.request.mock.calls.some(([path]) => String(path).startsWith('/admin/'))).toBe(false);
|
||||
});
|
||||
it('reports a failed read without pretending success', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ReportNotificationsPage portal="admin" />);
|
||||
await user.click(await screen.findByRole('button', { name: '查看消息' }));
|
||||
api.request.mockRejectedValueOnce(new Error('标记失败'));
|
||||
await user.click(await screen.findByRole('button', { name: '标记已读' }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('标记失败');
|
||||
expect(screen.getByRole('button', { name: '标记已读' })).toBeEnabled();
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Breadcrumb, Button, Modal, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { request } from '@/api/core/httpClient';
|
||||
import './report-notifications.css';
|
||||
|
||||
type Hour = {
|
||||
id: string;
|
||||
tenantName: string;
|
||||
hour: string;
|
||||
revision: number;
|
||||
signatureCount: number;
|
||||
drainageCount: number;
|
||||
unread: boolean;
|
||||
};
|
||||
type Event = {
|
||||
id: string;
|
||||
reportType: string;
|
||||
applicationName?: string;
|
||||
signatureName: string;
|
||||
targetName: string;
|
||||
createdAt: string;
|
||||
};
|
||||
type Page<T> = { items: T[]; total: number; page: number; pageSize: number };
|
||||
const time = (value: string) => new Date(value).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false });
|
||||
|
||||
export function ReportNotificationsPage({ portal = 'client' }: { portal?: 'admin' | 'client' }) {
|
||||
const [data, setData] = useState<Page<Hour>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||
const [page, setPage] = useState(1);
|
||||
const [unread, setUnread] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [detail, setDetail] = useState<{ hour: Hour; items: Event[]; total: number; page: number } | null>(null);
|
||||
const [detailError, setDetailError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const base = `/${portal}/report-notifications`;
|
||||
const load = useCallback(
|
||||
async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await request<Page<Hour>>(`${base}?page=${page}&unread=${unread}`, { signal });
|
||||
setData(result);
|
||||
setError('');
|
||||
} catch (e) {
|
||||
if (!signal?.aborted) setError(e instanceof Error ? e.message : '通知加载失败');
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
},
|
||||
[base, page, unread],
|
||||
);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void load(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [load]);
|
||||
async function open(item: Hour, detailPage = 1) {
|
||||
setBusy(true);
|
||||
setDetailError('');
|
||||
try {
|
||||
const result = await request<{ hour: Hour } & Page<Event>>(`${base}/${item.id}?page=${detailPage}`);
|
||||
setDetail({ ...result, hour: { ...result.hour, unread: item.unread } });
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '详情加载失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function markRead() {
|
||||
if (!detail) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await request(`${base}/${detail.hour.id}/read`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ revision: detail.hour.revision }),
|
||||
});
|
||||
setDetail({ ...detail, hour: { ...detail.hour, unread: false } });
|
||||
window.dispatchEvent(new Event('cmpp-report-notification-refresh'));
|
||||
await load();
|
||||
} catch (e) {
|
||||
setDetailError(e instanceof Error ? e.message : '标记已读失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const columns: TableColumn<Hour>[] = [
|
||||
{
|
||||
key: 'read',
|
||||
title: '状态',
|
||||
width: '90px',
|
||||
render: (row) => <Tag tone={row.unread ? 'info' : 'neutral'}>{row.unread ? '未读' : '已读'}</Tag>,
|
||||
},
|
||||
{ key: 'tenant', title: '企业', width: '220px', render: (row) => row.tenantName },
|
||||
{ key: 'hour', title: '汇总时段(北京时间)', width: '220px', render: (row) => `${time(row.hour)} 起一小时` },
|
||||
{
|
||||
key: 'content',
|
||||
title: '报备状态变化消息',
|
||||
width: '300px',
|
||||
render: (row) => (
|
||||
<div className="ui-table__long-text">
|
||||
{row.signatureCount} 个签名、{row.drainageCount} 条引流信息已具备三网全国通道报备成功记录
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '110px',
|
||||
render: (row) => (
|
||||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void open(row)}>
|
||||
查看消息
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<section className="report-notifications-page page-stack">
|
||||
<div className="page-heading">
|
||||
<Breadcrumb items={[portal === 'admin' ? '报备状态变化消息' : '消息通知']} />
|
||||
<Button onClick={() => void load()} disabled={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
从三网均无全国通道报备成功,变为移动、联通、电信均有全国通道报备成功时通知。按企业与北京时间小时汇总。
|
||||
</p>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unread}
|
||||
onChange={(e) => {
|
||||
setUnread(e.target.checked);
|
||||
setPage(1);
|
||||
}}
|
||||
/>{' '}
|
||||
仅看未读
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="form-error">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{loading ? (
|
||||
<p role="status">正在加载通知…</p>
|
||||
) : (
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={data.items} rowKey="id" />
|
||||
{data.total === 0 && <p>暂无报备状态变化通知</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="report-notifications-page__pager">
|
||||
<span>
|
||||
共 {data.total} 条 · 第 {page} 页
|
||||
</span>
|
||||
<Button variant="secondary" disabled={loading || page === 1} onClick={() => setPage(page - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={loading || page * 20 >= data.total} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
{detail && (
|
||||
<Modal
|
||||
open
|
||||
title="报备状态变化消息"
|
||||
size="xl"
|
||||
onClose={() => setDetail(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDetail(null)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button disabled={busy || !detail.hour.unread} onClick={() => void markRead()}>
|
||||
标记已读
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="report-notifications-page">
|
||||
<p>
|
||||
{detail.hour.tenantName} · {time(detail.hour.hour)} · 版本 {detail.hour.revision}
|
||||
</p>
|
||||
{detailError && (
|
||||
<p role="alert" className="form-error">
|
||||
{detailError}
|
||||
</p>
|
||||
)}
|
||||
{detail.items.map((item) => (
|
||||
<article className="report-notifications-page__event" key={item.id}>
|
||||
<Tag tone="success">三网报备成功</Tag>
|
||||
<strong>
|
||||
{item.reportType === 'signature' ? '签名' : '引流信息'}:{item.targetName}
|
||||
</strong>
|
||||
<span>
|
||||
应用:{item.applicationName ?? '未关联应用'} · 签名:{item.signatureName}
|
||||
</span>
|
||||
<time>{time(item.createdAt)}</time>
|
||||
</article>
|
||||
))}
|
||||
<div className="report-notifications-page__pager">
|
||||
<span>共 {detail.total} 项</span>
|
||||
<Button disabled={busy || detail.page === 1} onClick={() => void open(detail.hour, detail.page - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || detail.page * 20 >= detail.total}
|
||||
onClick={() => void open(detail.hour, detail.page + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.report-notifications-page .report-notifications-page__pager {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.report-notifications-page .report-notifications-page__event {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -67,11 +67,19 @@ it('separates retirement counts from security/monitoring and leaves audit counts
|
||||
await user.click(screen.getByRole('button', { name: '预警通知' }));
|
||||
const alerts = screen.getByRole('menu', { name: '预警中心' });
|
||||
expect(within(alerts).queryByRole('menuitem', { name: /签名清退/ })).not.toBeInTheDocument();
|
||||
expect(within(alerts).getAllByRole('menuitem')).toHaveLength(2);
|
||||
expect(within(alerts).getAllByRole('menuitem')).toHaveLength(3);
|
||||
expect(within(alerts).getByRole('menuitem', { name: /发送质量告警/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/monitor?tab=alerts',
|
||||
);
|
||||
expect(within(alerts).getByText('1 条严重告警待处置')).toBeVisible();
|
||||
await user.click(screen.getByRole('button', { name: '报备任务提醒' }));
|
||||
expect(screen.queryByRole('menu', { name: '预警中心' })).not.toBeInTheDocument();
|
||||
const reporting = screen.getByRole('menu', { name: '报备任务提醒' });
|
||||
expect(within(reporting).getByRole('menuitem', { name: /报备状态变化通知/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/report-records?tab=readiness',
|
||||
);
|
||||
expect(within(reporting).getByRole('menuitem', { name: /签名清退预警/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/signature-retirement',
|
||||
|
||||
@@ -34,6 +34,7 @@ import { adminApi, type PendingAuditCounts } from '@/api/adminApi';
|
||||
import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/session';
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||
import { request } from '@/api/core/httpClient';
|
||||
|
||||
const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
||||
enterpriseCertifications: 0,
|
||||
@@ -54,6 +55,8 @@ export function AdminLayout() {
|
||||
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
|
||||
const [retirementUnreadCount, setRetirementUnreadCount] = useState(0);
|
||||
const [reportSummary, setReportSummary] = useState({ count: 0, unavailable: false });
|
||||
const [monitorSummary, setMonitorSummary] = useState({ count: 0, unavailable: false });
|
||||
const [securityAlertSummary, setSecurityAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
||||
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState({ count: 0, criticalCount: 0 });
|
||||
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
|
||||
@@ -72,8 +75,20 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
adminApi.getSignatureRetirementUnreadCount(),
|
||||
adminApi.getSecurityNotificationSummary(),
|
||||
adminApi.getInfrastructureMonitoringNotificationSummary(),
|
||||
request<{ count: number }>('/admin/report-notifications/summary'),
|
||||
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary'),
|
||||
])
|
||||
.then(([audits, retirement, security, infrastructure]) => {
|
||||
.then(([audits, retirement, security, infrastructure, reporting, monitor]) => {
|
||||
setMonitorSummary((previous) =>
|
||||
monitor.status === 'fulfilled'
|
||||
? { count: monitor.value.count, unavailable: Boolean(monitor.value.unavailable) }
|
||||
: { ...previous, unavailable: true },
|
||||
);
|
||||
setReportSummary((previous) =>
|
||||
reporting.status === 'fulfilled'
|
||||
? { count: reporting.value.count, unavailable: false }
|
||||
: { ...previous, unavailable: true },
|
||||
);
|
||||
setPendingAudits(audits.status === 'fulfilled' ? audits.value : EMPTY_PENDING_AUDITS);
|
||||
setRetirementUnreadCount(retirement.status === 'fulfilled' ? retirement.value.count : 0);
|
||||
setSecurityAlertSummary(security.status === 'fulfilled' ? security.value : { count: 0, criticalCount: 0 });
|
||||
@@ -100,6 +115,8 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-report-notification-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
||||
window.addEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
||||
return () => {
|
||||
@@ -107,6 +124,8 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-retirement-count-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-report-notification-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-monitor-alert-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-security-alert-count-refresh', onAuditRefresh);
|
||||
window.removeEventListener('cmpp-infrastructure-alert-count-refresh', onAuditRefresh);
|
||||
};
|
||||
@@ -123,6 +142,12 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
userRole="平台管理员"
|
||||
onSessionLockedChange={setSessionLocked}
|
||||
reportingNotifications={[
|
||||
{
|
||||
label: '报备状态变化通知',
|
||||
count: reportSummary.count,
|
||||
description: reportSummary.unavailable ? '消息计数暂不可用,请重试' : '按企业与小时汇总的未读消息',
|
||||
to: '/admin/report-records?tab=readiness',
|
||||
},
|
||||
{
|
||||
label: '签名清退预警',
|
||||
count: retirementUnreadCount,
|
||||
@@ -136,6 +161,12 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
},
|
||||
]}
|
||||
alertNotifications={[
|
||||
{
|
||||
label: '发送质量告警',
|
||||
count: monitorSummary.count,
|
||||
description: monitorSummary.unavailable ? '告警计数暂不可用,请重试' : '未读活动发送质量告警',
|
||||
to: '/admin/monitor?tab=alerts',
|
||||
},
|
||||
{
|
||||
label: '安全检测与封禁',
|
||||
count: securityAlertSummary.count,
|
||||
|
||||
@@ -86,6 +86,21 @@ export function AppShell({
|
||||
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const [openNotice, setOpenNotice] = useState<'alerts' | 'reporting' | 'audits' | null>(null);
|
||||
useEffect(() => {
|
||||
if (!openNotice) return;
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setOpenNotice(null);
|
||||
};
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (event.target instanceof Element && !event.target.closest('.notice-menu-wrap')) setOpenNotice(null);
|
||||
};
|
||||
window.addEventListener('keydown', key);
|
||||
document.addEventListener('pointerdown', outside);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', key);
|
||||
document.removeEventListener('pointerdown', outside);
|
||||
};
|
||||
}, [openNotice]);
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Home,
|
||||
MessageSquareText,
|
||||
PenLine,
|
||||
ReceiptText,
|
||||
Cable,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
@@ -14,7 +13,11 @@ import { AppShell } from '@/layouts/AppShell';
|
||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||
|
||||
export function ClientLayout() {
|
||||
return <PortalSessionBoundary portal="client">{(session) => <ClientAuthenticatedLayout session={session} />}</PortalSessionBoundary>;
|
||||
return (
|
||||
<PortalSessionBoundary portal="client">
|
||||
{(session) => <ClientAuthenticatedLayout session={session} />}
|
||||
</PortalSessionBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientAuthenticatedLayout({ session }: { session: import('@/api/session').LoginSession }) {
|
||||
@@ -30,7 +33,10 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session
|
||||
navSections={[
|
||||
{
|
||||
title: '概览',
|
||||
items: [{ label: '工作台', to: '/client', icon: Home }],
|
||||
items: [
|
||||
{ label: '工作台', to: '/client', icon: Home },
|
||||
{ label: '消息通知', to: '/client/notifications', icon: MessageSquareText },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '短信业务',
|
||||
@@ -52,9 +58,7 @@ function ClientAuthenticatedLayout({ session }: { session: import('@/api/session
|
||||
},
|
||||
{
|
||||
title: '账户',
|
||||
items: [
|
||||
{ label: '账户余额', to: '/client/billing', icon: BadgeDollarSign },
|
||||
],
|
||||
items: [{ label: '账户余额', to: '/client/billing', icon: BadgeDollarSign }],
|
||||
},
|
||||
{
|
||||
title: '系统管理',
|
||||
|
||||
@@ -71,6 +71,10 @@ const AdminGatewaySubmitExceptionsPage = lazyNamed(
|
||||
);
|
||||
const AdminHome = lazyNamed(() => import('@/apps/admin/AdminHome'), 'AdminHome');
|
||||
const AdminMonitorPage = lazyNamed(() => import('@/apps/admin/AdminMonitorPage'), 'AdminMonitorPage');
|
||||
const ReportNotificationsPage = lazyNamed(
|
||||
() => import('@/apps/report-notifications/ReportNotificationsPage'),
|
||||
'ReportNotificationsPage',
|
||||
);
|
||||
const AdminPhoneSegmentsPage = lazyNamed(() => import('@/apps/admin/AdminPhoneSegmentsPage'), 'AdminPhoneSegmentsPage');
|
||||
const AdminRechargeRecordsPage = lazyNamed(
|
||||
() => import('@/apps/admin/AdminRechargeRecordsPage'),
|
||||
@@ -173,6 +177,7 @@ export function AppRoutes() {
|
||||
<Route path="/client/login" element={<LoginPage portal="client" />} />
|
||||
<Route path="/admin/login" element={<LoginPage portal="admin" />} />
|
||||
<Route path="/client" element={<ClientLayout />}>
|
||||
<Route path="notifications" element={<ReportNotificationsPage />} />
|
||||
<Route index element={<ClientHome />} />
|
||||
<Route path="send" element={<ClientSendPage />} />
|
||||
<Route path="batch-tasks" element={<ClientBatchTasksPage />} />
|
||||
|
||||
Reference in New Issue
Block a user