feat: build reporting workbench workflow

This commit is contained in:
hectorzhao
2026-09-02 14:30:15 +08:00
parent 53d3668306
commit 9b8196ecab
31 changed files with 7651 additions and 1973 deletions
+353 -63
View File
@@ -1,5 +1,29 @@
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
import type { AdminChannel, ChannelConnectionLogResponse, ChannelGroup, ChannelGroupDeletionImpact, ChannelReportField, ChannelTestResponse, CmppConnectionState, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, DictionaryItem, PagedResult, ReportImportMapping, ReportImportProfile, ReportImportReviewBatch, ReportMaterialBatch, ReportMaterialBatchPreflight, ReportMaterialBatchResult, ReportMaterialPendingItem, ReportRecord, ReportTask } from '../types';
import type {
AdminChannel,
ChannelConnectionLogResponse,
ChannelGroup,
ChannelGroupDeletionImpact,
ChannelReportField,
ChannelTestResponse,
CmppConnectionState,
DeleteTargetRequest,
DeletionPreflight,
DeletionResult,
DeletionTargetType,
DictionaryItem,
PagedResult,
ReportImportMapping,
ReportImportProfile,
ReportImportReviewBatch,
ReportMaterialBatch,
ReportMaterialBatchPreflight,
ReportMaterialBatchResult,
ReportMaterialPendingItem,
ReportRecord,
ReportTask,
SingleReportMaterialDetail,
} from '../types';
import { assertUploadFileSize } from '@/utils/fileUpload';
// Report generation consumes channel report fields, so these endpoints keep one
@@ -8,88 +32,354 @@ export const adminChannelsReportsApi = {
listChannels: () => request<AdminChannel[]>('/admin/channels'),
listChannelsPage: (query: { keyword?: string; carrier?: string; status?: string; page: number; pageSize: number }) =>
request<PagedResult<AdminChannel>>(withQuery('/admin/channels', query)),
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) =>
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string; desiredConnections?: number; windowSize?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number }) =>
request<AdminChannel>(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
copyChannel: (id: string, body: { operatorId?: string } = {}) => request<AdminChannel>(`/admin/channels/${id}/copy`, {
method: 'POST',
body: JSON.stringify(body),
}),
testChannel: (id: string, body: { phoneNumber?: string; phones?: string[] | string; content: string; accessNo?: string }) =>
request<ChannelTestResponse>(`/admin/channels/${id}/test`, { method: 'POST', body: JSON.stringify(body) }),
changeChannelStatus: (id: string, status: string, reason?: string) => request<AdminChannel>(`/admin/channels/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status, reason }),
}),
deleteChannel: (id: string, reason?: string) => request<AdminChannel>(`/admin/channels/${id}`, {
method: 'DELETE',
body: JSON.stringify({ reason }),
}),
createChannel: (
body: Partial<AdminChannel> & {
passwordCipher?: string;
desiredConnections?: number;
windowSize?: number;
heartbeatIntervalSeconds?: number;
heartbeatMissThreshold?: number;
},
) => request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
updateChannel: (
id: string,
body: Partial<AdminChannel> & {
passwordCipher?: string;
desiredConnections?: number;
windowSize?: number;
heartbeatIntervalSeconds?: number;
heartbeatMissThreshold?: number;
},
) => request<AdminChannel>(`/admin/channels/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
copyChannel: (id: string, body: { operatorId?: string } = {}) =>
request<AdminChannel>(`/admin/channels/${id}/copy`, {
method: 'POST',
body: JSON.stringify(body),
}),
testChannel: (
id: string,
body: { phoneNumber?: string; phones?: string[] | string; content: string; accessNo?: string },
) => request<ChannelTestResponse>(`/admin/channels/${id}/test`, { method: 'POST', body: JSON.stringify(body) }),
changeChannelStatus: (id: string, status: string, reason?: string) =>
request<AdminChannel>(`/admin/channels/${id}/status`, {
method: 'POST',
body: JSON.stringify({ status, reason }),
}),
deleteChannel: (id: string, reason?: string) =>
request<AdminChannel>(`/admin/channels/${id}`, {
method: 'DELETE',
body: JSON.stringify({ reason }),
}),
getDeletionPreflight: (type: DeletionTargetType, id: string) =>
request<DeletionPreflight>(`/admin/deletions/${type}/${id}/preflight`),
deleteGovernedTarget: (type: DeletionTargetType, id: string, body: DeleteTargetRequest) =>
request<DeletionResult>(`/admin/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }),
listChannelConnectionLogs: (id: string) => request<ChannelConnectionLogResponse>(`/admin/channels/${id}/connection-logs`),
listChannelConnectionLogs: (id: string) =>
request<ChannelConnectionLogResponse>(`/admin/channels/${id}/connection-logs`),
listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'),
createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number }) =>
request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
updateChannelGroup: (id: string, body: { code?: string; name?: string; carrier?: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: number; retryTimeLimitMinutes?: number; items?: Array<Record<string, unknown>> }) =>
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
createChannelGroup: (body: {
code: string;
name: string;
carrier: 'mobile' | 'unicom' | 'telecom';
description?: string;
status?: string;
retryEnabled?: boolean;
retryTimeLimitHours?: number;
retryTimeLimitMinutes?: number;
}) => request<ChannelGroup>('/admin/channel-groups', { method: 'POST', body: JSON.stringify(body) }),
updateChannelGroup: (
id: string,
body: {
code?: string;
name?: string;
carrier?: 'mobile' | 'unicom' | 'telecom';
description?: string;
status?: string;
retryEnabled?: boolean;
retryTimeLimitHours?: number;
retryTimeLimitMinutes?: number;
items?: Array<Record<string, unknown>>;
},
) => request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
getChannelGroupDeletionImpact: (id: string) =>
request<ChannelGroupDeletionImpact>(`/admin/channel-groups/${id}/deletion-impact`),
deleteChannelGroup: (id: string) =>
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }),
deleteChannelGroup: (id: string) => request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'DELETE' }),
addChannelGroupItem: (body: Record<string, unknown>) =>
request<DictionaryItem>('/admin/channel-groups/items', { method: 'POST', body: JSON.stringify(body) }),
listChannelRouteRules: () => request<DictionaryItem[]>('/admin/channel-route-rules'),
createChannelRouteRule: (body: { tenantId?: string; applicationId: string; groupId: string; carrier: string; priority?: number; status?: string }) =>
request<DictionaryItem>('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }),
createChannelRouteRule: (body: {
tenantId?: string;
applicationId: string;
groupId: string;
carrier: string;
priority?: number;
status?: string;
}) => request<DictionaryItem>('/admin/channel-route-rules', { method: 'POST', body: JSON.stringify(body) }),
listChannelConnections: (id: string) => request<CmppConnectionState[]>(`/admin/channels/${id}/connections`),
replaceApplicationRouteRules: (applicationId: string, body: { routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }> }) =>
request<DictionaryItem[]>(`/admin/enterprise-applications/${applicationId}/route-rules`, { method: 'PUT', body: JSON.stringify(body) }),
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
replaceApplicationRouteRules: (
applicationId: string,
body: {
routes: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; groupId: string; priority?: number; status?: string }>;
},
) =>
request<DictionaryItem[]>(`/admin/enterprise-applications/${applicationId}/route-rules`, {
method: 'PUT',
body: JSON.stringify(body),
}),
listChannelReportFields: (channelId?: string) =>
request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
createChannelReportField: (body: Record<string, unknown>) =>
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
replaceChannelReportFields: (channelId: string, reportType: 'signature' | 'drainage', fields: Array<Record<string, unknown>>) =>
request<ChannelReportField[]>(`/admin/channels/${channelId}/report-fields/${reportType}`, { method: 'PUT', body: JSON.stringify({ fields }) }),
listPendingReportMaterials: (query: { reportType?: 'signature' | 'drainage'; tenantId?: string; applicationId?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<ReportMaterialPendingItem>>(withQuery('/admin/report-materials/pending', query)),
replaceChannelReportFields: (
channelId: string,
reportType: 'signature' | 'drainage',
fields: Array<Record<string, unknown>>,
) =>
request<ChannelReportField[]>(`/admin/channels/${channelId}/report-fields/${reportType}`, {
method: 'PUT',
body: JSON.stringify({ fields }),
}),
listPendingReportMaterials: (
query: {
reportType?: 'signature' | 'drainage';
tenantId?: string;
applicationId?: string;
keyword?: string;
startAt?: string;
endAt?: string;
page?: number;
pageSize?: number;
} = {},
) => request<PagedResult<ReportMaterialPendingItem>>(withQuery('/admin/report-materials/pending', query)),
listReportImportProfiles: (reportType?: 'signature' | 'drainage') =>
request<ReportImportProfile[]>(withQuery('/admin/report-materials/import-profiles', { reportType })),
saveReportImportProfile: (body: Omit<ReportImportProfile, 'id'> & { id?: string }) =>
request<ReportImportProfile>('/admin/report-materials/import-profiles', { method: 'POST', body: JSON.stringify(body) }),
analyzeReportMaterialImport: (file: File, body: { tenantId: string; applicationId?: string; reportType: 'signature' | 'drainage'; sheetName?: string; headerRowCount?: number; dataStartRow?: number; profileId?: string }) => {
request<ReportImportProfile>('/admin/report-materials/import-profiles', {
method: 'POST',
body: JSON.stringify(body),
}),
analyzeReportMaterialImport: (
file: File,
body: {
tenantId: string;
applicationId?: string;
reportType: 'signature' | 'drainage';
sheetName?: string;
headerRowCount?: number;
dataStartRow?: number;
profileId?: string;
},
) => {
assertUploadFileSize(file);
const form = new FormData();
form.set('file', file);
Object.entries(body).forEach(([key, value]) => { if (value !== undefined) form.set(key, String(value)); });
return requestForm<Record<string, unknown> & { id: string; columns: Array<{ sourceColumnIndex: number; columnLetter: string; sourceHeader: string; sourceHeaderPath: string; imageCount: number }>; rows: Array<Record<string, unknown>>; suggestedMappings: ReportImportMapping[] }>('/admin/report-materials/imports/analyze', form);
Object.entries(body).forEach(([key, value]) => {
if (value !== undefined) form.set(key, String(value));
});
return requestForm<
Record<string, unknown> & {
id: string;
columns: Array<{
sourceColumnIndex: number;
columnLetter: string;
sourceHeader: string;
sourceHeaderPath: string;
imageCount: number;
}>;
rows: Array<Record<string, unknown>>;
suggestedMappings: ReportImportMapping[];
}
>('/admin/report-materials/imports/analyze', form);
},
commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } }) =>
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }),
listReportImportReviewBatches: (query: { reportType?: 'signature' | 'drainage'; status?: string; keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) =>
commitReportMaterialImport: (
id: string,
body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } },
) =>
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, {
method: 'PUT',
body: JSON.stringify(body),
}),
listReportImportReviewBatches: (
query: {
reportType?: 'signature' | 'drainage';
status?: string;
keyword?: string;
startAt?: string;
endAt?: string;
page?: number;
pageSize?: number;
} = {},
) =>
request<PagedResult<ReportImportReviewBatch>>(withQuery('/admin/report-materials/imports/review-batches', query)),
reviewReportImportItems: (id: string, body: { decision: 'approve' | 'reject'; itemIds?: string[]; reason?: string }) =>
request<{ batchId: string; status: string; approvedCount: number; rejectedCount: number; failedCount: number }>(`/admin/report-materials/imports/${id}/review`, { method: 'POST', body: JSON.stringify(body) }),
listReportMaterialBatches: (query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<ReportMaterialBatch>>(withQuery('/admin/report-materials/batches', query)),
preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) =>
request<ReportMaterialBatchPreflight>('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }),
createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) =>
request<ReportMaterialBatchResult>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
listReportTasksPage: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage'; keyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
request<PagedResult<ReportTask>>(withQuery('/admin/report-tasks', query)),
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom'; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom'; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) =>
request<Array<{ signatureId: string; reportStatus: string; carrierReportSummary: Record<string, { status: string; approved: number; total: number }> }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }),
reviewReportImportItems: (
id: string,
body: { decision: 'approve' | 'reject'; itemIds?: string[]; reason?: string },
) =>
request<{ batchId: string; status: string; approvedCount: number; rejectedCount: number; failedCount: number }>(
`/admin/report-materials/imports/${id}/review`,
{ method: 'POST', body: JSON.stringify(body) },
),
listReportMaterialBatches: (
query: { keyword?: string; startAt?: string; endAt?: string; page?: number; pageSize?: number } = {},
) => request<PagedResult<ReportMaterialBatch>>(withQuery('/admin/report-materials/batches', query)),
getReportMaterialBatch: (id: string) => request<ReportMaterialBatch>(`/admin/report-materials/batches/${id}`),
listReportMaterialBatchTasks: (
id: string,
query: {
keyword?: string;
reportType?: 'signature' | 'drainage';
status?: string;
channelId?: string;
page?: number;
pageSize?: number;
} = {},
) => request<PagedResult<ReportTask>>(withQuery(`/admin/report-materials/batches/${id}/tasks`, query)),
preflightReportMaterialBatch: (body: {
items: Array<{
reportType: 'signature' | 'drainage';
signatureId: string;
drainageItemId?: string;
materialVersion?: number;
}>;
}) =>
request<ReportMaterialBatchPreflight>('/admin/report-materials/batches/preflight', {
method: 'POST',
body: JSON.stringify(body),
}),
createReportMaterialBatch: (body: {
idempotencyKey: string;
items: Array<{
reportType: 'signature' | 'drainage';
signatureId: string;
drainageItemId?: string;
materialVersion: number;
}>;
}) =>
request<ReportMaterialBatchResult>('/admin/report-materials/batches', {
method: 'POST',
body: JSON.stringify(body),
}),
listReportTasks: (
query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {},
) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
listReportTasksPage: (query: {
tenantId?: string;
applicationId?: string;
status?: string;
channelId?: string;
reportType?: 'signature' | 'drainage';
keyword?: string;
carrier?: string;
todaySendMin?: number;
todaySendMax?: number;
sort?: string;
createdAtFrom?: string;
createdAtTo?: string;
page: number;
pageSize: number;
}) => request<PagedResult<ReportTask>>(withQuery('/admin/report-tasks', query)),
listReportDetailsPage: (query: {
tenantId?: string;
applicationId?: string;
signatureId?: string;
channelId?: string;
carrier?: string;
status?: string;
reportType?: 'signature' | 'drainage';
keyword?: string;
createdAtFrom?: string;
createdAtTo?: string;
page: number;
pageSize: number;
}) => request<PagedResult<ReportTask>>(withQuery('/admin/report-details', query)),
getSingleReportMaterialDetail: (body: {
reportType?: 'signature' | 'drainage';
signatureId: string;
channelId: string;
carrier?: 'mobile' | 'unicom' | 'telecom';
drainageItemId?: string;
batchItemId?: string;
}) =>
request<SingleReportMaterialDetail>('/admin/report-materials/single-detail', {
method: 'POST',
body: JSON.stringify(body),
}),
exportSingleReportMaterial: (body: {
reportType?: 'signature' | 'drainage';
signatureId: string;
channelId: string;
carrier?: 'mobile' | 'unicom' | 'telecom';
drainageItemId?: string;
batchItemId?: string;
}) => requestBlob('/admin/report-materials/single-export', { method: 'POST', body: JSON.stringify(body) }),
createReportTask: (body: {
tenantId: string;
signatureId: string;
channelId: string;
carrier?: 'mobile' | 'unicom' | 'telecom';
reportType?: 'signature' | 'drainage';
drainageItemId?: string;
createdById?: string;
}) => request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
changeReportTaskStatuses: (body: {
items: Array<{
signatureId: string;
channelId: string;
carrier?: 'mobile' | 'unicom' | 'telecom';
status: string;
reportType?: 'signature' | 'drainage';
drainageItemId?: string;
}>;
reason?: string;
operatorId?: string;
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
}) =>
request<
Array<{
signatureId: string;
reportStatus: string;
carrierReportSummary: Record<string, { status: string; approved: number; total: number }>;
}>
>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }),
createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }),
importReportReceipt: (id: string, body: { fileObjectId?: string; fileName: string; fileContent?: string; delimiter?: ',' | '\t'; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record<string, unknown> }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/receipt-import`, { method: 'POST', body: JSON.stringify(body) }),
listReportRecords: (query: { taskId?: string; channelId?: string } = {}) => request<ReportRecord[]>(withQuery('/admin/report-records', query)),
listReportRecordsPage: (query: { taskId?: string; channelId?: string; keyword?: string; reportType?: 'signature' | 'drainage'; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) =>
request<PagedResult<ReportRecord>>(withQuery('/admin/report-records', query)),
request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, {
method: 'POST',
body: JSON.stringify(body),
}),
importReportReceipt: (
id: string,
body: {
fileObjectId?: string;
fileName: string;
fileContent?: string;
delimiter?: ',' | '\t';
rowCount?: number;
successCount?: number;
failedCount?: number;
statusAfter?: string;
reason?: string;
result?: Record<string, unknown>;
},
) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/receipt-import`, {
method: 'POST',
body: JSON.stringify(body),
}),
listReportRecords: (query: { taskId?: string; channelId?: string } = {}) =>
request<ReportRecord[]>(withQuery('/admin/report-records', query)),
listReportRecordsPage: (query: {
taskId?: string;
channelId?: string;
batchNo?: string;
statusAfter?: string;
action?: string;
sourceEntry?: string;
operatorKeyword?: string;
keyword?: string;
reportType?: 'signature' | 'drainage';
createdAtFrom?: string;
createdAtTo?: string;
page: number;
pageSize: number;
}) => request<PagedResult<ReportRecord>>(withQuery('/admin/report-records', query)),
};
+84 -5
View File
@@ -18,7 +18,15 @@ export type AdminChannel = {
rateLimitPerSecond: number;
unitPrice: number;
status: string;
config?: { desiredConnections?: number; windowSize?: number; extensionDigits?: number; heartbeatIntervalSeconds?: number; heartbeatMissThreshold?: number; longMessageReceiptMode?: 'per_segment' | 'message_level'; [key: string]: unknown } | null;
config?: {
desiredConnections?: number;
windowSize?: number;
extensionDigits?: number;
heartbeatIntervalSeconds?: number;
heartbeatMissThreshold?: number;
longMessageReceiptMode?: 'per_segment' | 'message_level';
[key: string]: unknown;
} | null;
connectionStates?: CmppConnectionState[];
};
@@ -118,6 +126,14 @@ export type ReportMaterialPendingItem = {
signatureName?: string;
tenant?: TenantOption;
application?: ClientSmsApplication | null;
statusSummary?: {
total: number;
pending: number;
reporting: number;
approved: number;
failed: number;
abandoned: number;
};
};
export type ReportMaterialBatch = {
@@ -132,7 +148,47 @@ export type ReportMaterialBatch = {
successRate: number;
createdAt: string;
completedAt?: string | null;
exportFiles: Array<{ id: string; fileObjectId?: string | null; fileName: string; rowCount: number; channelId?: string | null }>;
exportFiles: Array<{
id: string;
fileObjectId?: string | null;
fileName: string;
rowCount: number;
channelId?: string | null;
}>;
items?: Array<{
id: string;
reportType: 'signature' | 'drainage';
signatureId: string;
drainageItemId?: string | null;
materialVersion: number;
status: string;
errorMessage?: string | null;
}>;
};
export type SingleReportMaterialDetail = {
reportType: 'signature' | 'drainage';
signatureId: string;
signatureName: string;
tenant: { id: string; name: string };
application?: { id: string; name: string } | null;
channel: { id: string; name: string; code: string };
carrier?: 'mobile' | 'unicom' | 'telecom' | null;
materialVersion: number;
batchItemId?: string | null;
fields: Array<{
id: string;
code: string;
name: string;
exportName?: string | null;
fieldType: string;
required: boolean;
value: unknown;
submitted: boolean;
missing: boolean;
}>;
historicalFields: Array<{ code: string; name: string; value: unknown }>;
missingFields: string[];
};
export type ReportImportReviewItem = {
@@ -245,7 +301,16 @@ export type ApplicationReportField = {
description?: string | null;
reportTypes: string[];
commonReportTypes?: Array<'signature' | 'drainage'>;
channels: Array<{ id: string; code: string; name: string; groupId: string; groupName: string; required: boolean; reportType: 'signature' | 'drainage' | 'both'; source?: 'common' | 'channel' | 'both' }>;
channels: Array<{
id: string;
code: string;
name: string;
groupId: string;
groupName: string;
required: boolean;
reportType: 'signature' | 'drainage' | 'both';
source?: 'common' | 'channel' | 'both';
}>;
};
export type ClientApplicationReportField = Omit<ApplicationReportField, 'channels' | 'commonReportTypes'>;
@@ -259,6 +324,7 @@ export type CommonReportField = DictionaryItem & {
};
export type ReportTask = DictionaryItem & {
virtual?: boolean;
tenantId: string;
signatureId: string;
channelId: string;
@@ -277,14 +343,26 @@ export type ReportTask = DictionaryItem & {
application?: { id: string; name: string } | null;
};
drainageInfo?: SmsDrainageInfo | null;
channel?: { id: string; name: string; code: string; carrier?: string | null; carriers?: Array<'mobile' | 'unicom' | 'telecom'> };
channel?: {
id: string;
name: string;
code: string;
carrier?: string | null;
carriers?: Array<'mobile' | 'unicom' | 'telecom'>;
};
reason?: string | null;
createdAt?: string;
updatedAt?: string;
exportItems?: Array<{
id: string;
rowNumber: number;
exportFile: { id: string; fileObjectId?: string | null; fileName: string; rowCount: number; batchId?: string | null };
exportFile: {
id: string;
fileObjectId?: string | null;
fileName: string;
rowCount: number;
batchId?: string | null;
};
batchItem: { id: string; materialVersion: number; batch: { id: string; batchNo: string; createdAt: string } };
}>;
records?: Array<{
@@ -320,4 +398,5 @@ export type ReportRecord = DictionaryItem & {
sourceEntry?: 'system' | 'legacy' | 'enterprise_signature' | 'report_task' | 'channel_report';
channel?: AdminChannel;
task?: ReportTask;
operator?: { id: string; username: string; displayName: string };
};
+144 -28
View File
@@ -138,12 +138,29 @@ export type PendingAuditCounts = {
export type DashboardResponse = {
taskCount: number;
messageStatus: Array<{ status: string; _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } }>;
today: { sent: number; delivered: number; failed: number; unknown: number; successRate: number; spendCents: number; returnedCents: number; billingUnits: number };
messageStatus: Array<{
status: string;
_count: { _all: number };
_sum: { amountCents?: number | null; billingUnits?: number | null };
}>;
today: {
sent: number;
delivered: number;
failed: number;
unknown: number;
successRate: number;
spendCents: number;
returnedCents: number;
billingUnits: number;
};
uplinkCount: number;
billing: { _count: { _all: number }; _sum: { amountCents?: number | null; billingUnits?: number | null } };
transactions: { _count: { _all: number }; _sum: { amountCents?: number | null } };
gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>;
gatewayConnections: Array<{
status: string;
_count: { _all: number };
_sum: { currentConnections?: number | null; desiredConnections?: number | null };
}>;
pendingAuditCount: number;
pendingAudits: PendingAuditCounts;
hourlySendTrend: Array<{ hour: number; label: string; submittedCount: number; successCount: number }>;
@@ -157,8 +174,21 @@ export type DashboardResponse = {
recentFailed: number;
alertCount: number;
};
accounts: Array<{ id: string; tenantId: string; balanceCents: number; creditCents: number; status: string; tenant?: TenantOption }>;
enterpriseSpendRanks: Array<{ tenantId: string; tenantName: string; todaySpendCents: number; balanceCents: number; creditCents: number }>;
accounts: Array<{
id: string;
tenantId: string;
balanceCents: number;
creditCents: number;
status: string;
tenant?: TenantOption;
}>;
enterpriseSpendRanks: Array<{
tenantId: string;
tenantName: string;
todaySpendCents: number;
balanceCents: number;
creditCents: number;
}>;
recentTasks: Array<Record<string, unknown>>;
recentRecharges: Array<RechargeOrder>;
clientOverview?: {
@@ -239,34 +269,71 @@ export type ClientSmsSignature = {
tenant?: TenantOption;
application?: ClientSmsApplication | null;
reportStatus?: string;
materialVersion?: number;
pendingReport?: boolean;
reportChangedAt?: string;
reportMaterialChanged?: boolean;
reportPoolAvailableAfter?: 'immediate' | 'approval';
pendingReportDetailCount?: number;
pendingReportMaterialVersion?: number | null;
pendingReportBlockedReason?: string | null;
reportTasks?: Array<ReportTask & { channel?: AdminChannel }>;
reportTargets?: Array<{ channel: AdminChannel; channelId: string; carrier: 'mobile' | 'unicom' | 'telecom'; status: string; taskId?: string; approvedAt?: string | null; approvalScope?: 'carrier_specific' | 'legacy_channel' }>;
reportTargets?: Array<{
channel: AdminChannel;
channelId: string;
carrier: 'mobile' | 'unicom' | 'telecom';
status: string;
taskId?: string;
approvedAt?: string | null;
approvalScope?: 'carrier_specific' | 'legacy_channel';
}>;
carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>;
drainageReportTargets?: Record<string, Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>>;
drainageCarrierReportSummary?: Record<string, Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>>;
drainageReportTargets?: Record<
string,
Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>
>;
drainageCarrierReportSummary?: Record<
string,
Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>
>;
};
export type ClientSmsSignatureView = Pick<ClientSmsSignature,
'id' | 'tenantId' | 'applicationId' | 'name' | 'purpose' | 'auditStatus' | 'reportStatus' | 'rejectReason' | 'createdAt' | 'updatedAt' | 'materials' | 'carrierReportSummary' | 'drainageCarrierReportSummary'
export type ClientSmsSignatureView = Pick<
ClientSmsSignature,
| 'id'
| 'tenantId'
| 'applicationId'
| 'name'
| 'purpose'
| 'auditStatus'
| 'reportStatus'
| 'rejectReason'
| 'createdAt'
| 'updatedAt'
| 'materials'
| 'carrierReportSummary'
| 'drainageCarrierReportSummary'
> & {
pendingReport?: boolean;
reportChangedAt?: string;
application?: Pick<ClientSmsApplication, 'id' | 'name' | 'status'> | null;
submittedMaterialCount: number;
reportValues: Record<string, unknown>;
drainageInfo: { links: Array<{
id: string;
siteName: string;
url: string;
remark?: string | null;
reportValues: Record<string, unknown>;
auditStatus: string;
rejectReason?: string | null;
submittedAt: string;
reviewedAt?: string | null;
createdAt: string;
updatedAt: string;
}> };
drainageInfo: {
links: Array<{
id: string;
siteName: string;
url: string;
remark?: string | null;
reportValues: Record<string, unknown>;
auditStatus: string;
rejectReason?: string | null;
submittedAt: string;
reviewedAt?: string | null;
createdAt: string;
updatedAt: string;
}>;
};
};
export type ClientSignatureWorkspace = {
@@ -394,15 +461,64 @@ export type HttpApiConfig = {
allowClientTest: boolean;
};
export type HttpApiConfigResponse = { applicationId: string; applicationName?: string; publicOrigin?: string; config: HttpApiConfig | null; ipAllowlist: string[] };
export type HttpApiConfigResponse = {
applicationId: string;
applicationName?: string;
publicOrigin?: string;
config: HttpApiConfig | null;
ipAllowlist: string[];
};
export type HttpApiCredential = { id: string; name: string; accessKey: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; expiresAt?: string | null; lastUsedAt?: string | null; lastUsedIp?: string | null; createdAt: string };
export type HttpApiCredential = {
id: string;
name: string;
accessKey: string;
secretLast4: string;
secret?: string;
secretShownOnce?: boolean;
status: string;
expiresAt?: string | null;
lastUsedAt?: string | null;
lastUsedIp?: string | null;
createdAt: string;
};
export type HttpWebhookEndpoint = { id: string; eventType: 'receipt' | 'uplink'; url: string; secretLast4: string; secret?: string; secretShownOnce?: boolean; status: string; lastTestAt?: string | null; lastTestStatus?: string | null; updatedAt: string };
export type HttpWebhookEndpoint = {
id: string;
eventType: 'receipt' | 'uplink';
url: string;
secretLast4: string;
secret?: string;
secretShownOnce?: boolean;
status: string;
lastTestAt?: string | null;
lastTestStatus?: string | null;
updatedAt: string;
};
export type HttpApiRequestLog = { id: string; requestId: string; clientMessageId?: string | null; sourceIp?: string | null; httpStatus?: number | null; businessCode?: string | null; status: string; durationMs?: number | null; createdAt: string; completedAt?: string | null };
export type HttpApiRequestLog = {
id: string;
requestId: string;
clientMessageId?: string | null;
sourceIp?: string | null;
httpStatus?: number | null;
businessCode?: string | null;
status: string;
durationMs?: number | null;
createdAt: string;
completedAt?: string | null;
};
export type HttpWebhookDelivery = { id: string; status: string; attemptCount: number; lastHttpStatus?: number | null; lastError?: string | null; createdAt: string; event: { eventId: string; eventType: string; messageId?: string | null }; endpoint: { eventType: string; url: string } };
export type HttpWebhookDelivery = {
id: string;
status: string;
attemptCount: number;
lastHttpStatus?: number | null;
lastError?: string | null;
createdAt: string;
event: { eventId: string; eventType: string; messageId?: string | null };
endpoint: { eventType: string; url: string };
};
export type EnterpriseApplication = {
id: string;
+497 -72
View File
@@ -1,14 +1,29 @@
import { useEffect, useMemo, useState } from 'react';
import { ArrowLeft, Eye, FileSliders, Search } from 'lucide-react';
import { ArrowLeft, Download, Eye, FileSliders, Search } from 'lucide-react';
import { useNavigate, useParams } from 'react-router-dom';
import { adminApi, type AdminChannel, type ChannelReportField, type ClientSmsSignature, type DictionaryItem, type ReportRecord, type ReportTask } from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
import {
adminApi,
type AdminChannel,
type ChannelReportField,
type ClientSmsSignature,
type DictionaryItem,
type ReportRecord,
type ReportTask,
type SingleReportMaterialDetail,
} from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
import { successRateClassName } from '@/utils/successRate';
import { ReportFieldMappingModal } from './ReportFieldMappingModal';
type ReportType = 'signature' | 'drainage';
type DrainageItem = Record<string, unknown> & { id?: string; url?: string; siteName?: string; submittedAt?: string; remark?: string };
type DrainageItem = Record<string, unknown> & {
id?: string;
url?: string;
siteName?: string;
submittedAt?: string;
remark?: string;
};
const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | 'warning' | 'neutral' }> = {
approved: { label: '报备成功', tone: 'success' },
@@ -25,21 +40,29 @@ const statusMeta: Record<string, { label: string; tone: 'success' | 'danger' | '
};
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
function formatSignatureName(value?: string | null) {
const name = String(value ?? '-').trim().replace(/^[【\[]+|[】\]]+$/g, '');
const name = String(value ?? '-')
.trim()
.replace(/^[【[]+|[】\]]+$/g, '');
return `${name || '-'}`;
}
function drainageItems(signature?: ClientSmsSignature) {
const payload = asRecord(signature?.drainageInfo);
return Array.isArray(payload.links) ? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object') : [];
return Array.isArray(payload.links)
? payload.links.filter((item): item is DrainageItem => Boolean(item) && typeof item === 'object')
: [];
}
function DateTime({ value }: { value?: unknown }) {
return value ? <span className="channel-report-date">{formatDateTime(String(value))}</span> : <span className="muted">-</span>;
return value ? (
<span className="channel-report-date">{formatDateTime(String(value))}</span>
) : (
<span className="muted">-</span>
);
}
function ReportStatus({ value }: { value?: string }) {
@@ -58,31 +81,118 @@ function DeliveryStats({ task }: { task: ReportTask }) {
failureCount: 0,
failureRate: 0,
};
return <div className="channel-report-stats">
<span><strong className={successRateClassName(stats.successRate)}>{stats.successRate}%</strong><b>{stats.successCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.unknownRate}%</strong><b>{stats.unknownCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.failureRate}%</strong><b>{stats.failureCount.toLocaleString('zh-CN')}</b></span>
<span><strong>{stats.submitFailureRate}%</strong><b>{stats.submitFailureCount.toLocaleString('zh-CN')}</b></span>
</div>;
return (
<div className="channel-report-stats">
<span>
<strong className={successRateClassName(stats.successRate)}>{stats.successRate}%</strong>
<b>{stats.successCount.toLocaleString('zh-CN')}</b>
</span>
<span>
<strong>{stats.unknownRate}%</strong>
<b>{stats.unknownCount.toLocaleString('zh-CN')}</b>
</span>
<span>
<strong>{stats.failureRate}%</strong>
<b>{stats.failureCount.toLocaleString('zh-CN')}</b>
</span>
<span>
<strong>{stats.submitFailureRate}%</strong>
<b>{stats.submitFailureCount.toLocaleString('zh-CN')}</b>
</span>
</div>
);
}
function DetailModal({ drainage, reportedAt, signature, task, onClose }: { drainage?: DrainageItem; reportedAt?: string | null; signature?: ClientSmsSignature; task: ReportTask; onClose: () => void }) {
function DetailModal({
drainage,
reportedAt,
signature,
task,
onClose,
}: {
drainage?: DrainageItem;
reportedAt?: string | null;
signature?: ClientSmsSignature;
task: ReportTask;
onClose: () => void;
}) {
const payload = asRecord(signature?.drainageInfo);
const profile = asRecord(payload.signatureProfile);
const reportValues = asRecord(drainage ? drainage.reportValues : payload.signatureReportValues);
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={drainage ? '查看引流信息详情' : '查看签名详情'}>
<Modal
footer={<Button onClick={onClose}></Button>}
onClose={onClose}
open
size="xl"
title={drainage ? '查看引流信息详情' : '查看签名详情'}
>
<div className="channel-report-detail">
<strong>{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong>
<p><span></span><span>{signature?.tenant?.name ?? task.tenantId}</span></p>
<p><span></span><span>{signature?.application?.name ?? '-'}</span></p>
<p><span></span><DateTime value={drainage?.submittedAt ?? task.createdAt} /></p>
<p><span></span><DateTime value={reportedAt} /></p>
<p><span></span><DateTime value={task.lastSuccessfulSentAt} /></p>
{!drainage ? <><p><span></span><span>{String(profile.basis ?? '-')}</span></p><p><span></span><span>{String(profile.companyName ?? '-')}</span></p><p><span></span><span>{String(profile.creditCode ?? '-')}</span></p></> : null}
{drainage ? <><p><span> URL </span><span>{String(drainage.url ?? '-')}</span></p><p><span></span><span>{String(drainage.remark ?? '-')}</span></p></> : null}
{Object.entries(reportValues).map(([key, value]) => <p key={key}><span>{key}</span><span>{typeof value === 'object' ? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-') : String(value ?? '-')}</span></p>)}
<section><h3></h3><DeliveryStats task={task} /></section>
<strong>
{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}
</strong>
<p>
<span></span>
<span>{signature?.tenant?.name ?? task.tenantId}</span>
</p>
<p>
<span></span>
<span>{signature?.application?.name ?? '-'}</span>
</p>
<p>
<span></span>
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
</p>
<p>
<span></span>
<DateTime value={reportedAt} />
</p>
<p>
<span></span>
<DateTime value={task.lastSuccessfulSentAt} />
</p>
{!drainage ? (
<>
<p>
<span></span>
<span>{String(profile.basis ?? '-')}</span>
</p>
<p>
<span></span>
<span>{String(profile.companyName ?? '-')}</span>
</p>
<p>
<span></span>
<span>{String(profile.creditCode ?? '-')}</span>
</p>
</>
) : null}
{drainage ? (
<>
<p>
<span> URL </span>
<span>{String(drainage.url ?? '-')}</span>
</p>
<p>
<span></span>
<span>{String(drainage.remark ?? '-')}</span>
</p>
</>
) : null}
{Object.entries(reportValues).map(([key, value]) => (
<p key={key}>
<span>{key}</span>
<span>
{typeof value === 'object'
? String(asRecord(value).fileName ?? asRecord(value).fileObjectId ?? '-')
: String(value ?? '-')}
</span>
</p>
))}
<section>
<h3></h3>
<DeliveryStats task={task} />
</section>
</div>
</Modal>
);
@@ -99,7 +209,19 @@ export function AdminChannelReportPage() {
const [libraryFields, setLibraryFields] = useState<DictionaryItem[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('all');
const [detail, setDetail] = useState<{ task: ReportTask; reportedAt?: string | null; signature?: ClientSmsSignature; drainage?: DrainageItem }>();
const [carrier, setCarrier] = useState('all');
const [todaySendMin, setTodaySendMin] = useState('');
const [todaySendMax, setTodaySendMax] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 10;
const [material, setMaterial] = useState<SingleReportMaterialDetail>();
const [detail, setDetail] = useState<{
task: ReportTask;
reportedAt?: string | null;
signature?: ClientSmsSignature;
drainage?: DrainageItem;
}>();
const [statusTask, setStatusTask] = useState<ReportTask>();
const [nextStatus, setNextStatus] = useState('approved');
const [statusReason, setStatusReason] = useState('');
@@ -109,31 +231,77 @@ export function AdminChannelReportPage() {
function loadData() {
Promise.all([
adminApi.listChannels(),
adminApi.listReportTasks({ channelId }),
adminApi.listReportTasksPage({
channelId,
keyword: keyword.trim() || undefined,
status: status === 'all' ? undefined : status,
carrier: carrier === 'all' ? undefined : carrier,
todaySendMin: todaySendMin ? Number(todaySendMin) : undefined,
todaySendMax: todaySendMax ? Number(todaySendMax) : undefined,
sort: 'todaySendDesc',
page,
pageSize,
}),
adminApi.listReportRecords({ channelId }),
adminApi.listEnterpriseSignatures(),
adminApi.listChannelReportFields(channelId),
adminApi.listDrainageFields(),
]).then(([channelItems, taskItems, recordItems, signatureItems, fieldItems, libraryItems]) => {
setChannel(channelItems.find((item) => item.id === channelId));
setTasks(taskItems);
setRecords(recordItems);
setSignatures(signatureItems);
setFields(fieldItems);
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
setError('');
}).catch((failure: Error) => setError(failure.message || '通道报备详情加载失败'));
])
.then(([channelItems, taskPage, recordItems, signatureItems, fieldItems, libraryItems]) => {
setChannel(channelItems.find((item) => item.id === channelId));
setTasks(taskPage.items);
setTotal(taskPage.total);
setRecords(recordItems);
setSignatures(signatureItems);
setFields(fieldItems);
setLibraryFields(libraryItems.filter((item) => item.status === 'active'));
setError('');
})
.catch((failure: Error) => setError(failure.message || '通道报备详情加载失败'));
}
useEffect(loadData, [channelId]);
useEffect(loadData, [channelId, page]);
const signatureMap = useMemo(() => new Map(signatures.map((item) => [item.id, item])), [signatures]);
const visibleTasks = useMemo(() => tasks.filter((task) => {
const signature = signatureMap.get(task.signatureId);
const drainage = drainageItems(signature).find((item) => String(item.id) === task.drainageItemId);
const matchesKeyword = !keyword.trim() || [signature?.name, signature?.tenant?.name, signature?.application?.name, drainage?.siteName, drainage?.url].some((value) => String(value ?? '').includes(keyword.trim()));
return matchesKeyword && (status === 'all' || task.status === status);
}), [keyword, signatureMap, status, tasks]);
const visibleTasks = tasks;
async function openMaterial(task: ReportTask) {
try {
setMaterial(
await adminApi.getSingleReportMaterialDetail({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
}),
);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备资料加载失败');
}
}
async function exportMaterial(task: ReportTask) {
try {
const blob = await adminApi.exportSingleReportMaterial({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
}
}
function approvedRecord(taskId: string) {
return records.find((record) => record.taskId === taskId && record.statusAfter === 'approved');
@@ -147,8 +315,26 @@ export function AdminChannelReportPage() {
function saveTaskStatus() {
if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, carrier: statusTask.carrier ?? undefined, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'channel_report' })
.then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); })
adminApi
.changeReportTaskStatuses({
items: [
{
signatureId: statusTask.signatureId,
channelId: statusTask.channelId,
carrier: statusTask.carrier ?? undefined,
reportType: statusTask.reportType,
drainageItemId: statusTask.drainageItemId ?? undefined,
status: nextStatus,
},
],
reason: statusReason,
sourceEntry: 'channel_report',
})
.then(() => {
setStatusTask(undefined);
setStatusReason('');
loadData();
})
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
}
@@ -157,48 +343,287 @@ export function AdminChannelReportPage() {
<div className="surface channel-report-hero">
<Breadcrumb items={['通道管理', '短信通道', '报备详情']} />
<div className="channel-report-heading">
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost"></Button>
<Button icon={<ArrowLeft size={16} />} onClick={() => navigate('/admin/channels')} variant="ghost">
</Button>
<h1>{channel?.name ?? '通道报备详情'}</h1>
<div className="channel-report-config-actions">
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('signature')} variant="ghost"></Button>
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('drainage')} variant="ghost"></Button>
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('signature')} variant="ghost">
</Button>
<Button icon={<FileSliders size={16} />} onClick={() => setConfigType('drainage')} variant="ghost">
</Button>
</div>
</div>
<div className="muted">{channel?.code ?? channelId} · {fields.length} </div>
<div className="muted">
{channel?.code ?? channelId} · {fields.length}
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface channel-report-filter">
<div className="channel-report-filter-grid">
<Input label="签名/企业/应用" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键词" prefix={<Search size={16} />} value={keyword} />
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, ...Object.entries(statusMeta).filter(([value]) => ['approved', 'failed', 'pending', 'waiting_material', 'exporting', 'partial_success'].includes(value)).map(([value, meta]) => ({ label: meta.label, value }))]} value={status} />
<div><strong></strong><p className="muted"></p></div>
<Input
label="签名/企业/应用"
onChange={(event) => setKeyword(event.target.value)}
placeholder="请输入关键词"
prefix={<Search size={16} />}
value={keyword}
/>
<Select
label="报备状态"
onChange={(event) => setStatus(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
...Object.entries(statusMeta)
.filter(([value]) =>
['approved', 'failed', 'pending', 'waiting_material', 'exporting', 'partial_success'].includes(value),
)
.map(([value, meta]) => ({ label: meta.label, value })),
]}
value={status}
/>
<Select
label="运营商"
onChange={(event) => setCarrier(event.target.value)}
options={[
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
]}
value={carrier}
/>
<Input
label="今日发送最小条数"
min="0"
onChange={(event) => setTodaySendMin(event.target.value)}
type="number"
value={todaySendMin}
/>
<Input
label="今日发送最大条数"
min="0"
onChange={(event) => setTodaySendMax(event.target.value)}
type="number"
value={todaySendMax}
/>
</div>
<div className="channel-report-filter-footer">
<span> {total} </span>
<div>
<Button
onClick={() => {
setKeyword('');
setStatus('all');
setCarrier('all');
setTodaySendMin('');
setTodaySendMax('');
}}
variant="ghost"
>
</Button>
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else loadData();
}}
>
</Button>
</div>
</div>
<div className="channel-report-filter-footer"><span> {visibleTasks.length} </span><div><Button onClick={() => { setKeyword(''); setStatus('all'); }} variant="ghost"></Button><Button icon={<Search size={16} />} onClick={loadData}></Button></div></div>
</div>
<div className="surface channel-report-table">
<div className="channel-report-table__head"><span /><span> / </span><span></span><span></span><span></span><span></span><span></span><span></span></div>
{visibleTasks.length === 0 ? <div className="channel-report-empty"></div> : visibleTasks.map((task) => {
const signature = signatureMap.get(task.signatureId);
const drainage = task.reportType === 'drainage' ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) : undefined;
const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt;
return <div className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`} key={task.id}>
<span />
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>{drainage ? <i /> : null}<span><strong>{drainage ? String(drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}</strong><small>{drainage ? formatSignatureName(signature?.name ?? task.signature?.name) : <>{signature?.tenant?.name ?? task.tenantId} · {task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}</>}</small></span></div>
<ReportStatus value={task.status} />
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
<DateTime value={reportedAt} />
<DateTime value={task.lastSuccessfulSentAt} />
<DeliveryStats task={task} />
<div className="channel-report-actions"><button onClick={() => setDetail({ task, reportedAt, signature, drainage })} type="button"><Eye size={16} /></button><button className="is-warning" onClick={() => { setStatusTask(task); setNextStatus(task.status); }} type="button"></button></div>
</div>;
})}
<div className="channel-report-table__head">
<span />
<span> / </span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleTasks.length === 0 ? (
<div className="channel-report-empty"></div>
) : (
visibleTasks.map((task) => {
const signature = signatureMap.get(task.signatureId);
const drainage =
task.reportType === 'drainage'
? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId)
: undefined;
const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt;
return (
<div
className={`channel-report-row ${drainage ? 'channel-report-row--drainage' : 'channel-report-row--signature'}`}
key={task.id}
>
<span />
<div className={`channel-report-name ${drainage ? 'channel-report-name--flow' : ''}`}>
{drainage ? <i /> : null}
<span>
<strong>
{drainage
? String(drainage.url || '引流信息')
: formatSignatureName(signature?.name ?? task.signature?.name)}
</strong>
<small>
{drainage ? (
formatSignatureName(signature?.name ?? task.signature?.name)
) : (
<>
{signature?.tenant?.name ?? task.tenantId} ·{' '}
{task.carrier ? <CarrierTag carrier={task.carrier} /> : '历史通道级(未拆分)'}
</>
)}
</small>
</span>
</div>
<ReportStatus value={task.status} />
<DateTime value={drainage?.submittedAt ?? task.createdAt} />
<DateTime value={reportedAt} />
<DateTime value={task.lastSuccessfulSentAt} />
<DeliveryStats task={task} />
<div className="channel-report-actions">
<button onClick={() => void openMaterial(task)} type="button">
<Eye size={16} />
</button>
{task.reportType !== 'drainage' ? (
<button onClick={() => void exportMaterial(task)} type="button">
<Download size={16} />
</button>
) : null}
<button
className="is-warning"
onClick={() => {
setStatusTask(task);
setNextStatus(task.status);
}}
type="button"
>
</button>
</div>
</div>
);
})
)}
</div>
<Pagination
nextDisabled={page * pageSize >= total}
onNext={() => setPage((value) => value + 1)}
onPageChange={setPage}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={page}
previousDisabled={page <= 1}
total={total}
totalPages={Math.max(1, Math.ceil(total / pageSize))}
/>
{detail ? <DetailModal {...detail} onClose={() => setDetail(undefined)} /> : null}
<Modal footer={<><Button onClick={() => setStatusTask(undefined)} variant="ghost"></Button><Button onClick={saveTaskStatus}></Button></>} onClose={() => setStatusTask(undefined)} open={Boolean(statusTask)} title="修改当前通道报备状态"><div className="admin-system-modal-form"><Select label="报备状态" onChange={(event) => setNextStatus(event.target.value)} options={[{label:'未报备',value:'pending'},{label:'资料待补充',value:'waiting_material'},{label:'报备中',value:'reporting'},{label:'报备通过',value:'approved'},{label:'报备失败',value:'failed'},{label:'放弃报备',value:'abandoned'}]} value={nextStatus}/><Textarea label="修改原因" onChange={(event) => setStatusReason(event.target.value)} rows={3} value={statusReason}/></div></Modal>
{configType ? <ReportFieldMappingModal fields={fields} libraryFields={libraryFields} onClose={() => setConfigType(undefined)} onSave={saveFieldMapping} reportType={configType} /> : null}
{material ? (
<Modal
footer={<Button onClick={() => setMaterial(undefined)}></Button>}
onClose={() => setMaterial(undefined)}
open
size="xl"
title="查看报备资料"
>
<div className="page-stack">
<div className="detail-grid">
<div>
<span></span>
<strong>{material.signatureName}</strong>
</div>
<div>
<span></span>
<strong>
{material.tenant.name} · {material.application?.name ?? '-'}
</strong>
</div>
<div>
<span>/</span>
<strong>
{material.channel.name} · V{material.materialVersion}
</strong>
</div>
</div>
<div className="report-material-detail-list">
{material.fields.map((field) => (
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
<span>
{field.exportName || field.name}
{field.required ? ' *' : ''}
</span>
<strong>
{typeof field.value === 'object'
? String((field.value as Record<string, unknown>)?.fileName ?? '-')
: String(field.value ?? '-')}
</strong>
</div>
))}
{material.historicalFields.map((field) => (
<div key={field.code}>
<span>{field.name}</span>
<strong>{String(field.value ?? '-')}</strong>
</div>
))}
</div>
</div>
</Modal>
) : null}
<Modal
footer={
<>
<Button onClick={() => setStatusTask(undefined)} variant="ghost">
</Button>
<Button onClick={saveTaskStatus}></Button>
</>
}
onClose={() => setStatusTask(undefined)}
open={Boolean(statusTask)}
title="修改当前通道报备状态"
>
<div className="admin-system-modal-form">
<Select
label="报备状态"
onChange={(event) => setNextStatus(event.target.value)}
options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]}
value={nextStatus}
/>
<Textarea
label="修改原因"
onChange={(event) => setStatusReason(event.target.value)}
rows={3}
value={statusReason}
/>
</div>
</Modal>
{configType ? (
<ReportFieldMappingModal
fields={fields}
libraryFields={libraryFields}
onClose={() => setConfigType(undefined)}
onSave={saveFieldMapping}
reportType={configType}
/>
) : null}
</section>
);
}
+193 -51
View File
@@ -1,22 +1,36 @@
import { useEffect, useState } from 'react';
import { FileSpreadsheet, Plus, Search } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Tabs } from '@/components/ui';
import { Breadcrumb, Button, Input, Modal, Tabs } from '@/components/ui';
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
import { DrainageFormModal } from './enterprise-signatures/DrainageFormModal';
import { EnterpriseSignaturesTable } from './enterprise-signatures/EnterpriseSignaturesTable';
import { SignatureFormModal } from './enterprise-signatures/SignatureFormModal';
import { ChannelReportStatusModal, ConfirmModal, DrainageReportStatusModal } from './enterprise-signatures/SignatureReportModals';
import {
ChannelReportStatusModal,
ConfirmModal,
DrainageReportStatusModal,
} from './enterprise-signatures/SignatureReportModals';
import { buildDrainagePayload, readDrainagePayload } from './enterprise-signatures/signature.helpers';
import type { DrainageInfo, SignatureFormState } from './enterprise-signatures/signature.types';
/** R4 page container: owns query state and coordinates focused presentation components. */
export function AdminEnterpriseSignaturesPage() {
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
const [deleteTarget, setDeleteTarget] = useState<{
kind: 'drainage';
signatureId: string;
id: string;
name: string;
} | null>(null);
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{
signature: ClientSmsSignature;
item: DrainageInfo;
} | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [applicationKeyword, setApplicationKeyword] = useState('');
@@ -36,10 +50,20 @@ export function AdminEnterpriseSignaturesPage() {
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
const [message, setMessage] = useState('');
const [materialChangedSignature, setMaterialChangedSignature] = useState<ClientSmsSignature | null>(null);
const pageSize = 10;
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }, targetPage = page, targetSort = signatureSort) {
async function loadData(
filters = {
enterpriseKeyword: appliedEnterpriseKeyword,
applicationKeyword: appliedApplicationKeyword,
signatureKeyword: appliedSignatureKeyword,
drainageKeyword: appliedDrainageKeyword,
},
targetPage = page,
targetSort = signatureSort,
) {
try {
const [signatureResult, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignaturesPage({ ...filters, signatureSort: targetSort, page: targetPage, pageSize }),
@@ -57,7 +81,7 @@ export function AdminEnterpriseSignaturesPage() {
}
useEffect(() => {
void loadData(undefined, page);
queueMicrotask(() => void loadData(undefined, page));
}, [page]);
const filteredSignatures = signatures;
@@ -68,21 +92,27 @@ export function AdminEnterpriseSignaturesPage() {
async function saveSignature(state: SignatureFormState) {
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
const existingPayload = existing ? readDrainagePayload(existing) : { links: [], signatureProfile: undefined };
const drainageInfo = buildDrainagePayload({
mobile: state.mobile,
unicom: state.unicom,
telecom: state.telecom,
}, existingPayload.links, existingPayload.signatureProfile, state.reportValues);
const drainageInfo = buildDrainagePayload(
{
mobile: state.mobile,
unicom: state.unicom,
telecom: state.telecom,
},
existingPayload.links,
existingPayload.signatureProfile,
state.reportValues,
);
try {
let saved: ClientSmsSignature;
if (existing) {
await adminApi.updateEnterpriseSignature(existing.id, {
saved = await adminApi.updateEnterpriseSignature(existing.id, {
applicationId: state.applicationId || null,
drainageInfo,
name: state.name,
purpose: state.purpose,
});
} else {
await adminApi.createEnterpriseSignature({
saved = await adminApi.createEnterpriseSignature({
applicationId: state.applicationId || undefined,
drainageInfo,
name: state.name,
@@ -91,6 +121,7 @@ export function AdminEnterpriseSignaturesPage() {
});
}
setSignatureModal(null);
if (saved.reportMaterialChanged) setMaterialChangedSignature(saved);
await loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '企业签名保存失败');
@@ -154,39 +185,87 @@ export function AdminEnterpriseSignaturesPage() {
<h1></h1>
</div>
<div className="page-heading-actions">
<Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost"></Button>
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal(activeTab === 'sms' ? 'new' : null)}></Button>
<Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost">
</Button>
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal(activeTab === 'sms' ? 'new' : null)}>
</Button>
</div>
</div>
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
<Input label="签名" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名" prefix={<Search size={16} />} value={signatureKeyword} />
<Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入引流信息、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} />
<Input
label="企业名称"
onChange={(event) => setEnterpriseKeyword(event.target.value)}
placeholder="请输入企业名称"
prefix={<Search size={16} />}
value={enterpriseKeyword}
/>
<Input
label="企业应用"
onChange={(event) => setApplicationKeyword(event.target.value)}
placeholder="请输入企业应用名称"
prefix={<Search size={16} />}
value={applicationKeyword}
/>
<Input
label="签名"
onChange={(event) => setSignatureKeyword(event.target.value)}
placeholder="请输入签名"
prefix={<Search size={16} />}
value={signatureKeyword}
/>
<Input
label="引流信息"
onChange={(event) => setDrainageKeyword(event.target.value)}
placeholder="请输入引流信息、URL 或备注"
prefix={<Search size={16} />}
value={drainageKeyword}
/>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), signatureKeyword: signatureKeyword.trim(), drainageKeyword: drainageKeyword.trim() };
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedSignatureKeyword(filters.signatureKeyword);
setAppliedDrainageKeyword(filters.drainageKeyword);
setPage(1);
void loadData(filters, 1);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', applicationKeyword: '', signatureKeyword: '', drainageKeyword: '' };
setEnterpriseKeyword('');
setApplicationKeyword('');
setSignatureKeyword('');
setDrainageKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedApplicationKeyword('');
setAppliedSignatureKeyword('');
setAppliedDrainageKeyword('');
setPage(1);
void loadData(filters, 1);
}} variant="ghost"></Button>
<Button
icon={<Search size={16} />}
onClick={() => {
const filters = {
enterpriseKeyword: enterpriseKeyword.trim(),
applicationKeyword: applicationKeyword.trim(),
signatureKeyword: signatureKeyword.trim(),
drainageKeyword: drainageKeyword.trim(),
};
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedSignatureKeyword(filters.signatureKeyword);
setAppliedDrainageKeyword(filters.drainageKeyword);
setPage(1);
void loadData(filters, 1);
}}
>
</Button>
<Button
onClick={() => {
const filters = {
enterpriseKeyword: '',
applicationKeyword: '',
signatureKeyword: '',
drainageKeyword: '',
};
setEnterpriseKeyword('');
setApplicationKeyword('');
setSignatureKeyword('');
setDrainageKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedApplicationKeyword('');
setAppliedSignatureKeyword('');
setAppliedDrainageKeyword('');
setPage(1);
void loadData(filters, 1);
}}
variant="ghost"
>
</Button>
</div>
</div>
@@ -199,7 +278,12 @@ export function AdminEnterpriseSignaturesPage() {
value={activeTab}
items={[
{ label: '短信签名', value: 'sms', content: smsSignatureContent },
{ label: '彩信签名', pending: true, value: 'mms', content: <div className="ui-table__empty"></div> },
{
label: '彩信签名',
pending: true,
value: 'mms',
content: <div className="ui-table__empty"></div>,
},
]}
/>
</div>
@@ -209,29 +293,87 @@ export function AdminEnterpriseSignaturesPage() {
applications={applications}
item={signatureModal === 'new' ? undefined : signatureModal}
onClose={() => setSignatureModal(null)}
onSubmit={(state) => { void saveSignature(state); }}
onSubmit={(state) => {
void saveSignature(state);
}}
tenants={tenants}
/>
) : null}
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => {
setMessage('导入解析完成,合格资料已进入审核中心的导入批次');
void loadData();
}} /> : null}
{reportStatusTarget ? <ChannelReportStatusModal item={reportStatusTarget} onClose={() => setReportStatusTarget(null)} onSaved={() => { setReportStatusTarget(null); void loadData(); }} /> : null}
{materialChangedSignature ? (
<Modal
footer={
<>
<Button onClick={() => setMaterialChangedSignature(null)} variant="ghost">
</Button>
<Button
onClick={() => {
const id = materialChangedSignature.id;
setMaterialChangedSignature(null);
navigate(`/admin/report-materials?signatureId=${encodeURIComponent(id)}`);
}}
>
</Button>
</>
}
onClose={() => setMaterialChangedSignature(null)}
open
title="签名资料已更新"
>
<div className="signature-alert">
<FileSpreadsheet size={20} />
<span></span>
</div>
</Modal>
) : null}
{importOpen ? (
<ReportMaterialImportModal
onClose={() => setImportOpen(false)}
onCompleted={() => {
setMessage('导入解析完成,合格资料已进入审核中心的导入批次');
void loadData();
}}
/>
) : null}
{reportStatusTarget ? (
<ChannelReportStatusModal
item={reportStatusTarget}
onClose={() => setReportStatusTarget(null)}
onSaved={() => {
setReportStatusTarget(null);
void loadData();
}}
/>
) : null}
{drainageModal ? (
<DrainageFormModal
applicationId={signatures.find((item) => item.id === drainageModal.signatureId)?.applicationId}
item={drainageModal.item}
onClose={() => setDrainageModal(null)}
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }}
onSubmit={(item) => {
void saveDrainage(drainageModal.signatureId, item);
}}
/>
) : null}
{drainageStatusTarget ? (
<DrainageReportStatusModal
item={drainageStatusTarget.item}
onClose={() => setDrainageStatusTarget(null)}
onSaved={() => {
setDrainageStatusTarget(null);
void loadData();
}}
signature={drainageStatusTarget.signature}
/>
) : null}
{drainageStatusTarget ? <DrainageReportStatusModal item={drainageStatusTarget.item} onClose={() => setDrainageStatusTarget(null)} onSaved={() => { setDrainageStatusTarget(null); void loadData(); }} signature={drainageStatusTarget.signature} /> : null}
{deleteTarget ? (
<ConfirmModal
message={`确认删除“${deleteTarget.name}”吗?删除后会写入真实后台。`}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => { void confirmDelete(); }}
onConfirm={() => {
void confirmDelete();
}}
/>
) : null}
</section>
+339
View File
@@ -0,0 +1,339 @@
import { useEffect, useState } from 'react';
import { Download, Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportMaterialBatch, type ReportTask } from '@/api/adminApi';
import {
Breadcrumb,
Button,
CarrierTag,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
Textarea,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusLabels: Record<string, string> = {
completed: '生成完成',
partial_failed: '部分生成',
failed: '生成失败',
generating: '生成中',
pending: '未报备',
waiting_material: '资料待补充',
reporting: '报备中',
approved: '报备通过',
rejected: '报备失败',
abandoned: '已放弃',
};
export function AdminReportBatchesPage() {
const [items, setItems] = useState<ReportMaterialBatch[]>([]);
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [detail, setDetail] = useState<ReportMaterialBatch>();
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [nextStatus, setNextStatus] = useState('reporting');
const [reason, setReason] = useState('');
const [error, setError] = useState('');
const pageSize = 20;
function load(target = page) {
adminApi
.listReportMaterialBatches({
keyword: keyword.trim() || undefined,
startAt: dateRange.start,
endAt: dateRange.end,
page: target,
pageSize,
})
.then((result) => {
setItems(result.items);
setTotal(result.total);
setError('');
})
.catch((failure: Error) => setError(failure.message || '报备批次加载失败'));
}
useEffect(() => {
load(page);
}, [page]);
async function openBatch(batch: ReportMaterialBatch) {
try {
const [batchDetail, taskPage] = await Promise.all([
adminApi.getReportMaterialBatch(batch.id),
adminApi.listReportMaterialBatchTasks(batch.id, { page: 1, pageSize: 100 }),
]);
setDetail(batchDetail);
setTasks(taskPage.items);
setSelected(new Set());
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批次明细加载失败');
}
}
async function saveStatuses() {
const chosen = tasks.filter((task) => selected.has(task.id));
if (!chosen.length) return;
try {
await adminApi.changeReportTaskStatuses({
items: chosen.map((task) => ({
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
reportType: task.reportType,
drainageItemId: task.drainageItemId ?? undefined,
status: nextStatus,
})),
reason: reason.trim() || undefined,
sourceEntry: 'report_task',
});
if (detail) await openBatch(detail);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '批量状态修改失败');
}
}
async function exportOne(task: ReportTask) {
try {
const blob = await adminApi.exportSingleReportMaterial({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
}
}
const columns: Array<TableColumn<ReportMaterialBatch>> = [
{ key: 'batchNo', title: '报备批次号', render: (item) => <strong>{item.batchNo}</strong> },
{ key: 'time', title: '生成时间', render: (item) => formatDateTime(item.createdAt) },
{ key: 'count', title: '明细进度', render: (item) => `${item.successCount}/${item.reportTotal}` },
{ key: 'channels', title: '通道/文件', render: (item) => `${item.channelCount}个通道 · ${item.fileCount}份文件` },
{
key: 'status',
title: '生成状态',
render: (item) => (
<Tag tone={item.status === 'completed' ? 'success' : item.status === 'failed' ? 'danger' : 'warning'}>
{statusLabels[item.status] ?? item.status}
</Tag>
),
},
{
key: 'files',
title: '文件',
render: (item) => (
<div className="table-actions">
{item.exportFiles.map((file) =>
file.fileObjectId ? (
<a href={fileDownloadUrl(file.fileObjectId)} key={file.id}>
<Download size={14} />
{file.fileName}
</a>
) : null,
)}
</div>
),
},
{
key: 'actions',
title: '操作',
align: 'right',
render: (item) => (
<Button icon={<Eye size={14} />} onClick={() => void openBatch(item)} size="sm" variant="ghost">
</Button>
),
},
];
const taskColumns: Array<TableColumn<ReportTask>> = [
{
key: 'select',
title: '',
width: '44px',
render: (task) => (
<input
aria-label={`选择${task.signature?.name ?? task.id}`}
checked={selected.has(task.id)}
onChange={() =>
setSelected((current) => {
const next = new Set(current);
if (next.has(task.id)) next.delete(task.id);
else next.add(task.id);
return next;
})
}
type="checkbox"
/>
),
},
{
key: 'target',
title: '企业/应用/签名',
render: (task) => (
<div>
<strong>{task.signature?.name ?? '-'}</strong>
<div className="muted">
{task.signature?.tenant?.name ?? '-'} · {task.signature?.application?.name ?? '-'}
</div>
</div>
),
},
{
key: 'channel',
title: '通道/运营商',
render: (task) => (
<div>
{task.channel?.name ?? '-'}
{task.carrier ? (
<div>
<CarrierTag carrier={task.carrier} />
</div>
) : null}
</div>
),
},
{
key: 'version',
title: '资料版本',
render: (task) => `V${task.exportItems?.[0]?.batchItem.materialVersion ?? '-'}`,
},
{
key: 'status',
title: '报备状态',
render: (task) => (
<Tag
tone={
task.status === 'approved'
? 'success'
: task.status === 'failed' || task.status === 'rejected'
? 'danger'
: 'warning'
}
>
{statusLabels[task.status] ?? task.status}
</Tag>
),
},
{
key: 'actions',
title: '操作',
align: 'right',
render: (task) =>
task.reportType !== 'drainage' ? (
<Button icon={<Download size={14} />} onClick={() => void exportOne(task)} size="sm" variant="ghost">
</Button>
) : (
'-'
),
},
];
return (
<section className="page-stack report-batch-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备工作台', '报备批次']} />
<h1></h1>
<p></p>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="报备批次号" onChange={(event) => setKeyword(event.target.value)} value={keyword} />
<DateRangeInput label="生成时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else load(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="surface">
<Table columns={columns} data={items} emptyText="尚未生成报备批次" pagination={false} rowKey="id" />
</div>
<Pagination
nextDisabled={page * pageSize >= total}
onNext={() => setPage((value) => value + 1)}
onPageChange={setPage}
onPrevious={() => setPage((value) => Math.max(1, value - 1))}
page={page}
previousDisabled={page <= 1}
total={total}
totalPages={Math.max(1, Math.ceil(total / pageSize))}
/>
{detail ? (
<Modal
footer={<Button onClick={() => setDetail(undefined)}></Button>}
onClose={() => setDetail(undefined)}
open
size="xl"
title={`批次明细 · ${detail.batchNo}`}
>
<div className="page-stack">
<div className="report-batch-toolbar">
<strong>
{tasks.length} {selected.size}
</strong>
<Select
aria-label="批量修改状态"
onChange={(event) => setNextStatus(event.target.value)}
options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]}
value={nextStatus}
/>
<Textarea
aria-label="修改原因"
onChange={(event) => setReason(event.target.value)}
placeholder="修改原因"
rows={2}
value={reason}
/>
<Button disabled={!selected.size} onClick={() => void saveStatuses()}>
</Button>
</div>
<Table columns={taskColumns} data={tasks} emptyText="该批次暂无明细" pagination={false} rowKey="id" />
</div>
</Modal>
) : null}
</section>
);
}
+301 -146
View File
@@ -1,9 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Download, Layers3, Search, ShieldCheck } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { AlertTriangle, CheckCircle2, Layers3, Search, ShieldCheck } from 'lucide-react';
import {
adminApi,
fileDownloadUrl,
type ReportMaterialBatch,
type ReportMaterialBatchPreflight,
type ReportMaterialBatchResult,
type ReportMaterialPendingItem,
@@ -18,7 +17,6 @@ import {
Pagination,
Select,
Table,
Tabs,
Tag,
type DateRangeValue,
type TableColumn,
@@ -26,29 +24,24 @@ import {
import { formatDateTime } from '@/utils/dateTime';
import { createUuid } from '@/utils/randomId';
const batchStatusLabels: Record<string, string> = {
completed: '生成完成',
partial_failed: '部分生成',
failed: '生成失败',
generating: '生成中',
processing: '生成中',
};
export function AdminReportMaterialsPage() {
const [activeTab, setActiveTab] = useState<'pending' | 'batches'>('pending');
const [pendingData, setPendingData] = useState<{ items: ReportMaterialPendingItem[]; total: number }>({ items: [], total: 0 });
const [batchData, setBatchData] = useState<{ items: ReportMaterialBatch[]; total: number }>({ items: [], total: 0 });
const navigate = useNavigate();
const [pendingData, setPendingData] = useState<{ items: ReportMaterialPendingItem[]; total: number }>({
items: [],
total: 0,
});
const [selected, setSelected] = useState<Set<string>>(new Set());
const [reportType, setReportType] = useState('all');
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [pendingPage, setPendingPage] = useState(1);
const [batchPage, setBatchPage] = useState(1);
const pageSize = 20;
const [busy, setBusy] = useState(false);
const [preflightBusy, setPreflightBusy] = useState(false);
const [preflight, setPreflight] = useState<ReportMaterialBatchPreflight | null>(null);
const [poolEligibility, setPoolEligibility] = useState<Map<string, ReportMaterialBatchPreflight['items'][number]>>(new Map());
const [poolEligibility, setPoolEligibility] = useState<Map<string, ReportMaterialBatchPreflight['items'][number]>>(
new Map(),
);
const [operationKey, setOperationKey] = useState('');
const [batchResult, setBatchResult] = useState<ReportMaterialBatchResult | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
@@ -68,7 +61,7 @@ export function AdminReportMaterialsPage() {
const nextReportType = filters.reportType ?? reportType;
try {
const result = await adminApi.listPendingReportMaterials({
reportType: nextReportType === 'all' ? undefined : nextReportType as 'signature' | 'drainage',
reportType: nextReportType === 'all' ? undefined : (nextReportType as 'signature' | 'drainage'),
keyword: nextKeyword.trim() || undefined,
startAt: nextDateRange.start,
endAt: nextDateRange.end,
@@ -76,7 +69,9 @@ export function AdminReportMaterialsPage() {
pageSize,
});
setPendingData({ items: result.items, total: result.total });
const eligibility = result.items.length ? await adminApi.preflightReportMaterialBatch({ items: result.items.map(toBatchItem) }) : null;
const eligibility = result.items.length
? await adminApi.preflightReportMaterialBatch({ items: result.items.map(toBatchItem) })
: null;
const eligibilityMap = new Map((eligibility?.items ?? []).map((item) => [item.id, item]));
setPoolEligibility(eligibilityMap);
setSelected((current) => new Set([...current].filter((id) => eligibilityMap.get(id)?.eligible)));
@@ -86,38 +81,9 @@ export function AdminReportMaterialsPage() {
}
}
async function loadBatches(
page = batchPage,
filters: {
keyword?: string;
dateRange?: DateRangeValue;
} = {},
) {
const nextKeyword = filters.keyword ?? keyword;
const nextDateRange = filters.dateRange ?? dateRange;
try {
const result = await adminApi.listReportMaterialBatches({
keyword: nextKeyword.trim() || undefined,
startAt: nextDateRange.start,
endAt: nextDateRange.end,
page,
pageSize,
});
setBatchData({ items: result.items, total: result.total });
setError('');
} catch (failure) {
setError(failure instanceof Error ? failure.message : '已生成批次加载失败');
}
}
function loadActive() {
if (activeTab === 'pending') void loadPending(pendingPage);
else void loadBatches(batchPage);
}
useEffect(() => {
loadActive();
}, [activeTab, pendingPage, batchPage, reportType]);
queueMicrotask(() => void loadPending(pendingPage));
}, [pendingPage, reportType]);
const eligibleItems = pendingData.items.filter((item) => poolEligibility.get(item.id)?.eligible);
const allSelected = eligibleItems.length > 0 && eligibleItems.every((item) => selected.has(item.id));
@@ -126,7 +92,8 @@ export function AdminReportMaterialsPage() {
if (!poolEligibility.get(id)?.eligible) return;
setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
@@ -164,9 +131,11 @@ export function AdminReportMaterialsPage() {
items: chosen.map((item) => ({ ...toBatchItem(item), materialVersion: item.materialVersion })),
});
setBatchResult(batch);
setMessage(`批次 ${batch.batchNo} 已生成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`);
setMessage(
`批次 ${batch.batchNo} 已生成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`,
);
setSelected(new Set());
await Promise.all([loadPending(pendingPage), loadBatches(1)]);
await loadPending(pendingPage);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备批次生成失败');
} finally {
@@ -174,103 +143,289 @@ export function AdminReportMaterialsPage() {
}
}
const pendingColumns = useMemo<Array<TableColumn<ReportMaterialPendingItem>>>(() => [
{
key: 'select',
title: '',
width: '48px',
render: (item) => {
const eligible = poolEligibility.get(item.id)?.eligible;
return <input aria-label={`选择${item.name}`} checked={selected.has(item.id)} disabled={!eligible} onChange={() => toggle(item.id)} type="checkbox" />;
const pendingColumns = useMemo<Array<TableColumn<ReportMaterialPendingItem>>>(
() => [
{
key: 'select',
title: '',
width: '48px',
render: (item) => {
const eligible = poolEligibility.get(item.id)?.eligible;
return (
<input
aria-label={`选择${item.name}`}
checked={selected.has(item.id)}
disabled={!eligible}
onChange={() => toggle(item.id)}
type="checkbox"
/>
);
},
},
},
{ key: 'name', title: '资料', render: (item) => <div><strong>{item.name}</strong><div className="muted">{item.reportType === 'signature' ? '签名资料' : `引流信息 · ${item.signatureName ?? '-'}`} · {item.detail || '-'}</div></div> },
{ key: 'tenant', title: '企业/应用', render: (item) => <div><strong>{item.tenant?.name ?? '-'}</strong><div className="muted">{item.application?.name ?? '未指定应用'}</div></div> },
{ key: 'eligibility', title: '版本/资格', render: (item) => {
const eligibility = poolEligibility.get(item.id);
const eligible = eligibility?.eligible;
return <div><Tag tone={eligible ? 'success' : 'warning'}>V{item.materialVersion} · {eligible ? `${eligibility.targets.filter((target) => target.eligible).length}个通道可生成` : '待补充'}</Tag>{!eligible ? <div className="muted">{eligibility?.blockedReasons[0] ?? '资格检查中'}</div> : null}</div>;
} },
{ key: 'changedAt', title: '资料变更时间', render: (item) => formatDateTime(item.changedAt) },
], [poolEligibility, selected]);
const batchColumns = useMemo<Array<TableColumn<ReportMaterialBatch>>>(() => [
{ key: 'batchNo', title: '报备批次号', render: (batch) => <strong>{batch.batchNo}</strong> },
{ key: 'time', title: '生成时间', render: (batch) => formatDateTime(batch.createdAt) },
{ key: 'reportTotal', title: '报备总数', render: (batch) => batch.reportTotal.toLocaleString('zh-CN') },
{ key: 'successCount', title: '成功数', render: (batch) => batch.successCount.toLocaleString('zh-CN') },
{ key: 'successRate', title: '成功率', render: (batch) => `${(batch.successRate * 100).toFixed(2)}%` },
{ key: 'channels', title: '通道/文件', render: (batch) => `${batch.channelCount}个通道 · ${batch.fileCount}份文件` },
{ key: 'status', title: '生成状态', render: (batch) => <Tag tone={batch.status === 'completed' ? 'success' : batch.status === 'failed' ? 'danger' : 'warning'}>{batchStatusLabels[batch.status] ?? batch.status}</Tag> },
{ key: 'files', title: '报备文件', align: 'right', render: (batch) => <div className="table-actions">{batch.exportFiles.map((file) => file.fileObjectId ? <a href={fileDownloadUrl(file.fileObjectId)} key={file.id}><Download size={15} />{file.fileName}{file.rowCount}</a> : null)}</div> },
], []);
const filter = <div className={`surface report-material-filter report-material-filter--${activeTab}`}>
{activeTab === 'pending' ? <Select label="资料类型" onChange={(event) => { setReportType(event.target.value); setPendingPage(1); }} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /> : null}
<Input label={activeTab === 'pending' ? '企业/应用/签名/站点' : '报备批次号'} onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'pending' ? '搜索待生成资料' : '搜索报备批次号'} value={keyword} />
<DateRangeInput label={activeTab === 'pending' ? '资料变更时间' : '批次生成时间'} onChange={setDateRange} value={dateRange} />
<div className="ui-query-actions">
<Button icon={<Search size={16} />} onClick={() => {
if (activeTab === 'pending') {
setPendingPage(1);
void loadPending(1);
} else {
setBatchPage(1);
void loadBatches(1);
}
}}></Button>
<Button onClick={() => {
setKeyword('');
setDateRange({});
if (activeTab === 'pending') {
setReportType('all');
setPendingPage(1);
void loadPending(1, { keyword: '', dateRange: {}, reportType: 'all' });
} else {
setBatchPage(1);
void loadBatches(1, { keyword: '', dateRange: {} });
}
}} variant="ghost"></Button>
</div>
</div>;
return <section className="page-stack report-material-page">
<div className="surface page-heading">
<div><Breadcrumb items={['报备任务', '待生成报备批次']} /><h1></h1><p></p></div>
{activeTab === 'pending' ? <Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void beginCreateBatch()}>{busy ? '生成中...' : `预检并生成(${selected.size}`}</Button> : null}
</div>
{error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null}
<Tabs
onChange={(value) => {
setActiveTab(value as 'pending' | 'batches');
setKeyword('');
setDateRange({});
}}
value={activeTab}
items={[
{
label: '待生成资料',
value: 'pending',
content: <div className="page-stack">{filter}<div className="surface"><label className="table-actions"><input checked={allSelected} onChange={() => setSelected(allSelected ? new Set() : new Set(eligibleItems.map((item) => item.id)))} type="checkbox" /></label><Table columns={pendingColumns} data={pendingData.items} emptyText="暂无符合条件的待生成资料" pagination={false} rowKey="id" /></div><Pagination nextDisabled={pendingPage * pageSize >= pendingData.total} onNext={() => setPendingPage((page) => page + 1)} onPageChange={setPendingPage} onPrevious={() => setPendingPage((page) => Math.max(1, page - 1))} page={pendingPage} previousDisabled={pendingPage <= 1} total={pendingData.total} totalPages={Math.max(1, Math.ceil(pendingData.total / pageSize))} /></div>,
{
key: 'name',
title: '资料',
render: (item) => (
<div>
<strong>{item.name}</strong>
<div className="muted">
{item.reportType === 'signature' ? '签名资料' : `引流信息 · ${item.signatureName ?? '-'}`} ·{' '}
{item.detail || '-'}
</div>
</div>
),
},
{
key: 'tenant',
title: '企业/应用',
render: (item) => (
<div>
<strong>{item.tenant?.name ?? '-'}</strong>
<div className="muted">{item.application?.name ?? '未指定应用'}</div>
</div>
),
},
{
key: 'eligibility',
title: '版本/资格',
render: (item) => {
const eligibility = poolEligibility.get(item.id);
const eligible = eligibility?.eligible;
return (
<div>
<Tag tone={eligible ? 'success' : 'warning'}>
V{item.materialVersion} ·{' '}
{eligible ? `${eligibility.targets.filter((target) => target.eligible).length}个通道可生成` : '待补充'}
</Tag>
{!eligible ? <div className="muted">{eligibility?.blockedReasons[0] ?? '资格检查中'}</div> : null}
</div>
);
},
{
label: '已生成批次',
value: 'batches',
content: <div className="page-stack">{filter}<div className="surface"><Table columns={batchColumns} data={batchData.items} emptyText="尚未生成报备批次" pagination={false} rowKey="id" /></div><Pagination nextDisabled={batchPage * pageSize >= batchData.total} onNext={() => setBatchPage((page) => page + 1)} onPageChange={setBatchPage} onPrevious={() => setBatchPage((page) => Math.max(1, page - 1))} page={batchPage} previousDisabled={batchPage <= 1} total={batchData.total} totalPages={Math.max(1, Math.ceil(batchData.total / pageSize))} /></div>,
},
]}
/>
<Modal footer={batchResult ? <Button onClick={() => setConfirmOpen(false)}></Button> : <><Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost"></Button><Button disabled={preflightBusy || busy || !preflight?.eligible} icon={<ShieldCheck size={16} />} onClick={() => void createBatch()}>{busy ? '生成处理中…' : '确认生成'}</Button></>} onClose={() => { if (!busy) setConfirmOpen(false); }} open={confirmOpen} size="xl" title="报备生成资格预检">
<div className="report-batch-preflight">
{preflightBusy ? <p role="status"></p> : null}
{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} /> {preflight.eligibleTargetCount} </span><span><AlertTriangle size={17} /> {preflight.skippedTargetCount} </span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li className={target.eligible ? 'is-eligible' : 'is-blocked'} key={target.businessKey}><span>{target.name} · <CarrierTag carrier={target.carrier} /></span><small>{target.eligible ? '资格通过' : target.blockedReasons.join('')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join('')}</p>}</article>)}</> : null}
{batchResult ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong> {batchResult.batchNo} </strong><span> {batchResult.result.successCount} · {batchResult.result.skippedCount} · {batchResult.result.failedCount}</span><span>{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}</span></div></div> : null}
},
{
key: 'summary',
title: '通道明细汇总',
render: (item) =>
item.statusSummary ? (
<div className="report-material-summary">
<strong>{item.statusSummary.total} </strong>
<span>
{item.statusSummary.pending} · {item.statusSummary.reporting} · {' '}
{item.statusSummary.approved} · {item.statusSummary.failed} · {item.statusSummary.abandoned}
</span>
</div>
) : (
'-'
),
},
{ key: 'changedAt', title: '资料变更时间', render: (item) => formatDateTime(item.changedAt) },
{
key: 'actions',
title: '操作',
align: 'right',
render: (item) => (
<Button
onClick={() => navigate(`/admin/report-tasks?signatureId=${encodeURIComponent(item.signatureId)}`)}
size="sm"
variant="ghost"
>
</Button>
),
},
],
[navigate, poolEligibility, selected],
);
const filter = (
<div className="surface report-material-filter report-material-filter--pending">
<Select
label="资料类型"
onChange={(event) => {
setReportType(event.target.value);
setPendingPage(1);
}}
options={[
{ label: '全部资料', value: 'all' },
{ label: '签名资料', value: 'signature' },
{ label: '引流信息', value: 'drainage' },
]}
value={reportType}
/>
<Input
label="企业/应用/签名/站点"
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索报备资料池"
value={keyword}
/>
<DateRangeInput label="资料变更时间" onChange={setDateRange} value={dateRange} />
<div className="ui-query-actions">
<Button
icon={<Search size={16} />}
onClick={() => {
setPendingPage(1);
void loadPending(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
setPendingPage(1);
void loadPending(1, { keyword: '', dateRange: {}, reportType: 'all' });
}}
variant="ghost"
>
</Button>
</div>
</Modal>
</section>;
</div>
);
return (
<section className="page-stack report-material-page">
<div className="surface page-heading">
<div>
<Breadcrumb items={['报备工作台', '报备资料池']} />
<h1></h1>
<p> × × </p>
</div>
<Button
disabled={busy || selected.size === 0}
icon={<Layers3 size={16} />}
onClick={() => void beginCreateBatch()}
>
{busy ? '生成中...' : `预检并生成(${selected.size}`}
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
{message ? <p className="form-success">{message}</p> : null}
<div className="page-stack">
{filter}
<div className="surface">
<label className="table-actions">
<input
checked={allSelected}
onChange={() => setSelected(allSelected ? new Set() : new Set(eligibleItems.map((item) => item.id)))}
type="checkbox"
/>
</label>
<Table
columns={pendingColumns}
data={pendingData.items}
emptyText="暂无符合条件的报备资料"
pagination={false}
rowKey="id"
/>
</div>
<Pagination
nextDisabled={pendingPage * pageSize >= pendingData.total}
onNext={() => setPendingPage((page) => page + 1)}
onPageChange={setPendingPage}
onPrevious={() => setPendingPage((page) => Math.max(1, page - 1))}
page={pendingPage}
previousDisabled={pendingPage <= 1}
total={pendingData.total}
totalPages={Math.max(1, Math.ceil(pendingData.total / pageSize))}
/>
</div>
<Modal
footer={
batchResult ? (
<Button onClick={() => setConfirmOpen(false)}></Button>
) : (
<>
<Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost">
</Button>
<Button
disabled={preflightBusy || busy || !preflight?.eligible}
icon={<ShieldCheck size={16} />}
onClick={() => void createBatch()}
>
{busy ? '生成处理中…' : '确认生成'}
</Button>
</>
)
}
onClose={() => {
if (!busy) setConfirmOpen(false);
}}
open={confirmOpen}
size="xl"
title="报备生成资格预检"
>
<div className="report-batch-preflight">
{preflightBusy ? <p role="status"></p> : null}
{preflight ? (
<>
<div className="report-batch-summary">
<span>
<CheckCircle2 size={17} />
{preflight.eligibleTargetCount}
</span>
<span>
<AlertTriangle size={17} />
{preflight.skippedTargetCount}
</span>
</div>
{preflight.items.map((item) => (
<article key={item.id}>
<div>
<strong>{item.name}</strong>
<small>
{item.tenantName} · {item.applicationName} · V{item.materialVersion}
</small>
</div>
{item.targets.length ? (
<ul>
{item.targets.map((target) => (
<li className={target.eligible ? 'is-eligible' : 'is-blocked'} key={target.businessKey}>
<span>
{target.name} · <CarrierTag carrier={target.carrier} />
</span>
<small>{target.eligible ? '资格通过' : target.blockedReasons.join('')}</small>
</li>
))}
</ul>
) : (
<p className="form-error">{item.blockedReasons.join('')}</p>
)}
</article>
))}
</>
) : null}
{batchResult ? (
<div className="risk-action-result" role="status">
<ShieldCheck size={20} />
<div>
<strong> {batchResult.batchNo} </strong>
<span>
{batchResult.result.successCount} · {batchResult.result.skippedCount} · {' '}
{batchResult.result.failedCount}
</span>
<span>
{batchResult.operationId}
{batchResult.replayed ? '(幂等重放)' : ''}
</span>
</div>
</div>
) : null}
</div>
</Modal>
</section>
);
}
function toBatchItem(item: ReportMaterialPendingItem) {
return { reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined, materialVersion: item.materialVersion };
return {
reportType: item.reportType,
signatureId: item.signatureId,
drainageItemId: item.drainageItemId ?? undefined,
materialVersion: item.materialVersion,
};
}
+260 -36
View File
@@ -1,7 +1,19 @@
import { useEffect, useState } from 'react';
import { Clock3, Eye, Search } from 'lucide-react';
import { adminApi, type ReportRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
import {
Breadcrumb,
Button,
DateRangeInput,
Input,
Modal,
Pagination,
Select,
Table,
Tag,
type DateRangeValue,
type TableColumn,
} from '@/components/ui';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'neutral',
@@ -17,15 +29,40 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
};
const statusLabel: Record<string, string> = {
pending: '待报备', waiting_material: '待补充资料', waiting_review: '待重新审核', reporting: '报备中', exporting: '导出中', partial: '部分通过', approved: '已通过', success: '成功', completed: '已完成', failed: '失败', rejected: '已驳回', abandoned: '已废弃', imported: '已导入', deleted: '已删除',
pending: '待报备',
waiting_material: '待补充资料',
waiting_review: '待重新审核',
reporting: '报备中',
exporting: '导出中',
partial: '部分通过',
approved: '已通过',
success: '成功',
completed: '已完成',
failed: '失败',
rejected: '已驳回',
abandoned: '已废弃',
imported: '已导入',
deleted: '已删除',
};
const actionLabel: Record<string, string> = {
create: '创建报备任务', manual_status_change: '人工修改状态', export: '导出报备资料', receipt_import: '导入回执', audit_approved_create: '引流审核通过后创建', audit_approved_reset: '引流审核通过后重置', audit_resubmit_freeze: '引流修改后冻结', audit_rejected_freeze: '引流审核驳回后冻结', drainage_deleted: '引流信息删除',
create: '创建报备任务',
manual_status_change: '人工修改状态',
export: '导出报备资料',
receipt_import: '导入回执',
audit_approved_create: '引流审核通过后创建',
audit_approved_reset: '引流审核通过后重置',
audit_resubmit_freeze: '引流修改后冻结',
audit_rejected_freeze: '引流审核驳回后冻结',
drainage_deleted: '引流信息删除',
};
const sourceEntryLabel: Record<string, string> = {
enterprise_signature: '企业签名修改', report_task: '报备任务修改', channel_report: '通道信息修改', system: '系统自动处理', legacy: '历史记录(入口未记录)',
enterprise_signature: '企业签名修改',
report_task: '报备任务修改',
channel_report: '通道信息修改',
system: '系统自动处理',
legacy: '历史记录(入口未记录)',
};
function translateStatus(value?: string | null) {
@@ -42,22 +79,67 @@ function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose:
const isDrainage = record.task?.reportType === 'drainage';
const target = isDrainage ? record.task?.drainageInfo?.url : record.task?.signature?.name;
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}>
<Modal
footer={<Button onClick={onClose}></Button>}
onClose={onClose}
open
size="xl"
title={
<div className="template-modal-title">
<h2></h2>
<p>{record.id}</p>
</div>
}
>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{record.taskId}</strong></div>
<div><span></span><strong>{record.channel?.name ?? '-'}</strong></div>
<div><span></span><strong>{isDrainage ? '引流信息' : '签名'}</strong></div>
<div><span></span><strong>{target ?? '-'}</strong></div>
<div><span></span><strong>{actionLabel[record.action] ?? record.action}</strong></div>
<div><span></span><strong>{recordSource(record)}</strong></div>
<div><span></span><strong>{translateStatus(record.statusBefore)}</strong></div>
<div><span></span><strong>{translateStatus(record.statusAfter)}</strong></div>
<div className="detail-grid__wide"><span>/</span><strong>{record.reason ?? '-'}</strong></div>
<div>
<span></span>
<strong>{record.taskId}</strong>
</div>
<div>
<span></span>
<strong>{record.channel?.name ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{isDrainage ? '引流信息' : '签名'}</strong>
</div>
<div>
<span></span>
<strong>{target ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{actionLabel[record.action] ?? record.action}</strong>
</div>
<div>
<span></span>
<strong>{recordSource(record)}</strong>
</div>
<div>
<span></span>
<strong>{translateStatus(record.statusBefore)}</strong>
</div>
<div>
<span></span>
<strong>{translateStatus(record.statusAfter)}</strong>
</div>
<div className="detail-grid__wide">
<span>/</span>
<strong>{record.reason ?? '-'}</strong>
</div>
</div>
<section className="report-history">
<h3><Clock3 size={17} /></h3>
<div><span>{record.createdAt ?? '-'}</span><strong>{actionLabel[record.action] ?? record.action}</strong><em>{record.reason ?? `修改入口:${recordSource(record)}`}</em></div>
<h3>
<Clock3 size={17} />
</h3>
<div>
<span>{record.createdAt ?? '-'}</span>
<strong>{actionLabel[record.action] ?? record.action}</strong>
<em>{record.reason ?? `修改入口:${recordSource(record)}`}</em>
</div>
</section>
</div>
</Modal>
@@ -69,6 +151,10 @@ export function AdminReportRecordsPage() {
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [reportType, setReportType] = useState('all');
const [batchNo, setBatchNo] = useState('');
const [operatorKeyword, setOperatorKeyword] = useState('');
const [statusAfter, setStatusAfter] = useState('all');
const [sourceEntry, setSourceEntry] = useState('all');
const [detail, setDetail] = useState<ReportRecord | null>(null);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
@@ -76,14 +162,19 @@ export function AdminReportRecordsPage() {
const pageSize = 10;
function loadData(targetPage = page) {
adminApi.listReportRecordsPage({
keyword: keyword || undefined,
reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage',
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
page: targetPage,
pageSize,
})
adminApi
.listReportRecordsPage({
keyword: keyword || undefined,
reportType: reportType === 'all' ? undefined : (reportType as 'signature' | 'drainage'),
batchNo: batchNo.trim() || undefined,
operatorKeyword: operatorKeyword.trim() || undefined,
statusAfter: statusAfter === 'all' ? undefined : statusAfter,
sourceEntry: sourceEntry === 'all' ? undefined : sourceEntry,
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
page: targetPage,
pageSize,
})
.then((result) => {
setRecords(result.items);
setTotal(result.total);
@@ -97,35 +188,168 @@ export function AdminReportRecordsPage() {
}, [page]);
const columns: Array<TableColumn<ReportRecord>> = [
{ key: 'task', title: '报备任务号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
{
key: 'task',
title: '报备任务号',
width: '190px',
render: (record) => <strong className="admin-task-id">{record.taskId}</strong>,
},
{ key: 'channel', title: '通道名称', width: '180px', render: (record) => record.channel?.name ?? '-' },
{ key: 'targetType', title: '变更主体', width: '110px', render: (record) => <Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}</Tag> },
{ key: 'target', title: '主体内容', width: '260px', render: (record) => record.task?.reportType === 'drainage' ? <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong><span>{record.task?.drainageInfo?.url ?? '-'}</span>{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}</div> : <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong>{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}</div> },
{
key: 'targetType',
title: '变更主体',
width: '110px',
render: (record) => (
<Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>
{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}
</Tag>
),
},
{
key: 'target',
title: '主体内容',
width: '260px',
render: (record) =>
record.task?.reportType === 'drainage' ? (
<div className="admin-task-enterprise">
<strong>{record.task?.signature?.name ?? '-'}</strong>
<span>{record.task?.drainageInfo?.url ?? '-'}</span>
{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}
</div>
) : (
<div className="admin-task-enterprise">
<strong>{record.task?.signature?.name ?? '-'}</strong>
{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}
</div>
),
},
{ key: 'source', title: '修改入口', width: '150px', render: (record) => recordSource(record) },
{
key: 'operator',
title: '操作人',
width: '140px',
render: (record) => record.operator?.displayName ?? record.operator?.username ?? '系统',
},
{ key: 'action', title: '动作', width: '170px', render: (record) => actionLabel[record.action] ?? record.action },
{ key: 'status', title: '状态变化', width: '210px', render: (record) => <Tag tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}>{`${translateStatus(record.statusBefore)}${translateStatus(record.statusAfter)}`}</Tag> },
{
key: 'status',
title: '状态变化',
width: '210px',
render: (record) => (
<Tag
tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}
>{`${translateStatus(record.statusBefore)}${translateStatus(record.statusAfter)}`}</Tag>
),
},
{ key: 'time', title: '记录时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{ key: 'reason', title: '备注', width: '320px', render: (record) => <span className="ui-table__long-text">{record.reason ?? '-'}</span> },
{ key: 'actions', title: '操作', align: 'right', width: '120px', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> },
{
key: 'reason',
title: '备注',
width: '320px',
render: (record) => <span className="ui-table__long-text">{record.reason ?? '-'}</span>,
},
{
key: 'actions',
title: '操作',
align: 'right',
width: '120px',
render: (record) => (
<Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost">
</Button>
),
},
];
return (
<section className="page-stack admin-sms-task-page report-record-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备任务', '报备记录']} />
<h1></h1>
<Breadcrumb items={['报备工作台', '状态记录']} />
<h1></h1>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="报备任务号/通道/动作/备注" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入报备任务号、通道、动作或备注" value={keyword} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<Input
label="报备任务号/通道/动作/备注"
onChange={(event) => setKeyword(event.target.value)}
placeholder="请输入报备任务号、通道、动作或备注"
value={keyword}
/>
<Select
label="报备类型"
onChange={(event) => setReportType(event.target.value)}
options={[
{ label: '全部类型', value: 'all' },
{ label: '签名报备', value: 'signature' },
{ label: '引流信息报备', value: 'drainage' },
]}
value={reportType}
/>
<Input
label="报备批次号"
onChange={(event) => setBatchNo(event.target.value)}
placeholder="输入批次号"
value={batchNo}
/>
<Input
label="操作人"
onChange={(event) => setOperatorKeyword(event.target.value)}
placeholder="姓名或账号"
value={operatorKeyword}
/>
<Select
label="变更后状态"
onChange={(event) => setStatusAfter(event.target.value)}
options={[
{ label: '全部状态', value: 'all' },
{ label: '未报备', value: 'pending' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '已放弃', value: 'abandoned' },
]}
value={statusAfter}
/>
<Select
label="修改入口"
onChange={(event) => setSourceEntry(event.target.value)}
options={[
{ label: '全部入口', value: 'all' },
{ label: '企业签名修改', value: 'enterprise_signature' },
{ label: '通道报备明细', value: 'report_task' },
{ label: '通道详情', value: 'channel_report' },
{ label: '系统自动处理', value: 'system' },
]}
value={sourceEntry}
/>
<DateRangeInput label="提交时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(1); }}></Button>
<Button onClick={() => { setKeyword(''); setDateRange({}); setReportType('all'); }} variant="ghost"></Button>
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else loadData(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
setBatchNo('');
setOperatorKeyword('');
setStatusAfter('all');
setSourceEntry('all');
}}
variant="ghost"
>
</Button>
</div>
</div>
+428 -93
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Eye, Search } from 'lucide-react';
import { adminApi, fileDownloadUrl, type ReportTask } from '@/api/adminApi';
import { Download, Eye, Search } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { adminApi, fileDownloadUrl, type ReportTask, type SingleReportMaterialDetail } from '@/api/adminApi';
import { Breadcrumb, Button, CarrierTag, DateRangeInput, Input, Modal, Pagination, Select, Table, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
@@ -31,45 +32,122 @@ function taskTargetLabel(task: ReportTask) {
function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => void }) {
const status = statusMeta[task.status] ?? { label: task.status, tone: 'info' as const };
const source = task.exportItems?.[0];
return <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="报备明细详情">
<div className="page-stack">
<div className="detail-grid">
<div><span></span><strong>{taskTargetLabel(task)}</strong></div>
<div><span></span><strong>{task.reportType === 'drainage' ? '引流信息' : '签名'}</strong></div>
<div><span></span><strong>{task.signature?.tenant?.name ?? task.tenantId}</strong></div>
<div><span></span><strong>{task.signature?.application?.name ?? '未指定应用'}</strong></div>
<div><span></span><strong>{task.channel?.name ?? task.channelId}</strong></div>
{task.reportType !== 'drainage' ? <div><span></span>{task.carrier ? <CarrierTag carrier={task.carrier} /> : <strong></strong>}</div> : null}
{task.reportType !== 'drainage' ? <div><span></span><strong>{task.approvedAt ? formatDateTime(task.approvedAt) : '-'}</strong></div> : null}
<div><span></span><Tag tone={status.tone}>{status.label}</Tag></div>
<div><span></span><strong>{formatDateTime(task.createdAt)}</strong></div>
<div><span></span><strong>{formatDateTime(task.updatedAt)}</strong></div>
<div><span></span><strong>{source ? `V${source.batchItem.materialVersion}` : '-'}</strong></div>
<div><span></span><strong>{source?.batchItem.batch.batchNo ?? '-'}</strong></div>
<div><span></span><strong>{source ? `${source.rowNumber}` : '-'}</strong></div>
<div><span></span><strong>{task.reason || '-'}</strong></div>
</div>
{source?.exportFile.fileObjectId ? <div className="surface" style={{ padding: 16 }}><a href={fileDownloadUrl(source.exportFile.fileObjectId)}>{source.exportFile.fileName}</a></div> : null}
<div className="surface" style={{ padding: 16 }}>
<h3></h3>
<div className="page-stack" style={{ marginTop: 12 }}>
{(task.records ?? []).length ? task.records!.map((record) => <div className="detail-grid" key={record.id}>
<div><span></span><strong>{formatDateTime(record.createdAt)}</strong></div>
<div><span></span><strong>{actionLabels[record.action] ?? record.action}</strong></div>
<div><span></span><strong>{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} {statusMeta[record.statusAfter]?.label ?? record.statusAfter}</strong></div>
<div><span></span><strong>{record.reason || '-'}</strong></div>
</div>) : <p className="muted"></p>}
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="报备明细详情">
<div className="page-stack">
<div className="detail-grid">
<div>
<span></span>
<strong>{taskTargetLabel(task)}</strong>
</div>
<div>
<span></span>
<strong>{task.reportType === 'drainage' ? '引流信息' : '签名'}</strong>
</div>
<div>
<span></span>
<strong>{task.signature?.tenant?.name ?? task.tenantId}</strong>
</div>
<div>
<span></span>
<strong>{task.signature?.application?.name ?? '未指定应用'}</strong>
</div>
<div>
<span></span>
<strong>{task.channel?.name ?? task.channelId}</strong>
</div>
{task.reportType !== 'drainage' ? (
<div>
<span></span>
{task.carrier ? <CarrierTag carrier={task.carrier} /> : <strong></strong>}
</div>
) : null}
{task.reportType !== 'drainage' ? (
<div>
<span></span>
<strong>{task.approvedAt ? formatDateTime(task.approvedAt) : '-'}</strong>
</div>
) : null}
<div>
<span></span>
<Tag tone={status.tone}>{status.label}</Tag>
</div>
<div>
<span></span>
<strong>{formatDateTime(task.createdAt)}</strong>
</div>
<div>
<span></span>
<strong>{formatDateTime(task.updatedAt)}</strong>
</div>
<div>
<span></span>
<strong>{source ? `V${source.batchItem.materialVersion}` : '-'}</strong>
</div>
<div>
<span></span>
<strong>{source?.batchItem.batch.batchNo ?? '-'}</strong>
</div>
<div>
<span></span>
<strong>{source ? `${source.rowNumber}` : '-'}</strong>
</div>
<div>
<span></span>
<strong>{task.reason || '-'}</strong>
</div>
</div>
{source?.exportFile.fileObjectId ? (
<div className="surface" style={{ padding: 16 }}>
<a href={fileDownloadUrl(source.exportFile.fileObjectId)}>{source.exportFile.fileName}</a>
</div>
) : null}
<div className="surface" style={{ padding: 16 }}>
<h3></h3>
<div className="page-stack" style={{ marginTop: 12 }}>
{(task.records ?? []).length ? (
task.records!.map((record) => (
<div className="detail-grid" key={record.id}>
<div>
<span></span>
<strong>{formatDateTime(record.createdAt)}</strong>
</div>
<div>
<span></span>
<strong>{actionLabels[record.action] ?? record.action}</strong>
</div>
<div>
<span></span>
<strong>
{statusMeta[record.statusBefore ?? '']?.label ?? record.statusBefore ?? '-'} {statusMeta[record.statusAfter]?.label ?? record.statusAfter}
</strong>
</div>
<div>
<span></span>
<strong>{record.reason || '-'}</strong>
</div>
</div>
))
) : (
<p className="muted"></p>
)}
</div>
</div>
</div>
</div>
</Modal>;
</Modal>
);
}
export function AdminReportTasksPage() {
const [searchParams] = useSearchParams();
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [reportType, setReportType] = useState('all');
const [status, setStatus] = useState(searchParams.get('scope') === 'pending' ? 'pending' : 'all');
const [carrier, setCarrier] = useState('all');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [material, setMaterial] = useState<SingleReportMaterialDetail | null>(null);
const [detailTask, setDetailTask] = useState<ReportTask | null>(null);
const [statusTask, setStatusTask] = useState<ReportTask | null>(null);
const [nextStatus, setNextStatus] = useState('approved');
@@ -81,14 +159,18 @@ export function AdminReportTasksPage() {
const pageSize = 10;
function loadData(targetPage = page) {
adminApi.listReportTasksPage({
reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage',
keyword: keyword || undefined,
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
page: targetPage,
pageSize,
})
adminApi
.listReportDetailsPage({
signatureId: searchParams.get('signatureId') || undefined,
reportType: reportType === 'all' ? undefined : (reportType as 'signature' | 'drainage'),
status: status === 'all' ? undefined : status,
carrier: carrier === 'all' ? undefined : carrier,
keyword: keyword || undefined,
createdAtFrom: dateRange.start || undefined,
createdAtTo: dateRange.end || undefined,
page: targetPage,
pageSize,
})
.then((result) => {
setTasks(result.items);
setTotal(result.total);
@@ -103,22 +185,24 @@ export function AdminReportTasksPage() {
async function saveTaskStatus() {
if (!statusTask) return;
const chosen = selected.size ? tasks.filter((task) => selected.has(task.id)) : [statusTask];
setBusy(true);
try {
await adminApi.changeReportTaskStatuses({
items: [{
signatureId: statusTask.signatureId,
channelId: statusTask.channelId,
carrier: statusTask.carrier ?? undefined,
reportType: statusTask.reportType,
drainageItemId: statusTask.drainageItemId ?? undefined,
items: chosen.map((task) => ({
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
reportType: task.reportType,
drainageItemId: task.drainageItemId ?? undefined,
status: nextStatus,
}],
})),
reason: statusReason.trim() || undefined,
sourceEntry: 'report_task',
});
setStatusTask(null);
setStatusReason('');
setSelected(new Set());
loadData();
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备状态保存失败');
@@ -127,56 +211,307 @@ export function AdminReportTasksPage() {
}
}
async function openMaterial(task: ReportTask) {
try {
setMaterial(
await adminApi.getSingleReportMaterialDetail({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
}),
);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '报备资料加载失败');
}
}
async function exportMaterial(task: ReportTask) {
try {
const blob = await adminApi.exportSingleReportMaterial({
reportType: task.reportType,
signatureId: task.signatureId,
channelId: task.channelId,
carrier: task.carrier ?? undefined,
drainageItemId: task.drainageItemId ?? undefined,
batchItemId: task.exportItems?.[0]?.batchItem.id,
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${task.signature?.name ?? '签名'}-${task.channel?.name ?? '通道'}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
} catch (failure) {
setError(failure instanceof Error ? failure.message : '单条资料导出失败');
}
}
const columns: Array<TableColumn<ReportTask>> = [
{ key: 'target', title: '报备对象', render: (record) => <div><strong>{taskTargetLabel(record)}</strong><div className="muted">{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}</div></div> },
{
key: 'select',
title: '',
width: '44px',
render: (record) => (
<input
aria-label={`选择${taskTargetLabel(record)}`}
checked={selected.has(record.id)}
onChange={() =>
setSelected((current) => {
const next = new Set(current);
if (next.has(record.id)) next.delete(record.id);
else next.add(record.id);
return next;
})
}
type="checkbox"
/>
),
},
{
key: 'target',
title: '报备对象',
render: (record) => (
<div>
<strong>{taskTargetLabel(record)}</strong>
<div className="muted">
{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}
</div>
</div>
),
},
{ key: 'application', title: '企业应用', render: (record) => record.signature?.application?.name ?? '未指定应用' },
{ key: 'channel', title: '通道/运营商', render: (record) => <div><strong>{record.channel?.name ?? record.channelId}</strong>{record.reportType !== 'drainage' ? <div className="muted">{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}</div> : null}</div> },
{ key: 'batch', title: '批次/版本', render: (record) => {
const source = record.exportItems?.[0];
return source ? <div><strong>{source.batchItem.batch.batchNo}</strong><div className="muted">V{source.batchItem.materialVersion} · {source.rowNumber}</div></div> : '-';
} },
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
{
key: 'channel',
title: '通道/运营商',
render: (record) => (
<div>
<strong>{record.channel?.name ?? record.channelId}</strong>
{record.reportType !== 'drainage' ? <div className="muted">{record.carrier ? <CarrierTag carrier={record.carrier} /> : '历史通道级(未拆分)'}</div> : null}
</div>
),
},
{
key: 'batch',
title: '批次/版本',
render: (record) => {
const source = record.exportItems?.[0];
return source ? (
<div>
<strong>{source.batchItem.batch.batchNo}</strong>
<div className="muted">
V{source.batchItem.materialVersion} · {source.rowNumber}
</div>
</div>
) : (
'-'
);
},
},
{
key: 'status',
title: '状态',
render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag>,
},
{ key: 'time', title: '更新时间', render: (record) => formatDateTime(record.updatedAt ?? record.createdAt) },
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={14} />} onClick={() => setDetailTask(record)} size="sm" variant="ghost"></Button><Button onClick={() => {
setStatusTask(record);
setNextStatus(record.status);
setStatusReason('');
}} size="sm" variant="ghost"></Button></div> },
{
key: 'actions',
title: '操作',
align: 'right',
render: (record) => (
<div className="table-actions">
<Button icon={<Eye size={14} />} onClick={() => void openMaterial(record)} size="sm" variant="ghost">
</Button>
{record.reportType !== 'drainage' ? (
<Button icon={<Download size={14} />} onClick={() => void exportMaterial(record)} size="sm" variant="ghost">
</Button>
) : null}
<Button
onClick={() => {
setSelected(new Set());
setStatusTask(record);
setNextStatus(record.status);
setStatusReason('');
}}
size="sm"
variant="ghost"
>
</Button>
</div>
),
},
];
return <section className="page-stack admin-sms-task-page report-task-page">
<div className="page-heading"><div><Breadcrumb items={['报备任务', '报备明细']} /><h1></h1><p></p></div></div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="企业/应用/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions"><Button icon={<Search size={16} />} onClick={() => { if (page !== 1) setPage(1); else loadData(1); }}></Button><Button onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
}} variant="ghost"></Button></div>
</div>
<div className="surface report-task-table-card"><Table columns={columns} data={tasks} emptyText="暂无报备明细" pagination={false} rowKey="id" /></div>
<Pagination nextDisabled={page * pageSize >= total} onNext={() => setPage((current) => current + 1)} onPageChange={setPage} onPrevious={() => setPage((current) => Math.max(1, current - 1))} page={page} previousDisabled={page <= 1} total={total} totalPages={Math.max(1, Math.ceil(total / pageSize))} />
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
<Modal footer={<><Button disabled={busy} onClick={() => setStatusTask(null)} variant="ghost"></Button><Button disabled={busy} onClick={() => void saveTaskStatus()}>{busy ? '保存中…' : '保存'}</Button></>} onClose={() => setStatusTask(null)} open={Boolean(statusTask)} title="修改报备状态">
{statusTask ? <div className="page-stack">
<div className="detail-grid">
<div><span></span><strong>{taskTargetLabel(statusTask)}</strong></div>
<div><span></span><strong>{statusTask.channel?.name ?? statusTask.channelId}</strong></div>
<div><span></span><strong>{statusMeta[statusTask.status]?.label ?? statusTask.status}</strong></div>
return (
<section className="page-stack admin-sms-task-page report-task-page">
<div className="page-heading">
<div>
<Breadcrumb items={['报备工作台', '通道报备明细']} />
<h1></h1>
<p> × / × × </p>
</div>
<Select label="修改为" onChange={(event) => setNextStatus(event.target.value)} options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]} value={nextStatus} />
<Textarea label="修改原因(选填)" onChange={(event) => setStatusReason(event.target.value)} placeholder="可填写供应商反馈或人工处理说明" rows={3} value={statusReason} />
</div> : null}
</Modal>
</section>;
<Button
disabled={!selected.size}
onClick={() => {
const first = tasks.find((task) => selected.has(task.id));
if (first) {
setStatusTask(first);
setNextStatus('reporting');
}
}}
>
{selected.size}
</Button>
</div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-task-filter">
<Input label="企业/应用/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} />
<Select
label="报备类型"
onChange={(event) => setReportType(event.target.value)}
options={[
{ label: '全部类型', value: 'all' },
{ label: '签名报备', value: 'signature' },
{ label: '引流信息报备', value: 'drainage' },
]}
value={reportType}
/>
<Select
label="运营商"
onChange={(event) => setCarrier(event.target.value)}
options={[
{ label: '全部运营商', value: 'all' },
{ label: '移动', value: 'mobile' },
{ label: '联通', value: 'unicom' },
{ label: '电信', value: 'telecom' },
]}
value={carrier}
/>
<Select label="报备状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, ...Object.entries(statusMeta).map(([value, meta]) => ({ label: meta.label, value }))]} value={status} />
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button
icon={<Search size={16} />}
onClick={() => {
if (page !== 1) setPage(1);
else loadData(1);
}}
>
</Button>
<Button
onClick={() => {
setKeyword('');
setDateRange({});
setReportType('all');
setCarrier('all');
setStatus('all');
}}
variant="ghost"
>
</Button>
</div>
</div>
<div className="surface report-task-table-card">
<Table columns={columns} data={tasks} emptyText="暂无报备明细" pagination={false} rowKey="id" />
</div>
<Pagination nextDisabled={page * pageSize >= total} onNext={() => setPage((current) => current + 1)} onPageChange={setPage} onPrevious={() => setPage((current) => Math.max(1, current - 1))} page={page} previousDisabled={page <= 1} total={total} totalPages={Math.max(1, Math.ceil(total / pageSize))} />
{detailTask ? <TaskDetailModal onClose={() => setDetailTask(null)} task={detailTask} /> : null}
{material ? (
<Modal footer={<Button onClick={() => setMaterial(null)}></Button>} onClose={() => setMaterial(null)} open size="xl" title="查看报备资料">
<div className="page-stack">
<div className="detail-grid">
<div>
<span></span>
<strong>{material.signatureName}</strong>
</div>
<div>
<span>/</span>
<strong>
{material.tenant.name} · {material.application?.name ?? '-'}
</strong>
</div>
<div>
<span>/</span>
<strong>
{material.channel.name} · V{material.materialVersion}
</strong>
</div>
</div>
<div className="report-material-detail-list">
{material.fields.map((field) => (
<div className={field.missing ? 'is-missing' : ''} key={field.id}>
<span>
{field.exportName || field.name}
{field.required ? ' *' : ''}
</span>
<strong>{typeof field.value === 'object' ? String((field.value as Record<string, unknown>)?.fileName ?? '-') : String(field.value ?? '-')}</strong>
</div>
))}
{material.historicalFields.map((field) => (
<div key={field.code}>
<span>{field.name}</span>
<strong>{String(field.value ?? '-')}</strong>
</div>
))}
</div>
</div>
</Modal>
) : null}
<Modal
footer={
<>
<Button disabled={busy} onClick={() => setStatusTask(null)} variant="ghost">
</Button>
<Button disabled={busy} onClick={() => void saveTaskStatus()}>
{busy ? '保存中…' : '保存'}
</Button>
</>
}
onClose={() => setStatusTask(null)}
open={Boolean(statusTask)}
title="修改报备状态"
>
{statusTask ? (
<div className="page-stack">
<div className="detail-grid">
<div>
<span></span>
<strong>{selected.size ? `已选择 ${selected.size} 条明细` : taskTargetLabel(statusTask)}</strong>
</div>
<div>
<span></span>
<strong>{statusTask.channel?.name ?? statusTask.channelId}</strong>
</div>
<div>
<span></span>
<strong>{statusMeta[statusTask.status]?.label ?? statusTask.status}</strong>
</div>
</div>
<Select
label="修改为"
onChange={(event) => setNextStatus(event.target.value)}
options={[
{ label: '未报备', value: 'pending' },
{ label: '资料待补充', value: 'waiting_material' },
{ label: '报备中', value: 'reporting' },
{ label: '报备通过', value: 'approved' },
{ label: '报备失败', value: 'failed' },
{ label: '放弃报备', value: 'abandoned' },
]}
value={nextStatus}
/>
<Textarea label="修改原因(选填)" onChange={(event) => setStatusReason(event.target.value)} placeholder="可填写供应商反馈或人工处理说明" rows={3} value={statusReason} />
</div>
) : null}
</Modal>
</section>
);
}
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import type { ClientSmsSignature } from '@/api/adminApi';
import { EnterpriseSignaturesTable } from './EnterpriseSignaturesTable';
@@ -12,6 +13,7 @@ const signature = {
auditStatus: 'approved',
createdAt: '2026-09-01T00:00:00.000Z',
updatedAt: '2026-09-01T01:00:00.000Z',
pendingReportDetailCount: 4,
tenant: { id: 'tenant-1', name: '深圳市聆界科技有限公司', status: 'active' },
application: { id: 'app-1', tenantId: 'tenant-1', name: '营销通知应用', status: 'active' },
carrierReportSummary: {
@@ -19,7 +21,9 @@ const signature = {
unicom: { status: 'reporting', approved: 2, total: 3 },
telecom: { status: 'abandoned', approved: 0, total: 3 },
},
drainageInfo: { links: [{ id: 'drainage-1', siteName: 'www.lisglo.com', url: 'www.lisglo.com', auditStatus: 'approved' }] },
drainageInfo: {
links: [{ id: 'drainage-1', siteName: 'www.lisglo.com', url: 'www.lisglo.com', auditStatus: 'approved' }],
},
drainageCarrierReportSummary: {
'drainage-1': {
mobile: { status: 'approved', approved: 3, total: 3 },
@@ -31,14 +35,33 @@ const signature = {
function renderTable(overrides: Partial<Parameters<typeof EnterpriseSignaturesTable>[0]> = {}) {
const props: Parameters<typeof EnterpriseSignaturesTable>[0] = {
appliedDrainageKeyword: '', currentPage: 1, expandedSignatureId: signature.id,
filteredSignatures: [signature], visibleSignatures: [signature], total: 1, totalPages: 1,
loadData: vi.fn().mockResolvedValue(undefined), setDeleteTarget: vi.fn(), setDrainageModal: vi.fn(),
setDrainageStatusTarget: vi.fn(), setExpandedSignatureId: vi.fn(), setPage: vi.fn(),
setReportStatusTarget: vi.fn(), setSignatureModal: vi.fn(), setSignatureSort: vi.fn(), signatureSort: 'asc',
appliedDrainageKeyword: '',
currentPage: 1,
expandedSignatureId: signature.id,
filteredSignatures: [signature],
visibleSignatures: [signature],
total: 1,
totalPages: 1,
loadData: vi.fn().mockResolvedValue(undefined),
setDeleteTarget: vi.fn(),
setDrainageModal: vi.fn(),
setDrainageStatusTarget: vi.fn(),
setExpandedSignatureId: vi.fn(),
setPage: vi.fn(),
setReportStatusTarget: vi.fn(),
setSignatureModal: vi.fn(),
setSignatureSort: vi.fn(),
signatureSort: 'asc',
...overrides,
};
return { ...render(<EnterpriseSignaturesTable {...props} />), props };
return {
...render(
<MemoryRouter>
<EnterpriseSignaturesTable {...props} />
</MemoryRouter>,
),
props,
};
}
describe('EnterpriseSignaturesTable dense presentation', () => {
@@ -56,6 +79,7 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
expect(screen.getAllByRole('button', { name: '报备状态' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '编辑' })).toHaveLength(2);
expect(screen.getAllByRole('button', { name: '删除' })).toHaveLength(2);
expect(screen.getByRole('button', { name: '4 条' })).toBeVisible();
});
it('requests a real descending sort from the signature column control', async () => {
@@ -64,7 +88,11 @@ describe('EnterpriseSignaturesTable dense presentation', () => {
await userEvent.click(screen.getByRole('button', { name: '签名降序' }));
expect(setSignatureSort).toHaveBeenCalledWith('desc');
expect(screen.getByRole('button', { name: '签名升序' }).querySelector('.lucide-triangle')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '签名降序' }).querySelector('.enterprise-signature-table__sort-triangle--down')).toBeInTheDocument();
expect(
screen
.getByRole('button', { name: '签名降序' })
.querySelector('.enterprise-signature-table__sort-triangle--down'),
).toBeInTheDocument();
expect(document.querySelector('.lucide-arrow-up, .lucide-arrow-down')).not.toBeInTheDocument();
});
});
@@ -1,8 +1,15 @@
import type { Dispatch, SetStateAction } from 'react';
import { ChevronDown, ChevronRight, Edit3, Plus, Triangle } from 'lucide-react';
import type { ClientSmsSignature } from '@/api/adminApi';
import { useNavigate } from 'react-router-dom';
import { Button, DeleteRiskAction, Pagination } from '@/components/ui';
import { AuditStatusTag, CarrierReportCount, formatSignatureName, readDrainagePayload, signatureCardVisual } from './signature.helpers';
import {
AuditStatusTag,
CarrierReportCount,
formatSignatureName,
readDrainagePayload,
signatureCardVisual,
} from './signature.helpers';
import type { DrainageInfo } from './signature.types';
type EnterpriseSignaturesTableProps = {
@@ -44,6 +51,7 @@ export function EnterpriseSignaturesTable({
totalPages,
visibleSignatures,
}: EnterpriseSignaturesTableProps) {
const navigate = useNavigate();
return (
<div className="signature-list admin-enterprise-signature-list">
<div className="enterprise-signature-table__head" role="row">
@@ -51,8 +59,33 @@ export function EnterpriseSignaturesTable({
<span className="enterprise-signature-table__sortable">
<span className="enterprise-signature-table__sort-actions">
<Button aria-pressed={signatureSort === 'asc'} icon={<Triangle aria-hidden="true" fill="currentColor" size={11} />} iconOnly onClick={() => setSignatureSort('asc')} size="sm" variant={signatureSort === 'asc' ? 'secondary' : 'ghost'}></Button>
<Button aria-pressed={signatureSort === 'desc'} icon={<Triangle aria-hidden="true" className="enterprise-signature-table__sort-triangle--down" fill="currentColor" size={11} />} iconOnly onClick={() => setSignatureSort('desc')} size="sm" variant={signatureSort === 'desc' ? 'secondary' : 'ghost'}></Button>
<Button
aria-pressed={signatureSort === 'asc'}
icon={<Triangle aria-hidden="true" fill="currentColor" size={11} />}
iconOnly
onClick={() => setSignatureSort('asc')}
size="sm"
variant={signatureSort === 'asc' ? 'secondary' : 'ghost'}
>
</Button>
<Button
aria-pressed={signatureSort === 'desc'}
icon={
<Triangle
aria-hidden="true"
className="enterprise-signature-table__sort-triangle--down"
fill="currentColor"
size={11}
/>
}
iconOnly
onClick={() => setSignatureSort('desc')}
size="sm"
variant={signatureSort === 'desc' ? 'secondary' : 'ghost'}
>
</Button>
</span>
</span>
<span></span>
@@ -62,33 +95,92 @@ export function EnterpriseSignaturesTable({
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{visibleSignatures.map((signature) => {
const payload = readDrainagePayload(signature);
const visibleDrainageLinks = appliedDrainageKeyword
? payload.links.filter((item) => `${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword))
? payload.links.filter((item) =>
`${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword),
)
: payload.links;
const cardVisual = signatureCardVisual(signature.auditStatus, signature.carrierReportSummary);
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
return (
<article aria-label={`签名总体状态:${cardVisual.label}`} className={`signature-card signature-card--${cardVisual.tone}`} key={signature.id} title={`总体状态:${cardVisual.label}`}>
<article
aria-label={`签名总体状态:${cardVisual.label}`}
className={`signature-card signature-card--${cardVisual.tone}`}
key={signature.id}
title={`总体状态:${cardVisual.label}`}
>
<div className="signature-summary">
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
<button
aria-label="展开签名"
onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)}
type="button"
>
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div className="enterprise-signature-table__signature" data-label="签名"><strong>{formatSignatureName(signature.name)}</strong></div>
<div data-label="企业"><span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span></div>
<div data-label="应用"><span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span></div>
<div data-label="审核状态"><AuditStatusTag status={signature.auditStatus} /></div>
<div data-label="移动"><CarrierReportCount summary={signature.carrierReportSummary?.mobile} /></div>
<div data-label="联通"><CarrierReportCount summary={signature.carrierReportSummary?.unicom} /></div>
<div data-label="电信"><CarrierReportCount summary={signature.carrierReportSummary?.telecom} /></div>
<div data-label="引流信息"><strong>{payload.links.length} </strong></div>
<div className="enterprise-signature-table__signature" data-label="签名">
<strong>{formatSignatureName(signature.name)}</strong>
</div>
<div data-label="企业">
<span className="signature-summary__regular-value">{signature.tenant?.name ?? signature.tenantId}</span>
</div>
<div data-label="应用">
<span className="signature-summary__regular-value">{signature.application?.name ?? '-'}</span>
</div>
<div data-label="审核状态">
<AuditStatusTag status={signature.auditStatus} />
</div>
<div data-label="移动">
<CarrierReportCount summary={signature.carrierReportSummary?.mobile} />
</div>
<div data-label="联通">
<CarrierReportCount summary={signature.carrierReportSummary?.unicom} />
</div>
<div data-label="电信">
<CarrierReportCount summary={signature.carrierReportSummary?.telecom} />
</div>
<div data-label="引流信息">
<strong>{payload.links.length} </strong>
</div>
<div data-label="待生成明细">
<Button
disabled={!signature.pendingReportDetailCount}
onClick={() =>
navigate(`/admin/report-tasks?signatureId=${encodeURIComponent(signature.id)}&scope=pending`)
}
size="sm"
variant="ghost"
>
{signature.pendingReportDetailCount ?? 0}
</Button>
</div>
<div className="signature-actions">
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost"></Button>
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost"></Button>
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" />
<Button
icon={<Edit3 size={16} />}
onClick={() => setReportStatusTarget(signature)}
size="sm"
variant="ghost"
>
</Button>
<Button
icon={<Edit3 size={16} />}
onClick={() => setSignatureModal(signature)}
size="sm"
variant="ghost"
>
</Button>
<DeleteRiskAction
onCompleted={() => void loadData()}
portal="admin"
targetId={signature.id}
targetType="signature"
/>
</div>
</div>
{expanded ? (
@@ -107,25 +199,61 @@ export function EnterpriseSignaturesTable({
{visibleDrainageLinks.map((item) => {
const summary = signature.drainageCarrierReportSummary?.[item.id];
return (
<div className="drainage-table__row" key={item.id}>
<span className="drainage-table__url" title={item.url}>{item.url}</span>
<AuditStatusTag status={item.auditStatus ?? 'pending'} />
<CarrierReportCount summary={summary?.mobile} />
<CarrierReportCount summary={summary?.unicom} />
<CarrierReportCount summary={summary?.telecom} />
<span className="drainage-row-actions">
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.url })} size="sm" variant="danger"></Button>
</span>
</div>
);})}
<div className="drainage-table__row" key={item.id}>
<span className="drainage-table__url" title={item.url}>
{item.url}
</span>
<AuditStatusTag status={item.auditStatus ?? 'pending'} />
<CarrierReportCount summary={summary?.mobile} />
<CarrierReportCount summary={summary?.unicom} />
<CarrierReportCount summary={summary?.telecom} />
<span className="drainage-row-actions">
<Button
disabled={item.auditStatus !== 'approved'}
onClick={() => setDrainageStatusTarget({ signature, item })}
size="sm"
variant="ghost"
>
</Button>
<Button
onClick={() => setDrainageModal({ signatureId: signature.id, item })}
size="sm"
variant="ghost"
>
</Button>
<Button
onClick={() =>
setDeleteTarget({
kind: 'drainage',
signatureId: signature.id,
id: item.id,
name: item.url,
})
}
size="sm"
variant="danger"
>
</Button>
</span>
</div>
);
})}
</div>
) : (
<p className="muted"></p>
)}
<div className="drainage-panel__footer">
<Button icon={<Plus size={16} />} onClick={() => setDrainageModal({ signatureId: signature.id })} size="sm" variant="ghost"></Button>
<Button
icon={<Plus size={16} />}
onClick={() => setDrainageModal({ signatureId: signature.id })}
size="sm"
variant="ghost"
>
</Button>
</div>
</div>
) : null}
+56 -18
View File
@@ -1,8 +1,4 @@
import {
useCallback,
useEffect,
useState,
} from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
Activity,
AlertTriangle,
@@ -39,10 +35,20 @@ import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/ses
import { AppShell } from '@/layouts/AppShell';
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
const EMPTY_PENDING_AUDITS = { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 };
const EMPTY_PENDING_AUDITS = {
enterpriseCertifications: 0,
smsAudits: 0,
templates: 0,
signatures: 0,
drainageInfos: 0,
};
export function AdminLayout() {
return <PortalSessionBoundary portal="admin">{(session) => <AdminAuthenticatedLayout session={session} />}</PortalSessionBoundary>;
return (
<PortalSessionBoundary portal="admin">
{(session) => <AdminAuthenticatedLayout session={session} />}
</PortalSessionBoundary>
);
}
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
@@ -53,17 +59,27 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
const loadPendingAuditCount = useCallback(() => {
const currentSession = readSession('admin');
if (!currentSession || currentSession.locked
|| Date.now() - getLastUserActivityAt() >= currentSession.idleTimeoutSeconds * 1000) {
if (
!currentSession ||
currentSession.locked ||
Date.now() - getLastUserActivityAt() >= currentSession.idleTimeoutSeconds * 1000
) {
return;
}
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
Promise.allSettled([adminApi.getPendingAudits(), adminApi.getSignatureRetirementUnreadCount(), adminApi.getSecurityNotificationSummary(), adminApi.getInfrastructureMonitoringNotificationSummary()])
Promise.allSettled([
adminApi.getPendingAudits(),
adminApi.getSignatureRetirementUnreadCount(),
adminApi.getSecurityNotificationSummary(),
adminApi.getInfrastructureMonitoringNotificationSummary(),
])
.then(([audits, retirement, security, infrastructure]) => {
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 });
setInfrastructureAlertSummary(infrastructure.status === 'fulfilled' ? infrastructure.value : { count: 0, criticalCount: 0 });
setInfrastructureAlertSummary(
infrastructure.status === 'fulfilled' ? infrastructure.value : { count: 0, criticalCount: 0 },
);
})
.catch(() => {
setPendingAudits(EMPTY_PENDING_AUDITS);
@@ -107,9 +123,30 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
userRole="平台管理员"
onSessionLockedChange={setSessionLocked}
alertNotifications={[
{ label: '签名清退预警', count: retirementUnreadCount, description: '今日未读且未抑制', to: '/admin/signature-retirement' },
{ label: '安全检测与封禁', count: securityAlertSummary.count, description: securityAlertSummary.criticalCount > 0 ? `${securityAlertSummary.criticalCount} 条严重告警待处置` : '待处置安全告警', to: '/admin/security-detection' },
{ label: '系统监控告警', count: infrastructureAlertSummary.count, description: infrastructureAlertSummary.criticalCount > 0 ? `${infrastructureAlertSummary.criticalCount} 条 Prometheus 严重告警` : 'Prometheus 活动告警', to: '/admin/system-monitoring#active-alerts' },
{
label: '签名清退预警',
count: retirementUnreadCount,
description: '今日未读且未抑制',
to: '/admin/signature-retirement',
},
{
label: '安全检测与封禁',
count: securityAlertSummary.count,
description:
securityAlertSummary.criticalCount > 0
? `${securityAlertSummary.criticalCount} 条严重告警待处置`
: '待处置安全告警',
to: '/admin/security-detection',
},
{
label: '系统监控告警',
count: infrastructureAlertSummary.count,
description:
infrastructureAlertSummary.criticalCount > 0
? `${infrastructureAlertSummary.criticalCount} 条 Prometheus 严重告警`
: 'Prometheus 活动告警',
to: '/admin/system-monitoring#active-alerts',
},
]}
auditNotifications={[
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
@@ -160,12 +197,13 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
],
},
{
title: '报备任务',
title: '报备工作台',
icon: ClipboardList,
items: [
{ label: '待生成报备批次', to: '/admin/report-materials', icon: FileSpreadsheet },
{ label: '报备明细', to: '/admin/report-tasks', icon: ClipboardList },
{ label: '报备记录', to: '/admin/report-records', icon: ListChecks },
{ label: '报备资料池', to: '/admin/report-materials', icon: FileSpreadsheet },
{ label: '报备批次', to: '/admin/report-batches', icon: Layers3 },
{ label: '通道报备明细', to: '/admin/report-tasks', icon: ClipboardList },
{ label: '状态记录', to: '/admin/report-records', icon: ListChecks },
],
},
{
+209 -114
View File
@@ -8,7 +8,7 @@ import { RouteLoadBoundary } from './RouteLoadBoundary';
function lazyNamed(loader: () => Promise<unknown>, exportName: string): LazyExoticComponent<ComponentType<any>> {
return lazy(async () => {
const loaded = await loader() as Record<string, ComponentType>;
const loaded = (await loader()) as Record<string, ComponentType>;
const component = loaded[exportName];
if (!component) throw new Error(`Lazy route export ${exportName} was not found`);
return { default: component };
@@ -16,53 +16,135 @@ function lazyNamed(loader: () => Promise<unknown>, exportName: string): LazyExot
}
const AdminAnalyticsPage = lazyNamed(() => import('@/apps/admin/AdminAnalyticsPage'), 'AdminAnalyticsPage');
const AdminChannelGroupFormPage = lazyNamed(() => import('@/apps/admin/AdminChannelGroupFormPage'), 'AdminChannelGroupFormPage');
const AdminChannelGroupFormPage = lazyNamed(
() => import('@/apps/admin/AdminChannelGroupFormPage'),
'AdminChannelGroupFormPage',
);
const AdminChannelGroupsPage = lazyNamed(() => import('@/apps/admin/AdminChannelGroupsPage'), 'AdminChannelGroupsPage');
const AdminChannelsPage = lazyNamed(() => import('@/apps/admin/AdminChannelsPage'), 'AdminChannelsPage');
const AdminChannelReportPage = lazyNamed(() => import('@/apps/admin/AdminChannelReportPage'), 'AdminChannelReportPage');
const AdminCustomerDetailPage = lazyNamed(() => import('@/apps/admin/AdminCustomerDetailPage'), 'AdminCustomerDetailPage');
const AdminCustomerDetailPage = lazyNamed(
() => import('@/apps/admin/AdminCustomerDetailPage'),
'AdminCustomerDetailPage',
);
const AdminCustomerFormPage = lazyNamed(() => import('@/apps/admin/AdminCustomerFormPage'), 'AdminCustomerFormPage');
const AdminCustomersPage = lazyNamed(() => import('@/apps/admin/AdminCustomersPage'), 'AdminCustomersPage');
const AdminDrainageFieldsPage = lazyNamed(() => import('@/apps/admin/AdminDrainageFieldsPage'), 'AdminDrainageFieldsPage');
const AdminDrainageDetectionRulesPage = lazyNamed(() => import('@/apps/admin/AdminDrainageDetectionRulesPage'), 'AdminDrainageDetectionRulesPage');
const AdminDownstreamDeliveriesPage = lazyNamed(() => import('@/apps/admin/AdminDownstreamDeliveriesPage'), 'AdminDownstreamDeliveriesPage');
const AdminDownstreamRecoveryStatusesPage = lazyNamed(() => import('@/apps/admin/AdminDownstreamRecoveryStatusesPage'), 'AdminDownstreamRecoveryStatusesPage');
const AdminEnterpriseApplicationsPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseApplicationsPage'), 'AdminEnterpriseApplicationsPage');
const AdminEnterpriseBlacklistPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseBlacklistPage'), 'AdminEnterpriseBlacklistPage');
const AdminEnterpriseSignaturesPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseSignaturesPage'), 'AdminEnterpriseSignaturesPage');
const AdminEnterpriseTemplatesPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseTemplatesPage'), 'AdminEnterpriseTemplatesPage');
const AdminGlobalBlacklistPage = lazyNamed(() => import('@/apps/admin/AdminGlobalBlacklistPage'), 'AdminGlobalBlacklistPage');
const AdminGatewaySubmitExceptionsPage = lazyNamed(() => import('@/apps/admin/AdminGatewaySubmitExceptionsPage'), 'AdminGatewaySubmitExceptionsPage');
const AdminDrainageFieldsPage = lazyNamed(
() => import('@/apps/admin/AdminDrainageFieldsPage'),
'AdminDrainageFieldsPage',
);
const AdminDrainageDetectionRulesPage = lazyNamed(
() => import('@/apps/admin/AdminDrainageDetectionRulesPage'),
'AdminDrainageDetectionRulesPage',
);
const AdminDownstreamDeliveriesPage = lazyNamed(
() => import('@/apps/admin/AdminDownstreamDeliveriesPage'),
'AdminDownstreamDeliveriesPage',
);
const AdminDownstreamRecoveryStatusesPage = lazyNamed(
() => import('@/apps/admin/AdminDownstreamRecoveryStatusesPage'),
'AdminDownstreamRecoveryStatusesPage',
);
const AdminEnterpriseApplicationsPage = lazyNamed(
() => import('@/apps/admin/AdminEnterpriseApplicationsPage'),
'AdminEnterpriseApplicationsPage',
);
const AdminEnterpriseBlacklistPage = lazyNamed(
() => import('@/apps/admin/AdminEnterpriseBlacklistPage'),
'AdminEnterpriseBlacklistPage',
);
const AdminEnterpriseSignaturesPage = lazyNamed(
() => import('@/apps/admin/AdminEnterpriseSignaturesPage'),
'AdminEnterpriseSignaturesPage',
);
const AdminEnterpriseTemplatesPage = lazyNamed(
() => import('@/apps/admin/AdminEnterpriseTemplatesPage'),
'AdminEnterpriseTemplatesPage',
);
const AdminGlobalBlacklistPage = lazyNamed(
() => import('@/apps/admin/AdminGlobalBlacklistPage'),
'AdminGlobalBlacklistPage',
);
const AdminGatewaySubmitExceptionsPage = lazyNamed(
() => import('@/apps/admin/AdminGatewaySubmitExceptionsPage'),
'AdminGatewaySubmitExceptionsPage',
);
const AdminHome = lazyNamed(() => import('@/apps/admin/AdminHome'), 'AdminHome');
const AdminMonitorPage = lazyNamed(() => import('@/apps/admin/AdminMonitorPage'), 'AdminMonitorPage');
const AdminPhoneSegmentsPage = lazyNamed(() => import('@/apps/admin/AdminPhoneSegmentsPage'), 'AdminPhoneSegmentsPage');
const AdminRechargeRecordsPage = lazyNamed(() => import('@/apps/admin/AdminRechargeRecordsPage'), 'AdminRechargeRecordsPage');
const AdminReconciliationReportsPage = lazyNamed(() => import('@/apps/admin/AdminReconciliationReportsPage'), 'AdminReconciliationReportsPage');
const AdminRechargeRecordsPage = lazyNamed(
() => import('@/apps/admin/AdminRechargeRecordsPage'),
'AdminRechargeRecordsPage',
);
const AdminReconciliationReportsPage = lazyNamed(
() => import('@/apps/admin/AdminReconciliationReportsPage'),
'AdminReconciliationReportsPage',
);
const AdminProfitReportsPage = lazyNamed(() => import('@/apps/admin/AdminProfitReportsPage'), 'AdminProfitReportsPage');
const AdminQualityReportsPage = lazyNamed(() => import('@/apps/admin/AdminQualityReportsPage'), 'AdminQualityReportsPage');
const AdminQualityReportsPage = lazyNamed(
() => import('@/apps/admin/AdminQualityReportsPage'),
'AdminQualityReportsPage',
);
const AdminReportRecordsPage = lazyNamed(() => import('@/apps/admin/AdminReportRecordsPage'), 'AdminReportRecordsPage');
const AdminReportTasksPage = lazyNamed(() => import('@/apps/admin/AdminReportTasksPage'), 'AdminReportTasksPage');
const AdminReportMaterialsPage = lazyNamed(() => import('@/apps/admin/AdminReportMaterialsPage'), 'AdminReportMaterialsPage');
const AdminSensitiveWordsPage = lazyNamed(() => import('@/apps/admin/AdminSensitiveWordsPage'), 'AdminSensitiveWordsPage');
const AdminReportMaterialsPage = lazyNamed(
() => import('@/apps/admin/AdminReportMaterialsPage'),
'AdminReportMaterialsPage',
);
const AdminReportBatchesPage = lazyNamed(() => import('@/apps/admin/AdminReportBatchesPage'), 'AdminReportBatchesPage');
const AdminSensitiveWordsPage = lazyNamed(
() => import('@/apps/admin/AdminSensitiveWordsPage'),
'AdminSensitiveWordsPage',
);
const AdminSmsAuditPage = lazyNamed(() => import('@/apps/admin/AdminSmsAuditPage'), 'AdminSmsAuditPage');
const AdminRiskRulesPage = lazyNamed(() => import('@/apps/admin/AdminRiskRulesPage'), 'AdminRiskRulesPage');
const AdminSmsApplicationFormPage = lazyNamed(() => import('@/apps/admin/AdminSmsApplicationFormPage'), 'AdminSmsApplicationFormPage');
const AdminSmsApplicationFormPage = lazyNamed(
() => import('@/apps/admin/AdminSmsApplicationFormPage'),
'AdminSmsApplicationFormPage',
);
const AdminSmsRecordsPage = lazyNamed(() => import('@/apps/admin/AdminSmsRecordsPage'), 'AdminSmsRecordsPage');
const AdminSmsTaskProgressPage = lazyNamed(() => import('@/apps/admin/AdminSmsTaskProgressPage'), 'AdminSmsTaskProgressPage');
const AdminSmsUplinkRecordsPage = lazyNamed(() => import('@/apps/admin/AdminSmsUplinkRecordsPage'), 'AdminSmsUplinkRecordsPage');
const AdminSignatureAuditPage = lazyNamed(() => import('@/apps/admin/AdminSignatureAuditPage'), 'AdminSignatureAuditPage');
const AdminSignatureRetirementPage = lazyNamed(() => import('@/apps/admin/AdminSignatureRetirementPage'), 'AdminSignatureRetirementPage');
const AdminSmsTaskProgressPage = lazyNamed(
() => import('@/apps/admin/AdminSmsTaskProgressPage'),
'AdminSmsTaskProgressPage',
);
const AdminSmsUplinkRecordsPage = lazyNamed(
() => import('@/apps/admin/AdminSmsUplinkRecordsPage'),
'AdminSmsUplinkRecordsPage',
);
const AdminSignatureAuditPage = lazyNamed(
() => import('@/apps/admin/AdminSignatureAuditPage'),
'AdminSignatureAuditPage',
);
const AdminSignatureRetirementPage = lazyNamed(
() => import('@/apps/admin/AdminSignatureRetirementPage'),
'AdminSignatureRetirementPage',
);
const AdminDrainageAuditPage = lazyNamed(() => import('@/apps/admin/AdminDrainageAuditPage'), 'AdminDrainageAuditPage');
const AdminSystemLogsPage = lazyNamed(() => import('@/apps/admin/AdminSystemLogsPage'), 'AdminSystemLogsPage');
const AdminSystemMonitoringPage = lazyNamed(() => import('@/apps/admin/system-monitoring/AdminSystemMonitoringPage'), 'AdminSystemMonitoringPage');
const AdminSecurityDetectionPage = lazyNamed(() => import('@/apps/admin/security-detection/AdminSecurityDetectionPage'), 'AdminSecurityDetectionPage');
const AdminSystemMonitoringPage = lazyNamed(
() => import('@/apps/admin/system-monitoring/AdminSystemMonitoringPage'),
'AdminSystemMonitoringPage',
);
const AdminSecurityDetectionPage = lazyNamed(
() => import('@/apps/admin/security-detection/AdminSecurityDetectionPage'),
'AdminSecurityDetectionPage',
);
const AdminTemplateAuditPage = lazyNamed(() => import('@/apps/admin/AdminTemplateAuditPage'), 'AdminTemplateAuditPage');
const AdminUsersPage = lazyNamed(() => import('@/apps/admin/AdminUsersPage'), 'AdminUsersPage');
const AdminEnterpriseAuditPage = lazyNamed(() => import('@/apps/admin/AdminEnterpriseAuditPage'), 'AdminEnterpriseAuditPage');
const ClientApplicationsPage = lazyNamed(() => import('@/apps/client/ClientApplicationsPage'), 'ClientApplicationsPage');
const AdminEnterpriseAuditPage = lazyNamed(
() => import('@/apps/admin/AdminEnterpriseAuditPage'),
'AdminEnterpriseAuditPage',
);
const ClientApplicationsPage = lazyNamed(
() => import('@/apps/client/ClientApplicationsPage'),
'ClientApplicationsPage',
);
const ClientBatchTasksPage = lazyNamed(() => import('@/apps/client/ClientBatchTasksPage'), 'ClientBatchTasksPage');
const ClientBillingPage = lazyNamed(() => import('@/apps/client/ClientBillingPage'), 'ClientBillingPage');
const ClientEnterpriseAuthPage = lazyNamed(() => import('@/apps/client/ClientEnterpriseAuthPage'), 'ClientEnterpriseAuthPage');
const ClientEnterpriseAuthPage = lazyNamed(
() => import('@/apps/client/ClientEnterpriseAuthPage'),
'ClientEnterpriseAuthPage',
);
const ClientHome = lazyNamed(() => import('@/apps/client/ClientHome'), 'ClientHome');
const ClientHttpApiPage = lazyNamed(() => import('@/apps/client/ClientHttpApiPage'), 'ClientHttpApiPage');
const ClientSendDetailPage = lazyNamed(() => import('@/apps/client/ClientSendDetailPage'), 'ClientSendDetailPage');
@@ -70,98 +152,111 @@ const ClientSendPage = lazyNamed(() => import('@/apps/client/ClientSendPage'), '
const ClientSignaturesPage = lazyNamed(() => import('@/apps/client/ClientSignaturesPage'), 'ClientSignaturesPage');
const ClientSystemLogsPage = lazyNamed(() => import('@/apps/client/ClientSystemLogsPage'), 'ClientSystemLogsPage');
const ClientTemplatesPage = lazyNamed(() => import('@/apps/client/ClientTemplatesPage'), 'ClientTemplatesPage');
const ClientUplinkMessagesPage = lazyNamed(() => import('@/apps/client/ClientUplinkMessagesPage'), 'ClientUplinkMessagesPage');
const ClientUplinkMessagesPage = lazyNamed(
() => import('@/apps/client/ClientUplinkMessagesPage'),
'ClientUplinkMessagesPage',
);
const ClientUsersPage = lazyNamed(() => import('@/apps/client/ClientUsersPage'), 'ClientUsersPage');
export function AppRoutes() {
return (
<RouteLoadBoundary>
<Suspense fallback={<div className="page-stack"><div className="surface ui-table__empty">...</div></div>}>
<Suspense
fallback={
<div className="page-stack">
<div className="surface ui-table__empty">...</div>
</div>
}
>
<Routes>
<Route path="/" element={<Navigate to="/client" replace />} />
<Route path="/client/login" element={<LoginPage portal="client" />} />
<Route path="/admin/login" element={<LoginPage portal="admin" />} />
<Route path="/client" element={<ClientLayout />}>
<Route index element={<ClientHome />} />
<Route path="send" element={<ClientSendPage />} />
<Route path="batch-tasks" element={<ClientBatchTasksPage />} />
<Route path="send-detail" element={<ClientSendDetailPage />} />
<Route path="uplink-messages" element={<ClientUplinkMessagesPage />} />
<Route path="applications" element={<ClientApplicationsPage />} />
<Route path="http-api" element={<ClientHttpApiPage />} />
<Route path="templates" element={<ClientTemplatesPage />} />
<Route path="signatures" element={<ClientSignaturesPage />} />
<Route path="mms-signatures" element={<PagePlaceholder />} />
<Route path="mms-templates" element={<PagePlaceholder />} />
<Route path="mms-send" element={<PagePlaceholder />} />
<Route path="mms-batch-tasks" element={<PagePlaceholder />} />
<Route path="mms-send-detail" element={<PagePlaceholder />} />
<Route path="mms-uplink-messages" element={<PagePlaceholder />} />
<Route path="billing" element={<ClientBillingPage />} />
<Route path="enterprise-auth" element={<ClientEnterpriseAuthPage />} />
<Route path="users" element={<ClientUsersPage />} />
<Route path="system-logs" element={<ClientSystemLogsPage />} />
<Route path="*" element={<PagePlaceholder />} />
</Route>
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminHome />} />
<Route path="monitor" element={<AdminMonitorPage />} />
<Route path="gateway-submit-exceptions" element={<AdminGatewaySubmitExceptionsPage />} />
<Route path="analytics" element={<AdminAnalyticsPage />} />
<Route path="customers" element={<AdminCustomersPage />} />
<Route path="customers/new" element={<AdminCustomerFormPage />} />
<Route path="customers/:enterpriseId" element={<AdminCustomerDetailPage />} />
<Route path="customers/:enterpriseId/edit" element={<AdminCustomerFormPage />} />
<Route path="customers/:enterpriseId/sms-apps/new" element={<AdminSmsApplicationFormPage />} />
<Route path="customers/:enterpriseId/sms-apps/:appId/edit" element={<AdminSmsApplicationFormPage />} />
<Route path="customers/:enterpriseId/mms-apps/new" element={<PagePlaceholder />} />
<Route path="customers/:enterpriseId/mms-apps/:appId/edit" element={<PagePlaceholder />} />
<Route path="customer-enterprises" element={<AdminCustomersPage basePath="/admin/customer-enterprises" />} />
<Route path="customer-enterprises/new" element={<AdminCustomerFormPage />} />
<Route path="customer-enterprises/:enterpriseId" element={<AdminCustomerDetailPage />} />
<Route path="customer-enterprises/:enterpriseId/edit" element={<AdminCustomerFormPage />} />
<Route path="enterprise-applications" element={<AdminEnterpriseApplicationsPage />} />
<Route path="enterprise-signatures" element={<AdminEnterpriseSignaturesPage />} />
<Route path="enterprise-templates" element={<AdminEnterpriseTemplatesPage />} />
<Route path="templates" element={<AdminTemplateAuditPage />} />
<Route path="signatures" element={<AdminSignatureAuditPage />} />
<Route path="drainage-audits" element={<AdminDrainageAuditPage />} />
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
<Route path="sms-audit" element={<AdminSmsAuditPage />} />
<Route path="risk-rules" element={<AdminRiskRulesPage />} />
<Route path="signature-retirement" element={<AdminSignatureRetirementPage />} />
<Route path="report-tasks" element={<AdminReportTasksPage />} />
<Route path="report-materials" element={<AdminReportMaterialsPage />} />
<Route path="report-records" element={<AdminReportRecordsPage />} />
<Route path="sms-task-progress" element={<AdminSmsTaskProgressPage />} />
<Route path="mms-task-progress" element={<PagePlaceholder />} />
<Route path="sms-records" element={<AdminSmsRecordsPage />} />
<Route path="mms-records" element={<PagePlaceholder />} />
<Route path="sms-uplink-records" element={<AdminSmsUplinkRecordsPage />} />
<Route path="downstream-deliveries" element={<AdminDownstreamDeliveriesPage />} />
<Route path="downstream-recovery-statuses" element={<AdminDownstreamRecoveryStatusesPage />} />
<Route path="recharge-records" element={<AdminRechargeRecordsPage />} />
<Route path="reconciliation-reports" element={<AdminReconciliationReportsPage />} />
<Route path="profit-reports" element={<AdminProfitReportsPage />} />
<Route path="quality-reports" element={<AdminQualityReportsPage />} />
<Route path="channels" element={<AdminChannelsPage />} />
<Route path="channels/:channelId/reports" element={<AdminChannelReportPage />} />
<Route path="channel-groups" element={<AdminChannelGroupsPage />} />
<Route path="channel-groups/new" element={<AdminChannelGroupFormPage />} />
<Route path="channel-groups/:groupId/edit" element={<AdminChannelGroupFormPage />} />
<Route path="mms-channels" element={<PagePlaceholder />} />
<Route path="enterprise-blacklist" element={<AdminEnterpriseBlacklistPage />} />
<Route path="global-blacklist" element={<AdminGlobalBlacklistPage />} />
<Route path="sensitive-words" element={<AdminSensitiveWordsPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="phone-segments" element={<AdminPhoneSegmentsPage />} />
<Route path="drainage-fields" element={<AdminDrainageFieldsPage />} />
<Route path="drainage-detection-rules" element={<AdminDrainageDetectionRulesPage />} />
<Route path="system-logs" element={<AdminSystemLogsPage />} />
<Route path="system-monitoring" element={<AdminSystemMonitoringPage />} />
<Route path="security-detection" element={<AdminSecurityDetectionPage />} />
<Route path="*" element={<PagePlaceholder />} />
</Route>
<Route path="/" element={<Navigate to="/client" replace />} />
<Route path="/client/login" element={<LoginPage portal="client" />} />
<Route path="/admin/login" element={<LoginPage portal="admin" />} />
<Route path="/client" element={<ClientLayout />}>
<Route index element={<ClientHome />} />
<Route path="send" element={<ClientSendPage />} />
<Route path="batch-tasks" element={<ClientBatchTasksPage />} />
<Route path="send-detail" element={<ClientSendDetailPage />} />
<Route path="uplink-messages" element={<ClientUplinkMessagesPage />} />
<Route path="applications" element={<ClientApplicationsPage />} />
<Route path="http-api" element={<ClientHttpApiPage />} />
<Route path="templates" element={<ClientTemplatesPage />} />
<Route path="signatures" element={<ClientSignaturesPage />} />
<Route path="mms-signatures" element={<PagePlaceholder />} />
<Route path="mms-templates" element={<PagePlaceholder />} />
<Route path="mms-send" element={<PagePlaceholder />} />
<Route path="mms-batch-tasks" element={<PagePlaceholder />} />
<Route path="mms-send-detail" element={<PagePlaceholder />} />
<Route path="mms-uplink-messages" element={<PagePlaceholder />} />
<Route path="billing" element={<ClientBillingPage />} />
<Route path="enterprise-auth" element={<ClientEnterpriseAuthPage />} />
<Route path="users" element={<ClientUsersPage />} />
<Route path="system-logs" element={<ClientSystemLogsPage />} />
<Route path="*" element={<PagePlaceholder />} />
</Route>
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<AdminHome />} />
<Route path="monitor" element={<AdminMonitorPage />} />
<Route path="gateway-submit-exceptions" element={<AdminGatewaySubmitExceptionsPage />} />
<Route path="analytics" element={<AdminAnalyticsPage />} />
<Route path="customers" element={<AdminCustomersPage />} />
<Route path="customers/new" element={<AdminCustomerFormPage />} />
<Route path="customers/:enterpriseId" element={<AdminCustomerDetailPage />} />
<Route path="customers/:enterpriseId/edit" element={<AdminCustomerFormPage />} />
<Route path="customers/:enterpriseId/sms-apps/new" element={<AdminSmsApplicationFormPage />} />
<Route path="customers/:enterpriseId/sms-apps/:appId/edit" element={<AdminSmsApplicationFormPage />} />
<Route path="customers/:enterpriseId/mms-apps/new" element={<PagePlaceholder />} />
<Route path="customers/:enterpriseId/mms-apps/:appId/edit" element={<PagePlaceholder />} />
<Route
path="customer-enterprises"
element={<AdminCustomersPage basePath="/admin/customer-enterprises" />}
/>
<Route path="customer-enterprises/new" element={<AdminCustomerFormPage />} />
<Route path="customer-enterprises/:enterpriseId" element={<AdminCustomerDetailPage />} />
<Route path="customer-enterprises/:enterpriseId/edit" element={<AdminCustomerFormPage />} />
<Route path="enterprise-applications" element={<AdminEnterpriseApplicationsPage />} />
<Route path="enterprise-signatures" element={<AdminEnterpriseSignaturesPage />} />
<Route path="enterprise-templates" element={<AdminEnterpriseTemplatesPage />} />
<Route path="templates" element={<AdminTemplateAuditPage />} />
<Route path="signatures" element={<AdminSignatureAuditPage />} />
<Route path="drainage-audits" element={<AdminDrainageAuditPage />} />
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
<Route path="sms-audit" element={<AdminSmsAuditPage />} />
<Route path="risk-rules" element={<AdminRiskRulesPage />} />
<Route path="signature-retirement" element={<AdminSignatureRetirementPage />} />
<Route path="report-tasks" element={<AdminReportTasksPage />} />
<Route path="report-materials" element={<AdminReportMaterialsPage />} />
<Route path="report-batches" element={<AdminReportBatchesPage />} />
<Route path="report-records" element={<AdminReportRecordsPage />} />
<Route path="sms-task-progress" element={<AdminSmsTaskProgressPage />} />
<Route path="mms-task-progress" element={<PagePlaceholder />} />
<Route path="sms-records" element={<AdminSmsRecordsPage />} />
<Route path="mms-records" element={<PagePlaceholder />} />
<Route path="sms-uplink-records" element={<AdminSmsUplinkRecordsPage />} />
<Route path="downstream-deliveries" element={<AdminDownstreamDeliveriesPage />} />
<Route path="downstream-recovery-statuses" element={<AdminDownstreamRecoveryStatusesPage />} />
<Route path="recharge-records" element={<AdminRechargeRecordsPage />} />
<Route path="reconciliation-reports" element={<AdminReconciliationReportsPage />} />
<Route path="profit-reports" element={<AdminProfitReportsPage />} />
<Route path="quality-reports" element={<AdminQualityReportsPage />} />
<Route path="channels" element={<AdminChannelsPage />} />
<Route path="channels/:channelId/reports" element={<AdminChannelReportPage />} />
<Route path="channel-groups" element={<AdminChannelGroupsPage />} />
<Route path="channel-groups/new" element={<AdminChannelGroupFormPage />} />
<Route path="channel-groups/:groupId/edit" element={<AdminChannelGroupFormPage />} />
<Route path="mms-channels" element={<PagePlaceholder />} />
<Route path="enterprise-blacklist" element={<AdminEnterpriseBlacklistPage />} />
<Route path="global-blacklist" element={<AdminGlobalBlacklistPage />} />
<Route path="sensitive-words" element={<AdminSensitiveWordsPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="phone-segments" element={<AdminPhoneSegmentsPage />} />
<Route path="drainage-fields" element={<AdminDrainageFieldsPage />} />
<Route path="drainage-detection-rules" element={<AdminDrainageDetectionRulesPage />} />
<Route path="system-logs" element={<AdminSystemLogsPage />} />
<Route path="system-monitoring" element={<AdminSystemMonitoringPage />} />
<Route path="security-detection" element={<AdminSecurityDetectionPage />} />
<Route path="*" element={<PagePlaceholder />} />
</Route>
</Routes>
</Suspense>
</RouteLoadBoundary>
+58 -7
View File
@@ -2007,9 +2007,9 @@
.admin-enterprise-signature-list .signature-summary {
gap: var(--space-2);
grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) 224px;
grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) minmax(92px, 0.5fr) 224px;
min-height: 64px;
min-width: 1160px;
min-width: 1260px;
padding-inline: var(--space-3);
}
@@ -2057,9 +2057,9 @@
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
gap: var(--space-2);
grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) 224px;
grid-template-columns: 22px minmax(132px, 0.9fr) minmax(168px, 1.1fr) minmax(120px, 0.8fr) minmax(82px, 0.55fr) repeat(3, minmax(64px, 0.45fr)) minmax(68px, 0.45fr) minmax(92px, 0.5fr) 224px;
min-height: 46px;
min-width: 1160px;
min-width: 1260px;
padding-inline: var(--space-3);
}
@@ -5526,7 +5526,7 @@
align-items: end;
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(240px, 1fr) minmax(200px, 0.8fr) minmax(360px, 1.4fr);
grid-template-columns: minmax(220px, 1.3fr) repeat(2, minmax(150px, 0.75fr)) repeat(2, minmax(138px, 0.65fr));
}
.channel-report-filter-footer {
@@ -5544,14 +5544,65 @@
}
.channel-report-table {
overflow: hidden;
overflow-x: auto;
padding: 0;
}
.channel-report-table__head,
.channel-report-row {
display: grid;
grid-template-columns: 34px minmax(220px, 1.2fr) 112px 120px 120px 130px minmax(280px, 1.35fr) 132px;
grid-template-columns: 34px minmax(220px, 1.2fr) 112px 120px 120px 130px minmax(280px, 1.35fr) 300px;
min-width: 1340px;
}
.report-material-detail-list {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.report-material-detail-list > div {
align-items: start;
border-bottom: 1px solid var(--color-border);
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(180px, 0.7fr) minmax(0, 1.3fr);
padding: var(--space-4);
}
.report-material-detail-list > div:last-child { border-bottom: 0; }
.report-material-detail-list > div > span { color: var(--color-text-muted); }
.report-material-detail-list > div > strong { overflow-wrap: anywhere; }
.report-material-detail-list > div.is-missing { background: var(--color-danger-soft); }
.report-batch-toolbar {
align-items: end;
background: var(--color-surface-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
display: grid;
gap: var(--space-3);
grid-template-columns: minmax(180px, 1fr) minmax(180px, 0.6fr) minmax(220px, 1fr) auto;
padding: var(--space-4);
}
.report-material-summary {
display: grid;
gap: var(--space-1);
min-width: 260px;
}
.report-material-summary span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
white-space: normal;
}
@media (max-width: 760px) {
.report-batch-toolbar,
.report-material-detail-list > div {
grid-template-columns: minmax(0, 1fr);
}
}
.channel-report-table__head {