feat: 实现发送质量监控与报备状态消息通知
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-06 19:22:49 +08:00
parent 69e3d7368d
commit 457319e627
66 changed files with 6992 additions and 489 deletions
+319 -57
View File
@@ -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({}),
}),
};
+85 -24
View File
@@ -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}
+123
View File
@@ -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;
}
}
+297 -71
View File
@@ -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>
);
}
+62 -4
View File
@@ -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);
+54 -23
View File
@@ -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 ? (
+143
View File
@@ -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;
}
+9 -1
View File
@@ -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',
+32 -1
View File
@@ -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,
+15
View File
@@ -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('');
+10 -6
View File
@@ -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: '系统管理',
+5
View File
@@ -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 />} />