feat: build reporting workbench workflow
This commit is contained in:
@@ -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)),
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user