fix: harden real backend admin workflows and ui
This commit is contained in:
+181
-8
@@ -89,6 +89,17 @@ export type TenantOption = {
|
||||
name: string;
|
||||
code: string;
|
||||
status: string;
|
||||
enterpriseProfile?: {
|
||||
creditCode?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
address?: string;
|
||||
contactName?: string;
|
||||
contactIdCard?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
photoFileObjectId?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type CaptchaResponse = {
|
||||
@@ -184,6 +195,13 @@ export type ClientSmsApplication = {
|
||||
scene?: string | null;
|
||||
customerUnitPrice?: number | null;
|
||||
status: string;
|
||||
dailyLimit?: number | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
sentToday?: number;
|
||||
deliveryRate?: number;
|
||||
cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive';
|
||||
cmppConnections?: CmppConnectionState[];
|
||||
};
|
||||
|
||||
export type ClientSmsSignature = {
|
||||
@@ -192,11 +210,14 @@ export type ClientSmsSignature = {
|
||||
applicationId?: string | null;
|
||||
name: string;
|
||||
purpose?: string | null;
|
||||
drainageInfo?: Record<string, unknown> | null;
|
||||
auditStatus: string;
|
||||
rejectReason?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
materials?: Array<Record<string, unknown>>;
|
||||
tenant?: TenantOption;
|
||||
application?: ClientSmsApplication | null;
|
||||
};
|
||||
|
||||
export type ClientSmsTemplate = {
|
||||
@@ -214,6 +235,7 @@ export type ClientSmsTemplate = {
|
||||
variables?: Array<{ name: string; example?: string | null; required?: boolean }>;
|
||||
application?: { id: string; name: string };
|
||||
signature?: { id: string; name: string };
|
||||
tenant?: TenantOption;
|
||||
};
|
||||
|
||||
export type SmsBatchTask = {
|
||||
@@ -230,12 +252,31 @@ export type SmsBatchTask = {
|
||||
reviewReason?: string | null;
|
||||
rejectReason?: string | null;
|
||||
progressTotal: number;
|
||||
progressSent: number;
|
||||
progressDelivered: number;
|
||||
progressFailed: number;
|
||||
progressSent?: number;
|
||||
progressDelivered?: number;
|
||||
progressFailed?: number;
|
||||
submittedTotal?: number;
|
||||
successTotal?: number;
|
||||
failedTotal?: number;
|
||||
unknownTotal?: number;
|
||||
timeoutTotal?: number;
|
||||
scheduledAt?: string | null;
|
||||
canceledAt?: string | null;
|
||||
createdAt: string;
|
||||
tenant?: TenantOption;
|
||||
application?: { id: string; name: string };
|
||||
template?: { id: string; name: string; content: string; billingUnits?: number };
|
||||
messages?: SmsMessageRecord[];
|
||||
};
|
||||
|
||||
export type ImportPreviewResponse = {
|
||||
fileName?: string;
|
||||
encoding: string;
|
||||
totalRows: number;
|
||||
validCount: number;
|
||||
errorCount: number;
|
||||
phones: string[];
|
||||
errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }>;
|
||||
};
|
||||
|
||||
export type SmsMessageRecord = {
|
||||
@@ -252,7 +293,30 @@ export type SmsMessageRecord = {
|
||||
status: string;
|
||||
errorMessage?: string | null;
|
||||
queuedAt: string;
|
||||
submittedAt?: string | null;
|
||||
deliveredAt?: string | null;
|
||||
receiptStatus?: string | null;
|
||||
submitStatus?: string | null;
|
||||
channel?: AdminChannel | null;
|
||||
tenant?: TenantOption | null;
|
||||
application?: { id: string; name: string };
|
||||
submitRecords?: Array<Record<string, unknown>>;
|
||||
receiptRecords?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export type SmsUplinkMessage = {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
channelId: string;
|
||||
messageId?: string | null;
|
||||
sequenceId?: number | null;
|
||||
phoneNumber: string;
|
||||
destId: string;
|
||||
content: string;
|
||||
receivedAt: string;
|
||||
createdAt: string;
|
||||
tenant?: TenantOption | null;
|
||||
channel?: AdminChannel | null;
|
||||
};
|
||||
|
||||
export type DictionaryItem = Record<string, unknown> & {
|
||||
@@ -269,7 +333,19 @@ export type ChannelGroup = DictionaryItem & {
|
||||
description?: string | null;
|
||||
retryEnabled?: boolean;
|
||||
retryTimeLimitHours?: number;
|
||||
items?: Array<Record<string, unknown>>;
|
||||
items?: ChannelGroupItem[];
|
||||
};
|
||||
|
||||
export type ChannelGroupItem = DictionaryItem & {
|
||||
groupId: string;
|
||||
channelId: string;
|
||||
carrier?: 'mobile' | 'unicom' | 'telecom' | null;
|
||||
province?: string | null;
|
||||
priority: number;
|
||||
weight?: number;
|
||||
isBackup?: boolean;
|
||||
rateLimitPerSecond?: number | null;
|
||||
channel?: AdminChannel;
|
||||
};
|
||||
|
||||
export type ChannelReportField = DictionaryItem & {
|
||||
@@ -374,6 +450,10 @@ export type EnterpriseApplication = {
|
||||
scene?: string | null;
|
||||
status: string;
|
||||
dailyLimit?: number | null;
|
||||
customerUnitPrice?: number | null;
|
||||
maxPhonesPerTask?: number | null;
|
||||
templateMismatchMode?: string | null;
|
||||
ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>;
|
||||
tenant?: TenantOption;
|
||||
sentToday?: number;
|
||||
deliveryRate?: number;
|
||||
@@ -438,9 +518,9 @@ export const adminApi = {
|
||||
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
||||
getTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`),
|
||||
createTenant: (body: { name: string; code: string; status?: string }) =>
|
||||
createTenant: (body: { name: string; code: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) =>
|
||||
request<TenantOption>('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateTenant: (id: string, body: { name?: string; code?: string; status?: string }) =>
|
||||
updateTenant: (id: string, body: { name?: string; code?: string; status?: string; creditCode?: string; province?: string; city?: string; address?: string; contactName?: string; contactIdCard?: string; contactPhone?: string; contactEmail?: string; photoFileObjectId?: string }) =>
|
||||
request<TenantOption>(`/admin/tenants/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeTenantStatus: (id: string, status: string) =>
|
||||
request<TenantOption>(`/admin/tenants/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
|
||||
@@ -463,8 +543,12 @@ export const adminApi = {
|
||||
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string } = {}) =>
|
||||
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
||||
getEnterpriseApplication: (id: string) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
|
||||
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
|
||||
request<EnterpriseApplication>('/client/applications', { method: 'POST', tenantId: body.tenantId, body: JSON.stringify(body) }),
|
||||
request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; maxPhonesPerTask?: number; templateMismatchMode?: string; ipAllowlist?: string[] }) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeApplicationStatus: (id: string, status: string, reason?: string) =>
|
||||
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
|
||||
method: 'POST',
|
||||
@@ -482,6 +566,8 @@ export const adminApi = {
|
||||
listChannels: () => request<AdminChannel[]>('/admin/channels'),
|
||||
createChannel: (body: Partial<AdminChannel> & { passwordCipher?: string }) =>
|
||||
request<AdminChannel>('/admin/channels', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateChannel: (id: string, body: Partial<AdminChannel> & { passwordCipher?: string }) =>
|
||||
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),
|
||||
@@ -509,8 +595,20 @@ export const adminApi = {
|
||||
}),
|
||||
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string } = {}) =>
|
||||
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
|
||||
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
|
||||
request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateEnterpriseSignature: (id: string, body: { applicationId?: string | null; name?: string; purpose?: string; auditStatus?: string; drainageInfo?: Record<string, unknown> }) =>
|
||||
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) =>
|
||||
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
|
||||
listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string } = {}) =>
|
||||
request<ClientSmsTemplate[]>(withQuery('/admin/enterprise-templates', query)),
|
||||
createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) =>
|
||||
request<ClientSmsTemplate>('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateEnterpriseTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) =>
|
||||
request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeEnterpriseTemplateStatus: (id: string, status: string, reason?: string) =>
|
||||
request<ClientSmsTemplate>(`/admin/enterprise-templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
|
||||
listEnterpriseCertifications: (query: { keyword?: string; status?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (query.keyword) params.set('keyword', query.keyword);
|
||||
@@ -530,10 +628,15 @@ export const adminApi = {
|
||||
listChannelGroups: () => request<ChannelGroup[]>('/admin/channel-groups'),
|
||||
createChannelGroup: (body: { code: string; name: string; carrier: 'mobile' | 'unicom' | 'telecom'; description?: string; status?: string; retryEnabled?: boolean; retryTimeLimitHours?: 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; items?: Array<Record<string, unknown>> }) =>
|
||||
request<ChannelGroup>(`/admin/channel-groups/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
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) }),
|
||||
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) }),
|
||||
@@ -542,11 +645,19 @@ export const adminApi = {
|
||||
request<ReportTask>('/admin/report-tasks/generate', { 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; rowCount?: number; successCount?: number; failedCount?: number; statusAfter?: string; reason?: string; result?: Record<string, unknown> }) =>
|
||||
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)),
|
||||
listAdminBatchTasks: (query: { tenantId?: string; status?: string } = {}) =>
|
||||
request<SmsBatchTask[]>(withQuery('/admin/send/batch-tasks', query)),
|
||||
terminateAdminBatchTask: (id: string) =>
|
||||
request<SmsBatchTask>(`/admin/send/batch-tasks/${id}/terminate`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
listAdminMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; phoneNumber?: string; status?: string } = {}) =>
|
||||
request<SmsMessageRecord[]>(withQuery('/admin/send/messages', query)),
|
||||
listOperationMessages: (query: { tenantId?: string; applicationId?: string; channelId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}) =>
|
||||
request<SmsMessageRecord[]>(withQuery('/admin/operations/messages', query)),
|
||||
listAdminUplinkMessages: (query: { tenantId?: string; channelId?: string } = {}) =>
|
||||
request<SmsUplinkMessage[]>(withQuery('/admin/operations/uplink-messages', query)),
|
||||
listMonitor: (query: { tenantId?: string; channelId?: string } = {}) => request<Record<string, unknown>>(withQuery('/admin/operations/monitor', query)),
|
||||
listStatistics: (query: { tenantId?: string; groupBy?: string } = {}) => request<Array<Record<string, unknown>>>(withQuery('/admin/operations/statistics', query)),
|
||||
listRiskReviewTasks: (query: { tenantId?: string; status?: string } = {}) => request<RiskReviewTask[]>(withQuery('/admin/risk-review/tasks', query)),
|
||||
@@ -575,6 +686,27 @@ export const adminApi = {
|
||||
listDrainageFields: () => request<DictionaryItem[]>('/admin/dictionaries/drainage-fields'),
|
||||
createDrainageField: (body: { code: string; name: string; fieldType: string; required?: boolean; status?: string; description?: string }) =>
|
||||
request<DictionaryItem>('/admin/dictionaries/drainage-fields', { method: 'POST', body: JSON.stringify(body) }),
|
||||
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId?: string) => {
|
||||
const form = new FormData();
|
||||
form.set('file', file);
|
||||
form.set('purpose', body.purpose);
|
||||
if (body.prefix) {
|
||||
form.set('prefix', body.prefix);
|
||||
}
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
if (session?.accessToken) {
|
||||
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
|
||||
}
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
}
|
||||
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form });
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json() as Promise<FileObject>;
|
||||
},
|
||||
};
|
||||
|
||||
export const clientApi = {
|
||||
@@ -595,6 +727,14 @@ export const clientApi = {
|
||||
request<ManagedUser>(`/client/users/${id}/password`, { method: 'POST', tenantId, body: JSON.stringify({ password, operatorId }) }),
|
||||
getDashboard: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<DashboardResponse>('/client/operations/dashboard', { tenantId }),
|
||||
listEnterpriseCertifications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<EnterpriseCertification[]>('/client/enterprise-certification', { tenantId }),
|
||||
submitEnterpriseCertification: (body: { companyName: string; licenseNo?: string; contactName?: string; contactPhone?: string; materials?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<EnterpriseCertification>('/client/enterprise-certification', {
|
||||
method: 'POST',
|
||||
tenantId,
|
||||
body: JSON.stringify({ ...body, tenantId }),
|
||||
}),
|
||||
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
|
||||
listTransactions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
@@ -607,6 +747,8 @@ export const clientApi = {
|
||||
request<RechargeOrder>('/client/billing/orders', { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
|
||||
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
|
||||
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignature[]>('/client/signatures', { tenantId }),
|
||||
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
@@ -621,6 +763,8 @@ export const clientApi = {
|
||||
request<ClientSmsTemplate[]>(withQuery('/client/templates', query), { tenantId }),
|
||||
createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsTemplate>('/client/templates', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
updateTemplate: (id: string, body: { applicationId?: string; signatureId?: string | null; name?: string; content?: string; category?: string; auditStatus?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsTemplate>(`/client/templates/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
|
||||
submitTemplate: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||
changeTemplateStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
@@ -631,8 +775,37 @@ export const clientApi = {
|
||||
request<SmsBatchTask>(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||
createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsBatchTask>('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
previewImport: (body: { content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ImportPreviewResponse>('/client/send/imports/preview', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
confirmImport: (body: { applicationId?: string; templateId?: string; content: string; category?: string; importContent: string; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; requiredVariables?: string[]; variables?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
listBatchTaskMessages: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsMessageRecord[]>(`/client/send/batch-tasks/${id}/messages`, { tenantId }),
|
||||
listMessages: (query: { applicationId?: string; taskId?: string; messageId?: string; phoneNumber?: string; status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsMessageRecord[]>(withQuery('/client/operations/messages', query), { tenantId }),
|
||||
listUplinkMessages: (query: { channelId?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
|
||||
createFileObject: (body: { bucket?: string; objectKey: string; fileName: string; contentType: string; sizeBytes: number; purpose: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<FileObject>('/admin/files', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId, bucket: body.bucket ?? 'cmpp-platform' }) }),
|
||||
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => {
|
||||
const form = new FormData();
|
||||
form.set('file', file);
|
||||
form.set('purpose', body.purpose);
|
||||
if (body.prefix) {
|
||||
form.set('prefix', body.prefix);
|
||||
}
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
if (session?.accessToken) {
|
||||
headers.set('Authorization', `${session.tokenType} ${session.accessToken}`);
|
||||
}
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
}
|
||||
const response = await fetch('/api/admin/files/upload', { method: 'POST', headers, body: form });
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json() as Promise<FileObject>;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Info, Plus } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelGroup } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag } from '@/components/ui';
|
||||
import type { TableColumn } from '@/components/ui';
|
||||
|
||||
@@ -9,13 +10,13 @@ type ChannelStatus = 'normal' | 'stopped';
|
||||
type ProvinceRoute = {
|
||||
id: string;
|
||||
province: string;
|
||||
channel: string;
|
||||
channelId: string;
|
||||
status: ChannelStatus;
|
||||
};
|
||||
type NationalRoute = {
|
||||
id: string;
|
||||
priority: number;
|
||||
channel: string;
|
||||
channelId: string;
|
||||
status: ChannelStatus;
|
||||
};
|
||||
type RouteModalState = {
|
||||
@@ -33,14 +34,6 @@ const provinceOptions = [
|
||||
{ label: '广东', value: '广东' },
|
||||
];
|
||||
|
||||
const channelOptions = [
|
||||
{ label: '请选择', value: '' },
|
||||
{ label: '行北-移动-山东有限公司-上海XXXXXXX-22j', value: '行北-移动-山东有限公司-上海XXXXXXX-22j' },
|
||||
{ label: '行北-移动-河南有限公司-上海XXXX-22j', value: '行北-移动-河南有限公司-上海XXXX-22j' },
|
||||
{ label: '三网行北-黄峰-三网-编号3.3', value: '三网行北-黄峰-三网-编号3.3' },
|
||||
{ label: '移动映华北-上海富煌C60289-移动2.7', value: '移动映华北-上海富煌C60289-移动2.7' },
|
||||
];
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: '请选择', value: '' },
|
||||
{ label: '1', value: '1' },
|
||||
@@ -66,26 +59,31 @@ const statusTones: Record<ChannelStatus, 'success' | 'neutral'> = {
|
||||
stopped: 'neutral',
|
||||
};
|
||||
|
||||
const defaultProvinceRoutes: ProvinceRoute[] = [
|
||||
{ id: 'p-shandong', province: '山东', channel: '行北-移动-山东有限公司-上海XXXXXXX-22j', status: 'normal' },
|
||||
{ id: 'p-henan', province: '河南', channel: '行北-移动-河南有限公司-上海XXXX-22j', status: 'stopped' },
|
||||
];
|
||||
function normalizeRegion(region?: string | null) {
|
||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
||||
}
|
||||
|
||||
const defaultNationalRoutes: NationalRoute[] = [
|
||||
{ id: 'n-1', priority: 1, channel: '三网行北-黄峰-三网-编号3.3', status: 'normal' },
|
||||
{ id: 'n-2', priority: 2, channel: '三网行北-黄峰(循环号用)-三网-编号3.4', status: 'normal' },
|
||||
{ id: 'n-3', priority: 3, channel: '移动映华北-上海富煌C60289-移动2.7', status: 'stopped' },
|
||||
];
|
||||
function isCarrierCompatible(channelCarrier: string | null | undefined, carrier: Carrier) {
|
||||
return !channelCarrier || channelCarrier === 'all' || channelCarrier === carrier;
|
||||
}
|
||||
|
||||
function getChannelStatus(channel?: AdminChannel): ChannelStatus {
|
||||
return channel?.status === 'active' ? 'normal' : 'stopped';
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status: ChannelStatus }) {
|
||||
return <Tag tone={statusTones[status]}>{statusLabels[status]}</Tag>;
|
||||
}
|
||||
|
||||
function RouteConfigModal({
|
||||
channels,
|
||||
carrier,
|
||||
modal,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
channels: AdminChannel[];
|
||||
carrier: Carrier;
|
||||
modal: RouteModalState;
|
||||
onClose: () => void;
|
||||
onSubmit: (route: ProvinceRoute | NationalRoute) => void;
|
||||
@@ -94,24 +92,43 @@ function RouteConfigModal({
|
||||
const nationalRoute = modal.type === 'national' ? modal.route as NationalRoute | undefined : undefined;
|
||||
const [province, setProvince] = useState(provinceRoute?.province ?? '');
|
||||
const [priority, setPriority] = useState(nationalRoute ? String(nationalRoute.priority) : '');
|
||||
const [channel, setChannel] = useState(modal.route?.channel ?? '');
|
||||
const [channelId, setChannelId] = useState(modal.route?.channelId ?? '');
|
||||
|
||||
const selectableChannels = channels.filter((channel) => {
|
||||
if (!isCarrierCompatible(channel.carrier, carrier)) return false;
|
||||
if (modal.type === 'province' && province) {
|
||||
return normalizeRegion(channel.sendRegion) === normalizeRegion(province);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const channelOptions = [
|
||||
{ label: '请选择', value: '' },
|
||||
...selectableChannels.map((channel) => ({
|
||||
label: `${channel.name} / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`,
|
||||
value: channel.id,
|
||||
})),
|
||||
];
|
||||
|
||||
function submit() {
|
||||
if (!channelId) return;
|
||||
const channel = channels.find((item) => item.id === channelId);
|
||||
if (modal.type === 'province') {
|
||||
if (!province) return;
|
||||
onSubmit({
|
||||
id: provinceRoute?.id ?? `p-${Date.now()}`,
|
||||
province: province || '山东',
|
||||
channel: channel || channelOptions[1].value,
|
||||
status: provinceRoute?.status ?? 'normal',
|
||||
province,
|
||||
channelId,
|
||||
status: getChannelStatus(channel),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!priority) return;
|
||||
onSubmit({
|
||||
id: nationalRoute?.id ?? `n-${Date.now()}`,
|
||||
priority: Number(priority || 1),
|
||||
channel: channel || channelOptions[1].value,
|
||||
status: nationalRoute?.status ?? 'normal',
|
||||
priority: Number(priority),
|
||||
channelId,
|
||||
status: getChannelStatus(channel),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,7 +146,7 @@ function RouteConfigModal({
|
||||
>
|
||||
<div className="channel-route-modal">
|
||||
{modal.type === 'province' ? (
|
||||
<Select label="* 选择省份" onChange={(event) => setProvince(event.target.value)} options={provinceOptions} value={province} />
|
||||
<Select label="* 选择省份" onChange={(event) => { setProvince(event.target.value); setChannelId(''); }} options={provinceOptions} value={province} />
|
||||
) : (
|
||||
<>
|
||||
<Select label="* 优先级" onChange={(event) => setPriority(event.target.value)} options={priorityOptions} value={priority} />
|
||||
@@ -139,7 +156,7 @@ function RouteConfigModal({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Select label="* 选择通道" onChange={(event) => setChannel(event.target.value)} options={channelOptions} value={channel} />
|
||||
<Select label="* 选择通道" onChange={(event) => setChannelId(event.target.value)} options={channelOptions} value={channelId} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -149,16 +166,65 @@ export function AdminChannelGroupFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { groupId } = useParams();
|
||||
const editing = Boolean(groupId && groupId !== 'new');
|
||||
const [groupName, setGroupName] = useState(editing ? '学医移动专用组' : '');
|
||||
const [channels, setChannels] = useState<AdminChannel[]>([]);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [carrier, setCarrier] = useState<Carrier>('mobile');
|
||||
const [retryEnabled, setRetryEnabled] = useState(false);
|
||||
const [provinceRoutes, setProvinceRoutes] = useState(defaultProvinceRoutes);
|
||||
const [nationalRoutes, setNationalRoutes] = useState(defaultNationalRoutes);
|
||||
const [retryEnabled, setRetryEnabled] = useState(true);
|
||||
const [provinceRoutes, setProvinceRoutes] = useState<ProvinceRoute[]>([]);
|
||||
const [nationalRoutes, setNationalRoutes] = useState<NationalRoute[]>([]);
|
||||
const [modal, setModal] = useState<RouteModalState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const channelById = useMemo(() => new Map(channels.map((channel) => [channel.id, channel])), [channels]);
|
||||
|
||||
function applyGroup(group: ChannelGroup) {
|
||||
setGroupName(group.name);
|
||||
setCarrier(group.carrier);
|
||||
setRetryEnabled(group.retryEnabled ?? true);
|
||||
setProvinceRoutes((group.items ?? [])
|
||||
.filter((item) => item.province)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
province: item.province ?? '',
|
||||
channelId: item.channelId,
|
||||
status: getChannelStatus(item.channel),
|
||||
})));
|
||||
setNationalRoutes((group.items ?? [])
|
||||
.filter((item) => !item.province)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
priority: item.priority,
|
||||
channelId: item.channelId,
|
||||
status: getChannelStatus(item.channel),
|
||||
}))
|
||||
.sort((a, b) => a.priority - b.priority));
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
Promise.all([adminApi.listChannels(), adminApi.listChannelGroups()])
|
||||
.then(([channelItems, groups]) => {
|
||||
setChannels(channelItems.filter((channel) => channel.status !== 'deleted'));
|
||||
if (editing && groupId) {
|
||||
const group = groups.find((item) => item.id === groupId);
|
||||
if (!group) throw new Error('通道组不存在或已被删除');
|
||||
applyGroup(group);
|
||||
}
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '通道组表单加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [groupId]);
|
||||
|
||||
const provinceColumns = useMemo<Array<TableColumn<ProvinceRoute>>>(() => [
|
||||
{ key: 'province', title: '省份', width: '120px', render: (record) => <strong>{record.province}</strong> },
|
||||
{ key: 'channel', title: '通道', render: (record) => record.channel },
|
||||
{ key: 'channel', title: '通道', render: (record) => channelById.get(record.channelId)?.name ?? record.channelId },
|
||||
{ key: 'status', title: '通道状态', width: '160px', render: (record) => <StatusTag status={record.status} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
@@ -171,11 +237,11 @@ export function AdminChannelGroupFormPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
], [channelById]);
|
||||
|
||||
const nationalColumns = useMemo<Array<TableColumn<NationalRoute>>>(() => [
|
||||
{ key: 'priority', title: '优先级', width: '120px', render: (record) => <strong>{record.priority}</strong> },
|
||||
{ key: 'channel', title: '通道', render: (record) => record.channel },
|
||||
{ key: 'channel', title: '通道', render: (record) => channelById.get(record.channelId)?.name ?? record.channelId },
|
||||
{ key: 'status', title: '通道状态', width: '160px', render: (record) => <StatusTag status={record.status} /> },
|
||||
{
|
||||
key: 'actions',
|
||||
@@ -188,26 +254,76 @@ export function AdminChannelGroupFormPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
], [channelById]);
|
||||
|
||||
function saveRoute(route: ProvinceRoute | NationalRoute) {
|
||||
if (modal?.type === 'province') {
|
||||
const nextRoute = route as ProvinceRoute;
|
||||
setProvinceRoutes((current) => {
|
||||
const exists = current.some((item) => item.id === nextRoute.id);
|
||||
return exists ? current.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...current, nextRoute];
|
||||
const withoutSameProvince = current.filter((item) => item.id === nextRoute.id || item.province !== nextRoute.province);
|
||||
const exists = withoutSameProvince.some((item) => item.id === nextRoute.id);
|
||||
return exists ? withoutSameProvince.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...withoutSameProvince, nextRoute];
|
||||
});
|
||||
} else {
|
||||
const nextRoute = route as NationalRoute;
|
||||
setNationalRoutes((current) => {
|
||||
const exists = current.some((item) => item.id === nextRoute.id);
|
||||
const next = exists ? current.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...current, nextRoute];
|
||||
const withoutSamePriority = current.filter((item) => item.id === nextRoute.id || item.priority !== nextRoute.priority);
|
||||
const exists = withoutSamePriority.some((item) => item.id === nextRoute.id);
|
||||
const next = exists ? withoutSamePriority.map((item) => (item.id === nextRoute.id ? nextRoute : item)) : [...withoutSamePriority, nextRoute];
|
||||
return [...next].sort((a, b) => a.priority - b.priority);
|
||||
});
|
||||
}
|
||||
setModal(null);
|
||||
}
|
||||
|
||||
function buildItems() {
|
||||
return [
|
||||
...provinceRoutes.map((route) => ({
|
||||
channelId: route.channelId,
|
||||
carrier,
|
||||
province: route.province,
|
||||
priority: 100,
|
||||
})),
|
||||
...nationalRoutes.map((route) => ({
|
||||
channelId: route.channelId,
|
||||
carrier,
|
||||
priority: route.priority,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function saveGroup() {
|
||||
if (!groupName.trim()) {
|
||||
setError('请输入通道组名称');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: groupName.trim(),
|
||||
carrier,
|
||||
status: 'active',
|
||||
retryEnabled,
|
||||
retryTimeLimitHours: 72,
|
||||
items: buildItems(),
|
||||
};
|
||||
setSaving(true);
|
||||
setError('');
|
||||
const request = editing && groupId
|
||||
? adminApi.updateChannelGroup(groupId, payload)
|
||||
: adminApi.createChannelGroup({
|
||||
code: `CG-${Date.now()}`,
|
||||
name: payload.name,
|
||||
carrier,
|
||||
status: 'active',
|
||||
retryEnabled,
|
||||
retryTimeLimitHours: 72,
|
||||
}).then((group) => adminApi.updateChannelGroup(group.id, payload));
|
||||
|
||||
request
|
||||
.then(() => navigate('/admin/channel-groups'))
|
||||
.catch((reason: Error) => setError(reason.message || '通道组保存失败'))
|
||||
.finally(() => setSaving(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack channel-group-form-page">
|
||||
<div className="page-heading">
|
||||
@@ -216,6 +332,9 @@ export function AdminChannelGroupFormPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="muted">正在加载真实通道组配置...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<h2>基础设置</h2>
|
||||
<div className="channel-group-base-form">
|
||||
@@ -240,26 +359,26 @@ export function AdminChannelGroupFormPage() {
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<h2>省网分流配置</h2>
|
||||
<Table columns={provinceColumns} data={provinceRoutes} rowKey="id" />
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost">
|
||||
<Table columns={provinceColumns} data={provinceRoutes} emptyText="暂无省网通道" rowKey="id" />
|
||||
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'province', mode: 'create' })} variant="ghost">
|
||||
添加通道
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="surface channel-group-form-section">
|
||||
<h2>全国通道配置</h2>
|
||||
<Table columns={nationalColumns} data={nationalRoutes} rowKey="id" />
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost">
|
||||
<Table columns={nationalColumns} data={nationalRoutes} emptyText="暂无全国通道" rowKey="id" />
|
||||
<Button disabled={loading} icon={<Plus size={16} />} onClick={() => setModal({ type: 'national', mode: 'create' })} variant="ghost">
|
||||
添加通道
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<div className="channel-group-form-footer">
|
||||
<Button onClick={() => navigate('/admin/channel-groups')}>确认</Button>
|
||||
<Button disabled={saving || loading} onClick={saveGroup}>{saving ? '保存中...' : '确认'}</Button>
|
||||
<Button onClick={() => navigate('/admin/channel-groups')} variant="ghost">返回</Button>
|
||||
</div>
|
||||
|
||||
{modal ? <RouteConfigModal modal={modal} onClose={() => setModal(null)} onSubmit={saveRoute} /> : null}
|
||||
{modal ? <RouteConfigModal carrier={carrier} channels={channels} modal={modal} onClose={() => setModal(null)} onSubmit={saveRoute} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type SmsChannel = {
|
||||
corpCode: string;
|
||||
account: string;
|
||||
accessNo: string;
|
||||
passwordCipher?: string;
|
||||
};
|
||||
|
||||
type ChannelModalState = {
|
||||
@@ -139,6 +140,22 @@ function mapUiStatusToApi(channel: SmsChannel) {
|
||||
return channel.status === 'stopped' ? 'active' : 'disabled';
|
||||
}
|
||||
|
||||
function buildChannelPayload(channel: SmsChannel, passwordCipher?: string) {
|
||||
return {
|
||||
name: channel.name,
|
||||
carrier: channel.carrier,
|
||||
sendRegion: channel.sendRegion,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: Number(channel.gatewayPort),
|
||||
enterpriseCode: channel.corpCode,
|
||||
account: channel.account,
|
||||
passwordCipher: passwordCipher || undefined,
|
||||
srcId: channel.accessNo,
|
||||
rateLimitPerSecond: 100,
|
||||
unitPrice: Math.round(channel.unitPrice),
|
||||
};
|
||||
}
|
||||
|
||||
function RateBlock({ label, rate, count, tone = 'neutral' }: { label: string; rate: number; count: number; tone?: 'success' | 'warning' | 'danger' | 'neutral' }) {
|
||||
return (
|
||||
<div className={`sms-channel-rate sms-channel-rate--${tone}`}>
|
||||
@@ -193,6 +210,7 @@ function ChannelFormModal({
|
||||
corpCode,
|
||||
account,
|
||||
accessNo,
|
||||
passwordCipher: password || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -355,31 +373,22 @@ export function AdminChannelsPage() {
|
||||
);
|
||||
|
||||
async function upsertChannel(nextChannel: SmsChannel) {
|
||||
if (modal?.mode === 'edit') {
|
||||
setError('短信通道编辑接口待补,当前不做本地模拟保存');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await adminApi.createChannel({
|
||||
code: `CH-${Date.now()}`,
|
||||
name: nextChannel.name,
|
||||
carrier: nextChannel.carrier,
|
||||
sendRegion: nextChannel.sendRegion,
|
||||
gatewayHost: nextChannel.gatewayHost,
|
||||
gatewayPort: Number(nextChannel.gatewayPort),
|
||||
enterpriseCode: nextChannel.corpCode,
|
||||
account: nextChannel.account,
|
||||
passwordCipher: 'secret',
|
||||
srcId: nextChannel.accessNo,
|
||||
rateLimitPerSecond: 100,
|
||||
unitPrice: Math.round(nextChannel.unitPrice),
|
||||
status: 'active',
|
||||
});
|
||||
setChannels((items) => [mapApiChannel(created), ...items]);
|
||||
if (modal?.mode === 'edit' && modal.channel) {
|
||||
const updated = await adminApi.updateChannel(modal.channel.id, buildChannelPayload(nextChannel, nextChannel.passwordCipher));
|
||||
setChannels((items) => items.map((item) => (item.id === updated.id ? mapApiChannel(updated) : item)));
|
||||
} else {
|
||||
const created = await adminApi.createChannel({
|
||||
code: `CH-${Date.now()}`,
|
||||
...buildChannelPayload(nextChannel, nextChannel.passwordCipher || 'secret'),
|
||||
status: 'active',
|
||||
});
|
||||
setChannels((items) => [mapApiChannel(created), ...items]);
|
||||
}
|
||||
setModal(null);
|
||||
setError('');
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '通道创建失败');
|
||||
setError(failure instanceof Error ? failure.message : '通道保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,19 @@ export function AdminCustomerDetailPage() {
|
||||
<div className="surface mini-status-card"><FileText size={22} /><div><span>现金余额</span><strong>¥{((account?.balanceCents ?? 0) / 100).toLocaleString('zh-CN')}</strong><small>真实账户余额</small></div></div>
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading"><div><h2>企业档案</h2><p className="muted">来自真实企业档案接口。</p></div></div>
|
||||
<div className="ui-detail-info-grid">
|
||||
<div className="ui-detail-info-grid__item"><span>统一社会信用代码</span><strong>{tenant?.enterpriseProfile?.creditCode || '-'}</strong></div>
|
||||
<div className="ui-detail-info-grid__item"><span>省/市</span><strong>{[tenant?.enterpriseProfile?.province, tenant?.enterpriseProfile?.city].filter(Boolean).join(' / ') || '-'}</strong></div>
|
||||
<div className="ui-detail-info-grid__item ui-detail-info-grid__item--full"><span>通讯地址</span><strong>{tenant?.enterpriseProfile?.address || '-'}</strong></div>
|
||||
<div className="ui-detail-info-grid__item"><span>联系人</span><strong>{tenant?.enterpriseProfile?.contactName || '-'}</strong></div>
|
||||
<div className="ui-detail-info-grid__item"><span>手机号</span><strong>{tenant?.enterpriseProfile?.contactPhone || '-'}</strong></div>
|
||||
<div className="ui-detail-info-grid__item"><span>身份证号</span><strong>{tenant?.enterpriseProfile?.contactIdCard || '-'}</strong></div>
|
||||
<div className="ui-detail-info-grid__item"><span>电子邮箱</span><strong>{tenant?.enterpriseProfile?.contactEmail || '-'}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface section-stack">
|
||||
<div className="section-heading"><div><h2>短信应用</h2><p className="muted">来自企业应用真实接口。</p></div><Tag tone="info">{applications.length} 个</Tag></div>
|
||||
<Table columns={appColumns} data={applications} emptyText="暂无短信应用" rowKey="id" />
|
||||
|
||||
@@ -1,36 +1,153 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { adminApi } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
|
||||
import { ImagePlus } from 'lucide-react';
|
||||
import { adminApi, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select, Textarea } from '@/components/ui';
|
||||
|
||||
type EnterpriseForm = {
|
||||
name: string;
|
||||
code: string;
|
||||
status: string;
|
||||
creditCode: string;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactIdCard: string;
|
||||
contactPhone: string;
|
||||
contactEmail: string;
|
||||
photoFileObjectId: string;
|
||||
photoFileName: string;
|
||||
};
|
||||
|
||||
type EnterpriseFormErrors = Partial<Record<keyof EnterpriseForm, string>>;
|
||||
|
||||
const provinceOptions = [
|
||||
{ label: '请选择省/直辖市', value: '' },
|
||||
...'北京,上海,广东,山东,河南,江苏,浙江,四川,重庆,湖北,湖南,陕西'.split(',').map((item) => ({ label: item, value: item })),
|
||||
];
|
||||
|
||||
const cityOptionsByProvince: Record<string, Array<{ label: string; value: string }>> = {
|
||||
北京: [{ label: '北京市', value: '北京市' }],
|
||||
上海: [{ label: '上海市', value: '上海市' }],
|
||||
广东: ['广州市', '深圳市', '东莞市'].map((item) => ({ label: item, value: item })),
|
||||
山东: ['济南市', '青岛市', '烟台市'].map((item) => ({ label: item, value: item })),
|
||||
河南: ['郑州市', '洛阳市', '开封市'].map((item) => ({ label: item, value: item })),
|
||||
江苏: ['南京市', '苏州市', '无锡市'].map((item) => ({ label: item, value: item })),
|
||||
浙江: ['杭州市', '宁波市', '温州市'].map((item) => ({ label: item, value: item })),
|
||||
四川: ['成都市', '绵阳市', '德阳市'].map((item) => ({ label: item, value: item })),
|
||||
重庆: [{ label: '重庆市', value: '重庆市' }],
|
||||
湖北: ['武汉市', '宜昌市', '襄阳市'].map((item) => ({ label: item, value: item })),
|
||||
湖南: ['长沙市', '株洲市', '湘潭市'].map((item) => ({ label: item, value: item })),
|
||||
陕西: ['西安市', '咸阳市', '宝鸡市'].map((item) => ({ label: item, value: item })),
|
||||
};
|
||||
|
||||
const emptyForm: EnterpriseForm = {
|
||||
name: '',
|
||||
code: '',
|
||||
status: 'active',
|
||||
creditCode: '',
|
||||
province: '',
|
||||
city: '',
|
||||
address: '',
|
||||
contactName: '',
|
||||
contactIdCard: '',
|
||||
contactPhone: '',
|
||||
contactEmail: '',
|
||||
photoFileObjectId: '',
|
||||
photoFileName: '',
|
||||
};
|
||||
|
||||
function formFromTenant(tenant: TenantOption): EnterpriseForm {
|
||||
const profile = tenant.enterpriseProfile;
|
||||
return {
|
||||
name: tenant.name,
|
||||
code: tenant.code,
|
||||
status: tenant.status,
|
||||
creditCode: profile?.creditCode ?? '',
|
||||
province: profile?.province ?? '',
|
||||
city: profile?.city ?? '',
|
||||
address: profile?.address ?? '',
|
||||
contactName: profile?.contactName ?? '',
|
||||
contactIdCard: profile?.contactIdCard ?? '',
|
||||
contactPhone: profile?.contactPhone ?? '',
|
||||
contactEmail: profile?.contactEmail ?? '',
|
||||
photoFileObjectId: profile?.photoFileObjectId ?? '',
|
||||
photoFileName: profile?.photoFileObjectId ? '已上传企业照片' : '',
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminCustomerFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { enterpriseId } = useParams();
|
||||
const isEdit = Boolean(enterpriseId);
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [status, setStatus] = useState('active');
|
||||
const [form, setForm] = useState<EnterpriseForm>(emptyForm);
|
||||
const [errors, setErrors] = useState<EnterpriseFormErrors>({});
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enterpriseId) return;
|
||||
if (!enterpriseId) {
|
||||
setForm(emptyForm);
|
||||
return;
|
||||
}
|
||||
adminApi.getTenant(enterpriseId)
|
||||
.then((tenant) => {
|
||||
setName(tenant.name);
|
||||
setCode(tenant.code);
|
||||
setStatus(tenant.status);
|
||||
setForm(formFromTenant(tenant));
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业信息加载失败'));
|
||||
}, [enterpriseId]);
|
||||
|
||||
const cityOptions = useMemo(() => [
|
||||
{ label: '请选择市/区', value: '' },
|
||||
...(cityOptionsByProvince[form.province] ?? []),
|
||||
], [form.province]);
|
||||
|
||||
function updateForm<K extends keyof EnterpriseForm>(key: K, value: EnterpriseForm[K]) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
[key]: value,
|
||||
...(key === 'province' ? { city: '' } : {}),
|
||||
}));
|
||||
setErrors((current) => ({ ...current, [key]: undefined }));
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
const nextErrors: EnterpriseFormErrors = {};
|
||||
if (!form.name.trim()) nextErrors.name = '请填写企业名称';
|
||||
if (!form.code.trim()) nextErrors.code = '请填写企业编码';
|
||||
if (!form.creditCode.trim()) nextErrors.creditCode = '请填写统一社会信用代码';
|
||||
if (!form.contactName.trim()) nextErrors.contactName = '请填写联系人姓名';
|
||||
if (!form.contactPhone.trim()) nextErrors.contactPhone = '请填写手机号';
|
||||
setErrors(nextErrors);
|
||||
return Object.keys(nextErrors).length === 0;
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
const action = isEdit && enterpriseId
|
||||
? adminApi.updateTenant(enterpriseId, { name, code, status })
|
||||
: adminApi.createTenant({ name, code, status });
|
||||
action
|
||||
if (!validateForm()) return;
|
||||
setSaving(true);
|
||||
const { photoFileName, ...payload } = form;
|
||||
const request = isEdit && enterpriseId
|
||||
? adminApi.updateTenant(enterpriseId, payload)
|
||||
: adminApi.createTenant(payload);
|
||||
request
|
||||
.then(() => navigate('/admin/customers'))
|
||||
.catch((failure: Error) => setError(failure.message || '企业保存失败'));
|
||||
.catch((failure: Error) => setError(failure.message || '企业保存失败'))
|
||||
.finally(() => setSaving(false));
|
||||
}
|
||||
|
||||
function uploadEnterprisePhoto(file: File | undefined) {
|
||||
if (!file) return;
|
||||
setUploadingPhoto(true);
|
||||
adminApi.uploadFileObject(file, { purpose: 'enterprise_photo', prefix: 'enterprise-photos' })
|
||||
.then((fileObject) => {
|
||||
setForm((current) => ({ ...current, photoFileObjectId: fileObject.id, photoFileName: fileObject.fileName }));
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业照片上传失败'))
|
||||
.finally(() => setUploadingPhoto(false));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -38,7 +155,7 @@ export function AdminCustomerFormPage() {
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={[isEdit ? '编辑企业' : '创建企业']} />
|
||||
<p>企业档案写入真实租户表,启用后可关联企业管理员和业务数据。</p>
|
||||
<p>填写企业基本信息、证照地址和联系人信息,保存到真实企业档案。</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
@@ -48,23 +165,87 @@ export function AdminCustomerFormPage() {
|
||||
<div className="ui-detail-section__header">
|
||||
<div>
|
||||
<h3>基本信息</h3>
|
||||
<p>企业名称、企业编码和启用状态。</p>
|
||||
<p>企业主体、证照识别和通讯地址。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input label="企业名称" onChange={(event) => setName(event.target.value)} placeholder="请填写企业全称" required value={name} />
|
||||
<Input label="企业编码" onChange={(event) => setCode(event.target.value)} placeholder="请填写唯一企业编码" required value={code} />
|
||||
|
||||
<div className="enterprise-upload-panel">
|
||||
<span>企业照片</span>
|
||||
<label className="enterprise-upload-button">
|
||||
<ImagePlus size={28} />
|
||||
{uploadingPhoto ? '上传中...' : form.photoFileName || '上传企业照片'}
|
||||
<input
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={uploadingPhoto}
|
||||
onChange={(event) => uploadEnterprisePhoto(event.target.files?.[0])}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<p>{form.photoFileObjectId ? `文件对象:${form.photoFileObjectId}` : '支持 JPG、PNG、WebP,上传后随企业档案保存。'}</p>
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input error={errors.name} label="企业名称" onChange={(event) => updateForm('name', event.target.value)} placeholder="请填写企业全称" required value={form.name} />
|
||||
<Input error={errors.code} label="企业编码" onChange={(event) => updateForm('code', event.target.value)} placeholder="请填写唯一企业编码" required value={form.code} />
|
||||
</div>
|
||||
<Input
|
||||
error={errors.creditCode}
|
||||
hint="修改此项将同步更新该企业档案。"
|
||||
label="统一社会信用代码"
|
||||
onChange={(event) => updateForm('creditCode', event.target.value)}
|
||||
placeholder="请填写统一社会信用代码或纳税识别号"
|
||||
required
|
||||
value={form.creditCode}
|
||||
/>
|
||||
|
||||
<div className="form-grid form-grid--two">
|
||||
<Select label="省/直辖市" onChange={(event) => updateForm('province', event.target.value)} options={provinceOptions} value={form.province} />
|
||||
<Select label="市/区" onChange={(event) => updateForm('city', event.target.value)} options={cityOptions} value={form.city} />
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
hint="通讯地址可以与营业执照上的地址不一致。"
|
||||
label="通讯地址"
|
||||
onChange={(event) => updateForm('address', event.target.value)}
|
||||
placeholder="请填写详细通讯地址"
|
||||
rows={4}
|
||||
value={form.address}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<div>
|
||||
<h3>联系人信息</h3>
|
||||
<p>建议填写联系人(法人或财务主管)的真实信息。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="enterprise-info-tip">
|
||||
为方便给企业提供更好的服务,建议填写联系人(法人或财务主管)的真实信息。
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input error={errors.contactName} label="联系人姓名" onChange={(event) => updateForm('contactName', event.target.value)} placeholder="请填写企业联系人姓名" required value={form.contactName} />
|
||||
<Input label="身份证号" onChange={(event) => updateForm('contactIdCard', event.target.value)} placeholder="请填写企业联系人身份证号" value={form.contactIdCard} />
|
||||
</div>
|
||||
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input error={errors.contactPhone} label="手机号" onChange={(event) => updateForm('contactPhone', event.target.value)} placeholder="请填写企业联系人手机号" required value={form.contactPhone} />
|
||||
<Input label="电子邮箱" onChange={(event) => updateForm('contactEmail', event.target.value)} placeholder="请填写企业联系人邮箱" type="email" value={form.contactEmail} />
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="企业状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
onChange={(event) => updateForm('status', event.target.value)}
|
||||
options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
|
||||
value={status}
|
||||
value={form.status}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="enterprise-form-footer">
|
||||
<Button disabled={!name || !code} onClick={submitForm}>{isEdit ? '保存企业' : '创建企业'}</Button>
|
||||
<Button disabled={saving} onClick={submitForm}>{saving ? '保存中...' : isEdit ? '保存企业' : '创建企业'}</Button>
|
||||
<Button onClick={() => navigate('/admin/customers')} variant="ghost">取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -58,16 +58,20 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
const totalBalance = records.reduce((sum, record) => sum + (record.account?.balanceCents ?? 0), 0);
|
||||
|
||||
const columns: Array<TableColumn<CustomerRow>> = [
|
||||
{ key: 'id', title: '企业ID', width: '220px', render: (record) => record.id },
|
||||
{ key: 'name', title: '企业名称', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'code', title: '企业编码', render: (record) => record.code },
|
||||
{ key: 'balance', title: '现金余额', align: 'right', render: (record) => `¥${((record.account?.balanceCents ?? 0) / 100).toLocaleString('zh-CN')}` },
|
||||
{ key: 'smsUnits', title: '短信余量', align: 'right', render: (record) => `${(record.account?.smsUnits ?? 0).toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'status', title: '企业状态', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
|
||||
{ key: 'id', title: '企业ID', width: '240px', render: (record) => <span className="table-mono-id">{record.id}</span> },
|
||||
{ key: 'name', title: '企业名称', width: '260px', render: (record) => <strong className="table-strong-text">{record.name}</strong> },
|
||||
{ key: 'code', title: '企业编码', width: '180px', render: (record) => <span className="table-mono-id">{record.code}</span> },
|
||||
{ key: 'creditCode', title: '统一社会信用代码', width: '220px', render: (record) => record.enterpriseProfile?.creditCode || '-' },
|
||||
{ key: 'contact', title: '联系人', width: '160px', render: (record) => record.enterpriseProfile?.contactName || '-' },
|
||||
{ key: 'phone', title: '联系电话', width: '150px', render: (record) => record.enterpriseProfile?.contactPhone || '-' },
|
||||
{ key: 'balance', title: '现金余额', width: '150px', align: 'right', render: (record) => `¥${((record.account?.balanceCents ?? 0) / 100).toLocaleString('zh-CN')}` },
|
||||
{ key: 'smsUnits', title: '短信余量', width: '150px', align: 'right', render: (record) => `${(record.account?.smsUnits ?? 0).toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'status', title: '企业状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'warning'}>{record.status === 'active' ? '正常' : '已禁用'}</Tag> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '280px',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button onClick={() => navigate(`${basePath}/${record.id}`)} size="sm" variant="ghost">详情</Button>
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Edit3, Plus, Search, Settings2, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Breadcrumb, Button, Input, Modal, Table, Tabs, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication } from '@/api/adminApi';
|
||||
import { adminApi, type ApplicationCmppParams, type CmppConnectionState, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
|
||||
|
||||
type SmsApp = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
name: string;
|
||||
enterprise: string;
|
||||
appId: string;
|
||||
@@ -43,18 +44,6 @@ type CmppConnection = {
|
||||
pendingWindow: number;
|
||||
};
|
||||
|
||||
type MmsApp = Omit<SmsApp, 'cmppStatus' | 'cmppConnections' | 'cmppParams'> & {
|
||||
pointPrice: number;
|
||||
};
|
||||
|
||||
type AppKind = 'sms' | 'mms';
|
||||
|
||||
const initialMmsApps: MmsApp[] = [
|
||||
{ id: 'mms-app-1', name: '营销活动彩信', enterprise: '上海XXXXX科技有限公司', appId: 'MMS_2024020112345678', enabled: true, sentToday: 320, deliveryRate: 92, unitPrice: 0.15, pointPrice: 50 },
|
||||
{ id: 'mms-app-2', name: '节日祝福彩信', enterprise: '重庆进载数智', appId: 'MMS_2024020187654321', enabled: true, sentToday: 180, deliveryRate: 88, unitPrice: 0.12, pointPrice: 30 },
|
||||
{ id: 'mms-app-3', name: '会员权益彩信', enterprise: '四川骠骑企业管理', appId: 'MMS_2024020199001122', enabled: false, sentToday: 0, deliveryRate: 0, unitPrice: 0.18, pointPrice: 60 },
|
||||
];
|
||||
|
||||
function enabledTag(enabled: boolean) {
|
||||
return <Tag tone={enabled ? 'success' : 'neutral'}>{enabled ? '启用' : '停用'}</Tag>;
|
||||
}
|
||||
@@ -77,6 +66,52 @@ function ConfirmModal({ message, danger, onCancel, onConfirm }: { message: strin
|
||||
);
|
||||
}
|
||||
|
||||
function AddApplicationModal({
|
||||
tenants,
|
||||
loading,
|
||||
selectedTenantId,
|
||||
onChange,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
tenants: TenantOption[];
|
||||
loading: boolean;
|
||||
selectedTenantId: string;
|
||||
onChange: (tenantId: string) => void;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
<Button disabled={!selectedTenantId || loading} onClick={onConfirm}>下一步</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title={<div className="template-modal-title"><h2>新建企业应用</h2><p>先选择真实企业,再配置短信应用和三网通道组。</p></div>}
|
||||
>
|
||||
<div className="form-grid app-create-modal">
|
||||
<label className="field">
|
||||
<span>所属企业</span>
|
||||
<select disabled={loading} onChange={(event) => onChange(event.target.value)} value={selectedTenantId}>
|
||||
<option value="">{loading ? '企业加载中...' : '请选择真实企业'}</option>
|
||||
{tenants.map((tenant) => (
|
||||
<option key={tenant.id} value={tenant.id}>{tenant.name}({tenant.code})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="app-create-modal__hint">
|
||||
<strong>{selectedTenantId ? tenants.find((tenant) => tenant.id === selectedTenantId)?.name : '请选择要开通短信应用的企业'}</strong>
|
||||
<span>下一步会进入应用参数、客户单价、IP 白名单和运营商通道组配置。</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const connectionStateMeta: Record<CmppConnection['state'], { label: string; tone: 'success' | 'warning' | 'neutral' }> = {
|
||||
open: { label: '已连接', tone: 'success' },
|
||||
closed: { label: '已断开', tone: 'neutral' },
|
||||
@@ -177,19 +212,19 @@ function CmppConnectionModal({
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'id', title: '连接ID', width: '150px', render: (record: CmppConnection) => <strong>{record.id}</strong> },
|
||||
{ key: 'state', title: '状态', width: '100px', render: (record: CmppConnection) => <Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag> },
|
||||
{ key: 'state', title: '状态', width: '130px', render: (record: CmppConnection) => <Tag tone={connectionStateMeta[record.state].tone}>{connectionStateMeta[record.state].label}</Tag> },
|
||||
{ key: 'bindType', title: '绑定类型', width: '120px', render: (record: CmppConnection) => record.bindType },
|
||||
{ key: 'clientIp', title: '客户端IP', width: '170px', render: (record: CmppConnection) => record.clientIp },
|
||||
{ key: 'sourceAddr', title: '企业代码', width: '120px', render: (record: CmppConnection) => record.sourceAddr },
|
||||
{ key: 'establishedAt', title: '连接建立时间', width: '180px', render: (record: CmppConnection) => record.establishedAt },
|
||||
{ key: 'lastHeartbeatAt', title: '上次心跳', width: '180px', render: (record: CmppConnection) => record.lastHeartbeatAt },
|
||||
{ key: 'lastSubmitAt', title: '上次提交', width: '180px', render: (record: CmppConnection) => record.lastSubmitAt },
|
||||
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '100px', render: (record: CmppConnection) => record.pendingWindow },
|
||||
{ key: 'pendingWindow', title: '窗口占用', align: 'right', width: '120px', render: (record: CmppConnection) => record.pendingWindow },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '100px',
|
||||
width: '120px',
|
||||
render: (record: CmppConnection) => (
|
||||
<Button icon={<Trash2 size={14} />} onClick={() => onDeleteConnection(record.id)} size="sm" variant="danger">删除</Button>
|
||||
),
|
||||
@@ -207,15 +242,18 @@ function CmppConnectionModal({
|
||||
export function AdminEnterpriseApplicationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
|
||||
const [mmsApps, setMmsApps] = useState(initialMmsApps);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
|
||||
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
|
||||
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [tenantsLoading, setTenantsLoading] = useState(false);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState('');
|
||||
const [confirmAction, setConfirmAction] = useState<
|
||||
| { action: 'toggle'; kind: AppKind; id: string; name: string; enabled: boolean }
|
||||
| { action: 'delete'; kind: AppKind; id: string; name: string }
|
||||
| { action: 'toggle'; id: string; name: string; enabled: boolean }
|
||||
| { action: 'delete'; id: string; name: string }
|
||||
| null
|
||||
>(null);
|
||||
|
||||
@@ -234,36 +272,47 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
void loadSmsApps();
|
||||
}, [enterpriseKeyword]);
|
||||
|
||||
async function confirmToggle(kind: AppKind, id: string) {
|
||||
if (kind === 'sms') {
|
||||
const app = smsApps.find((item) => item.id === id);
|
||||
if (app) {
|
||||
await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理');
|
||||
await loadSmsApps();
|
||||
async function openAddModal() {
|
||||
setAddModalOpen(true);
|
||||
if (tenants.length === 0) {
|
||||
setTenantsLoading(true);
|
||||
try {
|
||||
setTenants((await adminApi.listTenants()).filter((tenant) => tenant.status !== 'deleted'));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '企业列表加载失败');
|
||||
} finally {
|
||||
setTenantsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setMmsApps((current) => current.map((item) => item.id === id ? { ...item, enabled: !item.enabled } : item));
|
||||
}
|
||||
|
||||
async function confirmDelete(kind: AppKind, id: string) {
|
||||
if (kind === 'sms') {
|
||||
await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用');
|
||||
await loadSmsApps();
|
||||
} else {
|
||||
setMmsApps((current) => current.filter((item) => item.id !== id));
|
||||
function confirmAddApplication() {
|
||||
if (selectedTenantId) {
|
||||
navigate(`/admin/customers/${selectedTenantId}/sms-apps/new`);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmToggle(id: string) {
|
||||
const app = smsApps.find((item) => item.id === id);
|
||||
if (app) {
|
||||
await adminApi.changeApplicationStatus(id, app.enabled ? 'disabled' : 'active', '运营端企业应用管理');
|
||||
await loadSmsApps();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(id: string) {
|
||||
await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用');
|
||||
await loadSmsApps();
|
||||
}
|
||||
|
||||
async function runConfirmedAction() {
|
||||
if (!confirmAction) {
|
||||
return;
|
||||
}
|
||||
if (confirmAction.action === 'toggle') {
|
||||
await confirmToggle(confirmAction.kind, confirmAction.id);
|
||||
await confirmToggle(confirmAction.id);
|
||||
} else {
|
||||
await confirmDelete(confirmAction.kind, confirmAction.id);
|
||||
await confirmDelete(confirmAction.id);
|
||||
}
|
||||
setConfirmAction(null);
|
||||
}
|
||||
@@ -286,18 +335,13 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
[enterpriseKeyword, smsApps],
|
||||
);
|
||||
|
||||
const filteredMmsApps = useMemo(
|
||||
() => mmsApps.filter((item) => !enterpriseKeyword || item.enterprise.includes(enterpriseKeyword)),
|
||||
[enterpriseKeyword, mmsApps],
|
||||
);
|
||||
|
||||
const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [
|
||||
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
|
||||
{ key: 'appId', title: 'AppID', width: '220px', render: (record) => record.appId },
|
||||
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'deliveryRate', title: '到达率', width: '110px', render: (record) => `${record.deliveryRate}%` },
|
||||
{ key: 'unitPrice', title: '单价', width: '100px', render: (record) => `${record.unitPrice.toFixed(3)} 元` },
|
||||
{ key: 'deliveryRate', title: '到达率', width: '130px', render: (record) => `${record.deliveryRate}%` },
|
||||
{ key: 'unitPrice', title: '单价', width: '130px', render: (record) => `${record.unitPrice.toFixed(3)} 元` },
|
||||
{
|
||||
key: 'cmppStatus',
|
||||
title: 'CMPP状态',
|
||||
@@ -317,7 +361,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'enabled', title: '状态', width: '100px', render: (record) => enabledTag(record.enabled) },
|
||||
{ key: 'enabled', title: '状态', width: '130px', render: (record) => enabledTag(record.enabled) },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
@@ -325,37 +369,11 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
width: '190px',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/2763/sms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => setConfirmAction({ action: 'toggle', kind: 'sms', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant="secondary">
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/${record.tenantId}/sms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => setConfirmAction({ action: 'toggle', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant="secondary">
|
||||
{record.enabled ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', kind: 'sms', id: record.id, name: record.name })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [navigate]);
|
||||
|
||||
const mmsColumns = useMemo<Array<TableColumn<MmsApp>>>(() => [
|
||||
{ key: 'name', title: '应用名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'enterprise', title: '企业名称', width: '220px', render: (record) => record.enterprise },
|
||||
{ key: 'appId', title: 'AppID', width: '220px', render: (record) => record.appId },
|
||||
{ key: 'sentToday', title: '今日发送', width: '120px', render: (record) => `${record.sentToday.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'deliveryRate', title: '到达率', width: '110px', render: (record) => `${record.deliveryRate}%` },
|
||||
{ key: 'unitPrice', title: '单价', width: '100px', render: (record) => `${record.unitPrice.toFixed(3)} 元` },
|
||||
{ key: 'pointPrice', title: '点数', width: '100px', render: (record) => `${record.pointPrice} 分` },
|
||||
{ key: 'enabled', title: '状态', width: '100px', render: (record) => enabledTag(record.enabled) },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '190px',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => navigate(`/admin/customers/2763/mms-apps/${record.id}/edit`)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button onClick={() => setConfirmAction({ action: 'toggle', kind: 'mms', id: record.id, name: record.name, enabled: record.enabled })} size="sm" variant="secondary">
|
||||
{record.enabled ? '停用' : '启用'}
|
||||
</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', kind: 'mms', id: record.id, name: record.name })} size="sm" variant="danger">删除</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setConfirmAction({ action: 'delete', id: record.id, name: record.name })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -368,7 +386,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
<Breadcrumb items={['客户管理', '企业应用管理']} />
|
||||
<h1>企业应用管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => navigate('/admin/customers/2763/sms-apps/new')}>添加应用</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={() => { void openAddModal(); }}>添加应用</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-split-filter">
|
||||
@@ -388,7 +406,7 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
<Tabs
|
||||
items={[
|
||||
{ label: '短信应用', value: 'sms', content: <Table columns={smsColumns} data={filteredSmsApps} rowKey="id" /> },
|
||||
{ label: '彩信应用', value: 'mms', pending: true, content: <Table columns={mmsColumns} data={filteredMmsApps} rowKey="id" /> },
|
||||
{ label: '彩信应用', value: 'mms', pending: true, content: <div className="ui-table__empty">彩信应用待后端能力确认,本页不展示演示数据。</div> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -403,6 +421,16 @@ export function AdminEnterpriseApplicationsPage() {
|
||||
onConfirm={() => { void runConfirmedAction(); }}
|
||||
/>
|
||||
) : null}
|
||||
{addModalOpen ? (
|
||||
<AddApplicationModal
|
||||
loading={tenantsLoading}
|
||||
onCancel={() => setAddModalOpen(false)}
|
||||
onChange={setSelectedTenantId}
|
||||
onConfirm={confirmAddApplication}
|
||||
selectedTenantId={selectedTenantId}
|
||||
tenants={tenants}
|
||||
/>
|
||||
) : null}
|
||||
{connectionApp ? (
|
||||
<CmppConnectionModal
|
||||
app={connectionApp}
|
||||
@@ -419,13 +447,14 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
|
||||
const connections = (application.cmppConnections ?? []).map(mapConnection);
|
||||
return {
|
||||
id: application.id,
|
||||
tenantId: application.tenantId,
|
||||
name: application.name,
|
||||
enterprise: application.tenant?.name ?? application.tenantId,
|
||||
appId: application.id,
|
||||
enabled: application.status === 'active',
|
||||
sentToday: application.sentToday ?? 0,
|
||||
deliveryRate: application.deliveryRate ?? 0,
|
||||
unitPrice: 0,
|
||||
unitPrice: (application.customerUnitPrice ?? 0) / 100,
|
||||
cmppStatus: application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
|
||||
cmppParams: { host: '', port: 0, enterpriseCode: application.tenant?.code ?? application.tenantId, account: application.tenant?.code ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 3.0' },
|
||||
cmppConnections: connections,
|
||||
|
||||
@@ -44,11 +44,11 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => <strong>{record.phoneNumber}</strong> },
|
||||
{ key: 'createdAt', title: '入库时间', width: '170px', render: (record) => record.createdAt ?? '-' },
|
||||
{ key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (record) => record.status ?? '-' },
|
||||
{ key: 'status', title: '状态', width: '130px', render: (record) => record.status ?? '-' },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '110px',
|
||||
width: '130px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteEnterpriseBlacklist(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
|
||||
|
||||
@@ -1,53 +1,517 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsSignature } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileText, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type CarrierStatus = 'approved' | 'pending' | 'rejected' | 'filing';
|
||||
|
||||
type DrainageInfo = {
|
||||
id: string;
|
||||
siteName: string;
|
||||
url: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
submittedAt: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
type SignatureFormState = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<CarrierStatus, string> = {
|
||||
approved: '已通过',
|
||||
pending: '审核中',
|
||||
rejected: '已驳回',
|
||||
filing: '待报备',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger' | 'neutral'> = {
|
||||
approved: 'success',
|
||||
pending: 'info',
|
||||
rejected: 'danger',
|
||||
filing: 'neutral',
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '审核中', value: 'pending' },
|
||||
{ label: '已驳回', value: 'rejected' },
|
||||
{ label: '待报备', value: 'filing' },
|
||||
];
|
||||
|
||||
function StatusTag({ status }: { status: CarrierStatus }) {
|
||||
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
|
||||
}
|
||||
|
||||
function readDrainagePayload(signature: ClientSmsSignature) {
|
||||
const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {};
|
||||
const carrierStatus = typeof payload.carrierStatus === 'object' && payload.carrierStatus ? payload.carrierStatus as Record<string, unknown> : {};
|
||||
const links = Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
|
||||
const fallbackStatus = normalizeCarrierStatus(signature.auditStatus);
|
||||
return {
|
||||
carrierStatus: {
|
||||
mobile: normalizeCarrierStatus(carrierStatus.mobile, fallbackStatus),
|
||||
unicom: normalizeCarrierStatus(carrierStatus.unicom, fallbackStatus),
|
||||
telecom: normalizeCarrierStatus(carrierStatus.telecom, fallbackStatus),
|
||||
},
|
||||
links: links.map((item) => ({
|
||||
id: String(item.id ?? `drain-${Date.now()}`),
|
||||
siteName: String(item.siteName ?? ''),
|
||||
url: String(item.url ?? ''),
|
||||
mobile: normalizeCarrierStatus(item.mobile, 'filing'),
|
||||
unicom: normalizeCarrierStatus(item.unicom, 'filing'),
|
||||
telecom: normalizeCarrierStatus(item.telecom, 'filing'),
|
||||
submittedAt: String(item.submittedAt ?? ''),
|
||||
remark: String(item.remark ?? ''),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDrainagePayload(carrierStatus: { mobile: CarrierStatus; unicom: CarrierStatus; telecom: CarrierStatus }, links: DrainageInfo[]) {
|
||||
return { carrierStatus, links };
|
||||
}
|
||||
|
||||
function normalizeCarrierStatus(value: unknown, fallback: CarrierStatus = 'filing'): CarrierStatus {
|
||||
return value === 'approved' || value === 'pending' || value === 'rejected' || value === 'filing' ? value : fallback;
|
||||
}
|
||||
|
||||
function toAuditStatus(status: CarrierStatus) {
|
||||
return status === 'filing' ? 'pending' : status;
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString('zh-CN') : '-';
|
||||
}
|
||||
|
||||
function SignatureFormModal({
|
||||
applications,
|
||||
item,
|
||||
onClose,
|
||||
onSubmit,
|
||||
tenants,
|
||||
}: {
|
||||
applications: ClientSmsApplication[];
|
||||
item?: ClientSmsSignature;
|
||||
onClose: () => void;
|
||||
onSubmit: (state: SignatureFormState) => void;
|
||||
tenants: TenantOption[];
|
||||
}) {
|
||||
const payload = item ? readDrainagePayload(item) : null;
|
||||
const [form, setForm] = useState<SignatureFormState>({
|
||||
tenantId: item?.tenantId ?? '',
|
||||
applicationId: item?.applicationId ?? '',
|
||||
name: item?.name ?? '',
|
||||
purpose: item?.purpose ?? '',
|
||||
mobile: payload?.carrierStatus.mobile ?? 'filing',
|
||||
unicom: payload?.carrierStatus.unicom ?? 'filing',
|
||||
telecom: payload?.carrierStatus.telecom ?? 'filing',
|
||||
});
|
||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||
|
||||
function update<Key extends keyof SignatureFormState>(key: Key, value: SignatureFormState[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.tenantId || !form.name} onClick={() => onSubmit(form)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={item ? '编辑短信签名' : '添加短信签名'}
|
||||
>
|
||||
<div className="signature-form">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<div className="signature-form-grid">
|
||||
<Select
|
||||
disabled={Boolean(item)}
|
||||
label="所属企业"
|
||||
onChange={(event) => update('tenantId', event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择企业', value: '' },
|
||||
...tenants.map((tenant) => ({ label: `${tenant.name}(${tenant.code})`, value: tenant.id })),
|
||||
]}
|
||||
required
|
||||
value={form.tenantId}
|
||||
/>
|
||||
<Select
|
||||
label="所属应用"
|
||||
onChange={(event) => update('applicationId', event.target.value)}
|
||||
options={[
|
||||
{ label: '不绑定应用', value: '' },
|
||||
...tenantApplications.map((application) => ({ label: application.name, value: application.id })),
|
||||
]}
|
||||
value={form.applicationId}
|
||||
/>
|
||||
<Input label="短信签名" onChange={(event) => update('name', event.target.value)} placeholder="例如【某某科技】" required value={form.name} />
|
||||
<Input label="签名用途" onChange={(event) => update('purpose', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.purpose} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>三网报备状态</h3>
|
||||
<div className="signature-form-grid">
|
||||
<Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} />
|
||||
<Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} />
|
||||
<Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrainageFormModal({ item, onClose, onSubmit }: { item?: DrainageInfo; onClose: () => void; onSubmit: (item: DrainageInfo) => void }) {
|
||||
const [form, setForm] = useState<DrainageInfo>(() => item ?? {
|
||||
id: `drain-${Date.now()}`,
|
||||
siteName: '',
|
||||
url: '',
|
||||
mobile: 'filing',
|
||||
unicom: 'filing',
|
||||
telecom: 'filing',
|
||||
submittedAt: new Date().toLocaleString('zh-CN'),
|
||||
remark: '',
|
||||
});
|
||||
|
||||
function update<Key extends keyof DrainageInfo>(key: Key, value: DrainageInfo[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.siteName || !form.url} onClick={() => onSubmit(form)}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={item ? '编辑引流链接' : '添加引流链接'}
|
||||
>
|
||||
<div className="signature-form drainage-edit-form">
|
||||
<section>
|
||||
<h3>引流信息</h3>
|
||||
<div className="signature-form-grid">
|
||||
<Input label="站名称" onChange={(event) => update('siteName', event.target.value)} placeholder="请输入站点名称" required value={form.siteName} />
|
||||
<Input label="网站链接" onChange={(event) => update('url', event.target.value)} placeholder="https://example.com" required value={form.url} />
|
||||
<Select label="移动状态" onChange={(event) => update('mobile', event.target.value as CarrierStatus)} options={statusOptions} value={form.mobile} />
|
||||
<Select label="联通状态" onChange={(event) => update('unicom', event.target.value as CarrierStatus)} options={statusOptions} value={form.unicom} />
|
||||
<Select label="电信状态" onChange={(event) => update('telecom', event.target.value as CarrierStatus)} options={statusOptions} value={form.telecom} />
|
||||
<Input label="提交时间" onChange={(event) => update('submittedAt', event.target.value)} value={form.submittedAt} />
|
||||
<Textarea className="signature-form-grid__wide" label="备注" onChange={(event) => update('remark', event.target.value)} rows={4} value={form.remark} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureReportModal({ item, onClose }: { item: ClientSmsSignature; onClose: () => void }) {
|
||||
const payload = readDrainagePayload(item);
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="签名报备详情">
|
||||
<div className="admin-report-detail">
|
||||
<div className="detail-grid">
|
||||
<div><span>企业名称</span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
|
||||
<div><span>应用名称</span><strong>{item.application?.name ?? '-'}</strong></div>
|
||||
<div><span>签名名称</span><strong>{item.name}</strong></div>
|
||||
<div><span>更新时间</span><strong>{formatDate(item.updatedAt)}</strong></div>
|
||||
</div>
|
||||
<div className="admin-report-tabs">
|
||||
<button className="admin-report-carrier--mobile active" type="button"><strong>移动</strong><span><StatusTag status={payload.carrierStatus.mobile} /></span></button>
|
||||
<button className="admin-report-carrier--unicom active" type="button"><strong>联通</strong><span><StatusTag status={payload.carrierStatus.unicom} /></span></button>
|
||||
<button className="admin-report-carrier--telecom active" type="button"><strong>电信</strong><span><StatusTag status={payload.carrierStatus.telecom} /></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrainageReportModal({ item, onClose }: { item: DrainageInfo; onClose: () => void }) {
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="引流信息报备详情">
|
||||
<div className="detail-grid">
|
||||
<div><span>站名称</span><strong>{item.siteName}</strong></div>
|
||||
<div><span>网站链接</span><strong>{item.url}</strong></div>
|
||||
<div><span>移动</span><StatusTag status={item.mobile} /></div>
|
||||
<div><span>联通</span><StatusTag status={item.unicom} /></div>
|
||||
<div><span>电信</span><StatusTag status={item.telecom} /></div>
|
||||
<div><span>提交时间</span><strong>{item.submittedAt}</strong></div>
|
||||
<div className="detail-grid__wide"><span>备注</span><strong>{item.remark || '-'}</strong></div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
<Button onClick={onConfirm} variant="danger">确认删除</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title="删除确认"
|
||||
>
|
||||
<p className="admin-confirm-text">{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminEnterpriseSignaturesPage() {
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ kind: 'signature'; id: string; name: string } | { kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
|
||||
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
|
||||
const [drainageReport, setDrainageReport] = useState<DrainageInfo | null>(null);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [expandedSignatureId, setExpandedSignatureId] = useState('');
|
||||
const [signatureKeyword, setSignatureKeyword] = useState('');
|
||||
const [signatureModal, setSignatureModal] = useState<ClientSmsSignature | 'new' | null>(null);
|
||||
const [signatureReport, setSignatureReport] = useState<ClientSmsSignature | null>(null);
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
|
||||
function loadData() {
|
||||
adminApi.listEnterpriseSignatures({ keyword })
|
||||
.then((items) => {
|
||||
setSignatures(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业签名加载失败'));
|
||||
async function loadData() {
|
||||
try {
|
||||
const [signatureItems, tenantItems, applicationItems] = await Promise.all([
|
||||
adminApi.listEnterpriseSignatures({ keyword: [enterpriseKeyword, signatureKeyword].filter(Boolean).join(' ') }),
|
||||
adminApi.listTenants(),
|
||||
adminApi.listEnterpriseApplications(),
|
||||
]);
|
||||
setSignatures(signatureItems);
|
||||
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
|
||||
setApplications(applicationItems);
|
||||
setError('');
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '企业签名加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
void loadData();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => signatures.filter((item) => !keyword || [item.name, item.purpose, item.auditStatus].join(' ').includes(keyword)), [keyword, signatures]);
|
||||
const filteredSignatures = useMemo(() => signatures.filter((item) => {
|
||||
const enterprise = item.tenant?.name ?? item.tenantId;
|
||||
const application = item.application?.name ?? '';
|
||||
return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword))
|
||||
&& (!signatureKeyword || item.name.includes(signatureKeyword) || application.includes(signatureKeyword) || (item.purpose ?? '').includes(signatureKeyword));
|
||||
}), [enterpriseKeyword, signatureKeyword, signatures]);
|
||||
|
||||
const columns: Array<TableColumn<ClientSmsSignature>> = [
|
||||
{ key: 'name', title: '签名名称', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'tenant', title: '企业', render: (record) => record.tenantId },
|
||||
{ key: 'purpose', title: '用途', render: (record) => record.purpose ?? '-' },
|
||||
{ key: 'materials', title: '材料', render: (record) => `${record.materials?.length ?? 0} 份` },
|
||||
{ key: 'status', title: '审核状态', render: (record) => <Tag tone={record.auditStatus === 'approved' ? 'success' : record.auditStatus === 'rejected' ? 'danger' : 'info'}>{record.auditStatus}</Tag> },
|
||||
{ key: 'updatedAt', title: '更新时间', render: (record) => record.updatedAt },
|
||||
];
|
||||
async function saveSignature(state: SignatureFormState) {
|
||||
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
|
||||
const existingPayload = existing ? readDrainagePayload(existing) : { links: [] };
|
||||
const drainageInfo = buildDrainagePayload({
|
||||
mobile: state.mobile,
|
||||
unicom: state.unicom,
|
||||
telecom: state.telecom,
|
||||
}, existingPayload.links);
|
||||
try {
|
||||
if (existing) {
|
||||
await adminApi.updateEnterpriseSignature(existing.id, {
|
||||
applicationId: state.applicationId || null,
|
||||
auditStatus: toAuditStatus(state.mobile),
|
||||
drainageInfo,
|
||||
name: state.name,
|
||||
purpose: state.purpose,
|
||||
});
|
||||
} else {
|
||||
await adminApi.createEnterpriseSignature({
|
||||
applicationId: state.applicationId || undefined,
|
||||
drainageInfo,
|
||||
name: state.name,
|
||||
purpose: state.purpose,
|
||||
tenantId: state.tenantId,
|
||||
});
|
||||
}
|
||||
setSignatureModal(null);
|
||||
await loadData();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '企业签名保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDrainage(signatureId: string, item: DrainageInfo) {
|
||||
const signature = signatures.find((current) => current.id === signatureId);
|
||||
if (!signature) {
|
||||
return;
|
||||
}
|
||||
const payload = readDrainagePayload(signature);
|
||||
const links = payload.links.some((current) => current.id === item.id)
|
||||
? payload.links.map((current) => current.id === item.id ? item : current)
|
||||
: [item, ...payload.links];
|
||||
await adminApi.updateEnterpriseSignature(signatureId, {
|
||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, links),
|
||||
});
|
||||
setDrainageModal(null);
|
||||
setExpandedSignatureId(signatureId);
|
||||
await loadData();
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
if (deleteTarget.kind === 'signature') {
|
||||
await adminApi.changeEnterpriseSignatureStatus(deleteTarget.id, 'deleted', '运营端删除签名');
|
||||
} else {
|
||||
const signature = signatures.find((item) => item.id === deleteTarget.signatureId);
|
||||
if (signature) {
|
||||
const payload = readDrainagePayload(signature);
|
||||
await adminApi.updateEnterpriseSignature(signature.id, {
|
||||
drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
await loadData();
|
||||
}
|
||||
|
||||
const smsSignatureContent = (
|
||||
<div className="signature-list admin-enterprise-signature-list">
|
||||
{filteredSignatures.map((signature) => {
|
||||
const payload = readDrainagePayload(signature);
|
||||
const expanded = expandedSignatureId === signature.id;
|
||||
return (
|
||||
<article className="signature-card signature-card--green" key={signature.id}>
|
||||
<div className="signature-summary">
|
||||
<button aria-label="展开签名" onClick={() => setExpandedSignatureId(expanded ? '' : signature.id)} type="button">
|
||||
{expanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
<div><span>签名名称</span><strong>{signature.name}</strong></div>
|
||||
<div><span>企业</span><strong>{signature.tenant?.name ?? signature.tenantId}</strong></div>
|
||||
<div><span>应用</span><strong>{signature.application?.name ?? '-'}</strong></div>
|
||||
<div><span>移动</span><StatusTag status={payload.carrierStatus.mobile} /></div>
|
||||
<div><span>联通</span><StatusTag status={payload.carrierStatus.unicom} /></div>
|
||||
<div><span>电信</span><StatusTag status={payload.carrierStatus.telecom} /></div>
|
||||
<div><span>引流信息</span><strong>{payload.links.length} 条</strong></div>
|
||||
<div className="signature-actions">
|
||||
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className="drainage-panel">
|
||||
<h2>引流信息列表</h2>
|
||||
{payload.links.length ? (
|
||||
<div className="drainage-table">
|
||||
<div className="drainage-table__head">
|
||||
<span>站名称</span>
|
||||
<span>网站链接</span>
|
||||
<span>移动</span>
|
||||
<span>联通</span>
|
||||
<span>电信</span>
|
||||
<span>提交时间</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{payload.links.map((item) => (
|
||||
<div className="drainage-table__row" key={item.id}>
|
||||
<strong>{item.siteName}</strong>
|
||||
<a href={item.url} rel="noreferrer" target="_blank">{item.url}</a>
|
||||
<StatusTag status={item.mobile} />
|
||||
<StatusTag status={item.unicom} />
|
||||
<StatusTag status={item.telecom} />
|
||||
<span className="muted">{item.submittedAt}</span>
|
||||
<span className="drainage-row-actions">
|
||||
<Button onClick={() => setDrainageReport(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.siteName })} 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>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
{filteredSignatures.length === 0 ? <div className="ui-table__empty">暂无企业签名</div> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<section className="page-stack admin-customer-split-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['企业配置', '企业签名']} />
|
||||
<h1>企业签名</h1>
|
||||
<Breadcrumb items={['客户管理', '企业签名管理']} />
|
||||
<h1>企业签名管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setSignatureModal(activeTab === 'sms' ? 'new' : null)}>添加签名</Button>
|
||||
</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) => setSignatureKeyword(event.target.value)} placeholder="请输入签名或应用名称" prefix={<Search size={16} />} value={signatureKeyword} />
|
||||
<Button onClick={() => { setEnterpriseKeyword(''); setSignatureKeyword(''); void loadData(); }} variant="ghost">重置</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { void loadData(); }}>查询</Button>
|
||||
</div>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface admin-security-filter">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索签名、用途或状态" prefix={<Search size={16} />} value={keyword} />
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={filtered} emptyText="暂无企业签名" rowKey="id" />
|
||||
|
||||
<div className="surface section-stack">
|
||||
<Tabs
|
||||
onChange={(value) => setActiveTab(value as 'sms' | 'mms')}
|
||||
value={activeTab}
|
||||
items={[
|
||||
{ label: '短信签名', value: 'sms', content: smsSignatureContent },
|
||||
{ label: '彩信签名', pending: true, value: 'mms', content: <div className="ui-table__empty">彩信签名待后端能力确认,本页不展示演示数据。</div> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{signatureModal ? (
|
||||
<SignatureFormModal
|
||||
applications={applications}
|
||||
item={signatureModal === 'new' ? undefined : signatureModal}
|
||||
onClose={() => setSignatureModal(null)}
|
||||
onSubmit={(state) => { void saveSignature(state); }}
|
||||
tenants={tenants}
|
||||
/>
|
||||
) : null}
|
||||
{signatureReport ? <SignatureReportModal item={signatureReport} onClose={() => setSignatureReport(null)} /> : null}
|
||||
{drainageModal ? (
|
||||
<DrainageFormModal
|
||||
item={drainageModal.item}
|
||||
onClose={() => setDrainageModal(null)}
|
||||
onSubmit={(item) => { void saveDrainage(drainageModal.signatureId, item); }}
|
||||
/>
|
||||
) : null}
|
||||
{drainageReport ? <DrainageReportModal item={drainageReport} onClose={() => setDrainageReport(null)} /> : null}
|
||||
{deleteTarget ? (
|
||||
<ConfirmModal
|
||||
message={`确认删除“${deleteTarget.name}”吗?删除后会写入真实后台。`}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => { void confirmDelete(); }}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,391 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
|
||||
type TemplateFormState = {
|
||||
tenantId: string;
|
||||
applicationId: string;
|
||||
signatureId: string;
|
||||
name: string;
|
||||
content: string;
|
||||
category: string;
|
||||
variables: TemplateVariable[];
|
||||
};
|
||||
|
||||
type TemplateVariable = {
|
||||
name: string;
|
||||
example?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
const recommendedVariables = [
|
||||
['验证码', 'code'],
|
||||
['手机号', 'phone'],
|
||||
['姓名', 'name'],
|
||||
['日期', 'date'],
|
||||
['金额', 'amount'],
|
||||
['时间', 'time'],
|
||||
['余额', 'balance'],
|
||||
['地址', 'address'],
|
||||
['天数', 'days'],
|
||||
['快递单号', 'trackingNumber'],
|
||||
['案件号', 'caseNumber'],
|
||||
['链接', 'link'],
|
||||
['站点', 'station'],
|
||||
];
|
||||
|
||||
function extractVariables(content: string): TemplateVariable[] {
|
||||
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
|
||||
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString('zh-CN') : '-';
|
||||
}
|
||||
|
||||
function statusTone(status: string) {
|
||||
if (status === 'approved') return 'success';
|
||||
if (status === 'rejected') return 'danger';
|
||||
if (status === 'deleted') return 'neutral';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function billingUnits(content: string) {
|
||||
if (!content) {
|
||||
return 1;
|
||||
}
|
||||
return content.length <= 70 ? 1 : Math.ceil(content.length / 67);
|
||||
}
|
||||
|
||||
function TemplateFormModal({
|
||||
applications,
|
||||
item,
|
||||
onClose,
|
||||
onSubmit,
|
||||
signatures,
|
||||
tenants,
|
||||
}: {
|
||||
applications: ClientSmsApplication[];
|
||||
item?: ClientSmsTemplate;
|
||||
onClose: () => void;
|
||||
onSubmit: (state: TemplateFormState) => void;
|
||||
signatures: ClientSmsSignature[];
|
||||
tenants: TenantOption[];
|
||||
}) {
|
||||
const [customVariable, setCustomVariable] = useState('');
|
||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||
const [form, setForm] = useState<TemplateFormState>({
|
||||
tenantId: item?.tenantId ?? '',
|
||||
applicationId: item?.applicationId ?? '',
|
||||
signatureId: item?.signatureId ?? '',
|
||||
name: item?.name ?? '',
|
||||
content: item?.content ?? '',
|
||||
category: item?.category ?? '行业通知',
|
||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||
});
|
||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||
const tenantSignatures = signatures.filter((signature) => signature.tenantId === form.tenantId && signature.auditStatus !== 'deleted');
|
||||
const currentVariables = form.variables.length ? form.variables : extractVariables(form.content);
|
||||
|
||||
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function setContent(content: string) {
|
||||
setForm((current) => ({ ...current, content, variables: extractVariables(content) }));
|
||||
}
|
||||
|
||||
function insertVariable(name: string) {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
setContent(`${form.content}\${${normalized}}`);
|
||||
}
|
||||
|
||||
function updateVariableExample(name: string, example: string) {
|
||||
const variables = currentVariables.map((variable) => variable.name === name ? { ...variable, example } : variable);
|
||||
update('variables', variables);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.tenantId || !form.applicationId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="template-modal-title"><h2>{item ? '编辑短信模板' : '添加短信模板'}</h2><p>模板内容和变量将写入真实后台。</p></div>}
|
||||
>
|
||||
<div className="template-form">
|
||||
<Select
|
||||
disabled={Boolean(item)}
|
||||
label="所属企业"
|
||||
onChange={(event) => update('tenantId', event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择企业', value: '' },
|
||||
...tenants.map((tenant) => ({ label: `${tenant.name}(${tenant.code})`, value: tenant.id })),
|
||||
]}
|
||||
required
|
||||
value={form.tenantId}
|
||||
/>
|
||||
<Select
|
||||
label="所属应用"
|
||||
onChange={(event) => update('applicationId', event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择应用', value: '' },
|
||||
...tenantApplications.map((application) => ({ label: application.name, value: application.id })),
|
||||
]}
|
||||
required
|
||||
value={form.applicationId}
|
||||
/>
|
||||
<Select
|
||||
label="签名"
|
||||
onChange={(event) => update('signatureId', event.target.value)}
|
||||
options={[
|
||||
{ label: '不绑定签名', value: '' },
|
||||
...tenantSignatures.map((signature) => ({ label: signature.name, value: signature.id })),
|
||||
]}
|
||||
value={form.signatureId}
|
||||
/>
|
||||
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" required value={form.name} />
|
||||
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
|
||||
<Textarea
|
||||
label="模板内容"
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
placeholder="例如:尊敬的${name},您的验证码为${code}。"
|
||||
required
|
||||
rows={8}
|
||||
value={form.content}
|
||||
/>
|
||||
<div className="template-form-meta">
|
||||
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||||
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
||||
</button>
|
||||
<span>{form.content.length} 字符,计费 {billingUnits(form.content)} 条</span>
|
||||
</div>
|
||||
{variablesOpen ? (
|
||||
<div className="template-variable-panel">
|
||||
<h3>推荐变量</h3>
|
||||
<div className="template-variable-buttons">
|
||||
{recommendedVariables.map(([label, value]) => (
|
||||
<button key={value} onClick={() => insertVariable(value)} type="button">
|
||||
{label} ({value})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<h3>自定义变量</h3>
|
||||
<div className="template-custom-variable">
|
||||
<Input onChange={(event) => setCustomVariable(event.target.value)} placeholder="英文字符或数字" value={customVariable} />
|
||||
<Button onClick={() => { insertVariable(customVariable); setCustomVariable(''); }}>插入</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="template-variable-panel">
|
||||
<h3>变量示例</h3>
|
||||
{currentVariables.length ? currentVariables.map((variable) => (
|
||||
<Input
|
||||
key={variable.name}
|
||||
label={`\${${variable.name}}`}
|
||||
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
|
||||
placeholder="请输入变量示例值"
|
||||
value={variable.example ?? ''}
|
||||
/>
|
||||
)) : <p className="muted">模板内容中暂无变量。</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatePreviewModal({ item, onClose }: { item: ClientSmsTemplate; onClose: () => void }) {
|
||||
return (
|
||||
<Modal footer={<Button onClick={onClose}>关闭</Button>} onClose={onClose} open title="模板预览">
|
||||
<div className="detail-grid">
|
||||
<div><span>企业</span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
|
||||
<div><span>应用</span><strong>{item.application?.name ?? item.applicationId}</strong></div>
|
||||
<div><span>签名</span><strong>{item.signature?.name ?? '-'}</strong></div>
|
||||
<div><span>计费条数</span><strong>{billingUnits(item.content)} 条</strong></div>
|
||||
<div className="detail-grid__wide"><span>模板内容</span><strong>{item.content}</strong></div>
|
||||
<div className="detail-grid__wide"><span>变量</span><strong>{item.variables?.map((variable) => `\${${variable.name}}`).join('、') || '-'}</strong></div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onCancel} variant="ghost">取消</Button>
|
||||
<Button onClick={onConfirm} variant="danger">确认删除</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onCancel}
|
||||
open
|
||||
title="删除确认"
|
||||
>
|
||||
<p className="admin-confirm-text">{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminEnterpriseTemplatesPage() {
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [signatureItems, setSignatureItems] = useState<ClientSmsSignature[]>([]);
|
||||
const [templateModal, setTemplateModal] = useState<ClientSmsTemplate | 'new' | null>(null);
|
||||
const [templatePreview, setTemplatePreview] = useState<ClientSmsTemplate | null>(null);
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
|
||||
function loadData() {
|
||||
adminApi.listEnterpriseTemplates({ keyword })
|
||||
.then((items) => {
|
||||
setTemplates(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业模板加载失败'));
|
||||
async function loadData() {
|
||||
try {
|
||||
const [templateItems, tenantItems, applicationItems, signatureList] = await Promise.all([
|
||||
adminApi.listEnterpriseTemplates({ keyword: [enterpriseKeyword, templateKeyword].filter(Boolean).join(' ') }),
|
||||
adminApi.listTenants(),
|
||||
adminApi.listEnterpriseApplications(),
|
||||
adminApi.listEnterpriseSignatures(),
|
||||
]);
|
||||
setTemplates(templateItems);
|
||||
setTenants(tenantItems.filter((tenant) => tenant.status !== 'deleted'));
|
||||
setApplications(applicationItems);
|
||||
setSignatureItems(signatureList);
|
||||
setError('');
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '企业模板加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
void loadData();
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => templates.filter((item) => !keyword || [item.name, item.content, item.auditStatus, item.application?.name].join(' ').includes(keyword)), [keyword, templates]);
|
||||
const filteredTemplates = useMemo(() => templates.filter((item) => {
|
||||
const enterprise = item.tenant?.name ?? item.tenantId;
|
||||
const application = item.application?.name ?? '';
|
||||
return (!enterpriseKeyword || enterprise.includes(enterpriseKeyword))
|
||||
&& (!templateKeyword || item.name.includes(templateKeyword) || item.content.includes(templateKeyword) || application.includes(templateKeyword));
|
||||
}), [enterpriseKeyword, templateKeyword, templates]);
|
||||
|
||||
async function saveTemplate(state: TemplateFormState) {
|
||||
const existing = templateModal && templateModal !== 'new' ? templateModal : null;
|
||||
try {
|
||||
if (existing) {
|
||||
await adminApi.updateEnterpriseTemplate(existing.id, {
|
||||
applicationId: state.applicationId,
|
||||
category: state.category,
|
||||
content: state.content,
|
||||
name: state.name,
|
||||
signatureId: state.signatureId || null,
|
||||
variables: state.variables,
|
||||
});
|
||||
} else {
|
||||
await adminApi.createEnterpriseTemplate({
|
||||
applicationId: state.applicationId,
|
||||
category: state.category,
|
||||
content: state.content,
|
||||
name: state.name,
|
||||
signatureId: state.signatureId || undefined,
|
||||
tenantId: state.tenantId,
|
||||
variables: state.variables,
|
||||
});
|
||||
}
|
||||
setTemplateModal(null);
|
||||
await loadData();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '企业模板保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
await adminApi.changeEnterpriseTemplateStatus(deleteTarget.id, 'deleted', '运营端删除模板');
|
||||
setDeleteTarget(null);
|
||||
await loadData();
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<ClientSmsTemplate>> = [
|
||||
{ key: 'name', title: '模板名称', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'tenant', title: '企业', render: (record) => record.tenantId },
|
||||
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? record.applicationId },
|
||||
{ key: 'content', title: '内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||
{ key: 'status', title: '审核状态', render: (record) => <Tag tone={record.auditStatus === 'approved' ? 'success' : record.auditStatus === 'rejected' ? 'danger' : 'info'}>{record.auditStatus}</Tag> },
|
||||
{ key: 'updatedAt', title: '更新时间', render: (record) => record.updatedAt },
|
||||
{ key: 'name', title: '模板名称', width: '180px', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'tenant', title: '企业', width: '240px', render: (record) => record.tenant?.name ?? record.tenantId },
|
||||
{ key: 'application', title: '应用', width: '180px', render: (record) => record.application?.name ?? record.applicationId },
|
||||
{ key: 'signature', title: '签名', width: '160px', render: (record) => record.signature?.name ?? '-' },
|
||||
{ key: 'content', title: '模板内容', width: '420px', render: (record) => <span className="table-long-text table-long-text--sms-template">{record.content}</span> },
|
||||
{ key: 'variables', title: '变量', width: '120px', render: (record) => `${record.variables?.length ?? 0} 个` },
|
||||
{ key: 'status', title: '审核状态', width: '130px', render: (record) => <Tag tone={statusTone(record.auditStatus)}>{record.auditStatus}</Tag> },
|
||||
{ key: 'updatedAt', title: '更新时间', width: '170px', render: (record) => formatDate(record.updatedAt) },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '220px',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(record)} size="sm" variant="ghost">预览</Button>
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget(record)} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<section className="page-stack admin-customer-split-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['企业配置', '企业模板']} />
|
||||
<h1>企业模板</h1>
|
||||
<Breadcrumb items={['客户管理', '企业模板管理']} />
|
||||
<h1>企业模板管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setTemplateModal(activeTab === 'sms' ? 'new' : null)}>添加模板</Button>
|
||||
</div>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
<div className="surface admin-security-filter">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索模板、应用、内容或状态" prefix={<Search size={16} />} value={keyword} />
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<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) => setTemplateKeyword(event.target.value)} placeholder="请输入模板、应用或内容" prefix={<Search size={16} />} value={templateKeyword} />
|
||||
<Button onClick={() => { setEnterpriseKeyword(''); setTemplateKeyword(''); void loadData(); }} variant="ghost">重置</Button>
|
||||
<Button icon={<Search size={16} />} onClick={() => { void loadData(); }}>查询</Button>
|
||||
</div>
|
||||
<div className="surface">
|
||||
<Table columns={columns} data={filtered} emptyText="暂无企业模板" rowKey="id" />
|
||||
<div className="surface section-stack">
|
||||
<Tabs
|
||||
onChange={(value) => setActiveTab(value as 'sms' | 'mms')}
|
||||
value={activeTab}
|
||||
items={[
|
||||
{ label: '短信模板', value: 'sms', content: <Table columns={columns} data={filteredTemplates} emptyText="暂无企业模板" rowKey="id" /> },
|
||||
{ label: '彩信模板', pending: true, value: 'mms', content: <div className="ui-table__empty">彩信模板待后端能力确认,本页不展示演示数据。</div> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{templateModal ? (
|
||||
<TemplateFormModal
|
||||
applications={applications}
|
||||
item={templateModal === 'new' ? undefined : templateModal}
|
||||
onClose={() => setTemplateModal(null)}
|
||||
onSubmit={(state) => { void saveTemplate(state); }}
|
||||
signatures={signatureItems}
|
||||
tenants={tenants}
|
||||
/>
|
||||
) : null}
|
||||
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null}
|
||||
{deleteTarget ? (
|
||||
<ConfirmModal
|
||||
message={`确认删除模板“${deleteTarget.name}”吗?删除后会写入真实后台。`}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => { void confirmDelete(); }}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function AdminGlobalBlacklistPage() {
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '110px',
|
||||
width: '130px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteGlobalBlacklist(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, RefreshCw } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
|
||||
|
||||
const channelOptions = {
|
||||
mobile: [
|
||||
{ label: '移动通道A', value: 'mobile-a' },
|
||||
{ label: '移动通道B', value: 'mobile-b' },
|
||||
],
|
||||
unicom: [
|
||||
{ label: '联通通道B', value: 'unicom-b' },
|
||||
{ label: '联通通道C', value: 'unicom-c' },
|
||||
],
|
||||
telecom: [
|
||||
{ label: '电信通道C', value: 'telecom-c' },
|
||||
{ label: '电信通道D', value: 'telecom-d' },
|
||||
],
|
||||
};
|
||||
|
||||
function generateCode(prefix: string) {
|
||||
return `${prefix}${Math.random().toString(36).slice(2, 8).toUpperCase()}`;
|
||||
}
|
||||
|
||||
export function AdminMmsApplicationFormPage() {
|
||||
const navigate = useNavigate();
|
||||
const { enterpriseId, appId } = useParams();
|
||||
const enterpriseName = enterpriseId ? `企业 ${enterpriseId}` : '当前企业';
|
||||
const isEdit = Boolean(appId);
|
||||
const [appName, setAppName] = useState(isEdit ? '示例彩信应用' : '');
|
||||
const [unitPrice, setUnitPrice] = useState(isEdit ? '0.0300' : '');
|
||||
const [mobileChannel, setMobileChannel] = useState('mobile-a');
|
||||
const [unicomChannel, setUnicomChannel] = useState('unicom-b');
|
||||
const [telecomChannel, setTelecomChannel] = useState('telecom-c');
|
||||
const [dailyLimit, setDailyLimit] = useState(isEdit ? '100000' : '');
|
||||
const [phoneDailyLimit, setPhoneDailyLimit] = useState(isEdit ? '10' : '');
|
||||
const [mmsEnabled, setMmsEnabled] = useState(true);
|
||||
const [ipAddress, setIpAddress] = useState(isEdit ? '192.168.1.100' : '');
|
||||
const [connectionCount, setConnectionCount] = useState(isEdit ? '2' : '');
|
||||
const [enterpriseCode, setEnterpriseCode] = useState(isEdit ? 'ABC123' : generateCode('ME'));
|
||||
const [interfaceAccount, setInterfaceAccount] = useState(isEdit ? 'ABC123' : generateCode('MA'));
|
||||
const [interfacePassword, setInterfacePassword] = useState(isEdit ? '************' : generateCode('MP'));
|
||||
const [accessNumber, setAccessNumber] = useState(isEdit ? '1069' : '');
|
||||
const [nameError, setNameError] = useState('');
|
||||
|
||||
function goBack() {
|
||||
navigate(`/admin/customers/${enterpriseId ?? ''}`);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
if (!appName.trim()) {
|
||||
setNameError('请填写应用名称');
|
||||
return;
|
||||
}
|
||||
goBack();
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-app-form-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={[isEdit ? '编辑彩信应用' : '添加彩信应用']} />
|
||||
<p>{enterpriseName} 的彩信应用配置(彩信能力待开发)。</p>
|
||||
</div>
|
||||
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">
|
||||
返回企业详情
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-app-form-card">
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>业务信息</h3>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<Input
|
||||
error={nameError}
|
||||
label="应用名称"
|
||||
onChange={(event) => {
|
||||
setAppName(event.target.value);
|
||||
setNameError('');
|
||||
}}
|
||||
placeholder="请输入应用名称"
|
||||
required
|
||||
value={appName}
|
||||
/>
|
||||
<Input
|
||||
label="编ID(元)"
|
||||
onChange={(event) => setUnitPrice(event.target.value)}
|
||||
placeholder="0.0300"
|
||||
required
|
||||
suffix={<span className="admin-app-form-price-note">(3.0000)</span>}
|
||||
value={unitPrice}
|
||||
/>
|
||||
<Select label="发送通道-移动" onChange={(event) => setMobileChannel(event.target.value)} options={channelOptions.mobile} required value={mobileChannel} />
|
||||
<Select label="发送通道-联通" onChange={(event) => setUnicomChannel(event.target.value)} options={channelOptions.unicom} required value={unicomChannel} />
|
||||
<Select label="发送通道-电信" onChange={(event) => setTelecomChannel(event.target.value)} options={channelOptions.telecom} required value={telecomChannel} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>安全策略</h3>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
|
||||
<Input label="每号码日发送频次限制" onChange={(event) => setPhoneDailyLimit(event.target.value)} placeholder="10" required value={phoneDailyLimit} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<h3>接口配置</h3>
|
||||
</div>
|
||||
<div className="admin-app-form-grid">
|
||||
<div className="admin-app-form-row admin-app-form-row--wide">
|
||||
<span>彩信接口</span>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input checked={mmsEnabled} onChange={() => setMmsEnabled(true)} type="radio" />
|
||||
开通
|
||||
</label>
|
||||
<label>
|
||||
<input checked={!mmsEnabled} onChange={() => setMmsEnabled(false)} type="radio" />
|
||||
关闭
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<Input label="IP地址" onChange={(event) => setIpAddress(event.target.value)} placeholder="请输入 IP 地址" required value={ipAddress} />
|
||||
<Input label="连接数" onChange={(event) => setConnectionCount(event.target.value)} placeholder="请输入连接数" required value={connectionCount} />
|
||||
<Input
|
||||
label="企业代码"
|
||||
onChange={(event) => setEnterpriseCode(event.target.value)}
|
||||
required
|
||||
suffix={<Button icon={<RefreshCw size={14} />} onClick={() => setEnterpriseCode(generateCode('ME'))} size="sm" variant="ghost">生成</Button>}
|
||||
value={enterpriseCode}
|
||||
/>
|
||||
<Input label="接口账号" onChange={(event) => setInterfaceAccount(event.target.value)} required value={interfaceAccount} />
|
||||
<Input
|
||||
label="接口密码"
|
||||
onChange={(event) => setInterfacePassword(event.target.value)}
|
||||
required
|
||||
suffix={<Button icon={<RefreshCw size={14} />} onClick={() => setInterfacePassword(generateCode('MP'))} size="sm" variant="ghost">生成</Button>}
|
||||
value={interfacePassword}
|
||||
/>
|
||||
<Input label="接入号" onChange={(event) => setAccessNumber(event.target.value)} placeholder="请输入接入号" required value={accessNumber} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="enterprise-form-footer">
|
||||
<Button onClick={submit}>确认</Button>
|
||||
<Button onClick={goBack} variant="ghost">返回</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Ban, Pencil, Plus, Power, Search, Trash2 } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tag, Textarea } from '@/components/ui';
|
||||
import type { TableColumn } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
type MmsChannelStatus = 'active' | 'inactive';
|
||||
|
||||
type MmsChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
carrier: Carrier;
|
||||
endpoint: string;
|
||||
status: MmsChannelStatus;
|
||||
unitPrice: number;
|
||||
priority: number;
|
||||
dailyLimit: number;
|
||||
description: string;
|
||||
total: number;
|
||||
successRate: number;
|
||||
successCount: number;
|
||||
unknownRate: number;
|
||||
unknownCount: number;
|
||||
failureRate: number;
|
||||
failureCount: number;
|
||||
};
|
||||
|
||||
type MmsChannelModalState = {
|
||||
mode: 'create' | 'edit';
|
||||
channel?: MmsChannel;
|
||||
};
|
||||
|
||||
const carrierOptions = [
|
||||
{ label: '全部运营商', value: 'all' },
|
||||
{ label: '移动', value: 'mobile' },
|
||||
{ label: '联通', value: 'unicom' },
|
||||
{ label: '电信', value: 'telecom' },
|
||||
];
|
||||
|
||||
const formCarrierOptions = carrierOptions.slice(1);
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '正常', value: 'active' },
|
||||
{ label: '停用', value: 'inactive' },
|
||||
];
|
||||
|
||||
const formStatusOptions = statusOptions.slice(1);
|
||||
|
||||
const carrierMeta: Record<Carrier, { label: string; tone: 'info' | 'danger' | 'success' }> = {
|
||||
mobile: { label: '移动', tone: 'info' },
|
||||
unicom: { label: '联通', tone: 'danger' },
|
||||
telecom: { label: '电信', tone: 'success' },
|
||||
};
|
||||
|
||||
const initialChannels: MmsChannel[] = [
|
||||
{ id: 'mmschannel001', name: '移动彩信通道A', carrier: 'mobile', endpoint: 'https://mms-api.example.com/mobile/a', status: 'active', unitPrice: 0.18, priority: 1, dailyLimit: 10000, description: '移动主通道', total: 5240, successRate: 92.5, successCount: 4847, unknownRate: 3.2, unknownCount: 168, failureRate: 4.3, failureCount: 225 },
|
||||
{ id: 'mmschannel002', name: '联通彩信通道A', carrier: 'unicom', endpoint: 'https://mms-api.example.com/unicom/a', status: 'active', unitPrice: 0.16, priority: 2, dailyLimit: 8000, description: '联通主通道', total: 3820, successRate: 89.8, successCount: 3431, unknownRate: 5.1, unknownCount: 195, failureRate: 5.1, failureCount: 194 },
|
||||
{ id: 'mmschannel003', name: '电信彩信通道A', carrier: 'telecom', endpoint: 'https://mms-api.example.com/telecom/a', status: 'active', unitPrice: 0.2, priority: 1, dailyLimit: 12000, description: '电信主通道', total: 6580, successRate: 94.2, successCount: 6200, unknownRate: 2.8, unknownCount: 184, failureRate: 3, failureCount: 196 },
|
||||
{ id: 'mmschannel004', name: '移动彩信通道B', carrier: 'mobile', endpoint: 'https://mms-api.example.com/mobile/b', status: 'inactive', unitPrice: 0.19, priority: 3, dailyLimit: 5000, description: '移动备用通道', total: 0, successRate: 0, successCount: 0, unknownRate: 0, unknownCount: 0, failureRate: 0, failureCount: 0 },
|
||||
];
|
||||
|
||||
function Metric({ label, rate, count, tone }: { label: string; rate: number; count: number; tone: 'success' | 'warning' | 'danger' }) {
|
||||
return (
|
||||
<span className={`mms-channel-metric mms-channel-metric--${tone}`}>
|
||||
<small>{label}</small>
|
||||
<strong>{rate}%</strong>
|
||||
<b>{count.toLocaleString('zh-CN')}</b>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MmsChannelFormModal({
|
||||
modal,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
modal: MmsChannelModalState;
|
||||
onClose: () => void;
|
||||
onSubmit: (channel: MmsChannel) => void;
|
||||
}) {
|
||||
const channel = modal.channel;
|
||||
const [name, setName] = useState(channel?.name ?? '');
|
||||
const [carrier, setCarrier] = useState<Carrier>(channel?.carrier ?? 'mobile');
|
||||
const [endpoint, setEndpoint] = useState(channel?.endpoint ?? '');
|
||||
const [status, setStatus] = useState<MmsChannelStatus>(channel?.status ?? 'active');
|
||||
const [priority, setPriority] = useState(String(channel?.priority ?? 1));
|
||||
const [dailyLimit, setDailyLimit] = useState(String(channel?.dailyLimit ?? 10000));
|
||||
const [unitPrice, setUnitPrice] = useState(String(channel?.unitPrice ?? 0.18));
|
||||
const [description, setDescription] = useState(channel?.description ?? '');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const nameError = submitted && !name.trim() ? '请输入通道名称' : '';
|
||||
const endpointError = submitted && !endpoint.trim() ? '请输入 API 端点' : '';
|
||||
|
||||
function submit() {
|
||||
setSubmitted(true);
|
||||
if (!name.trim() || !endpoint.trim()) return;
|
||||
onSubmit({
|
||||
id: channel?.id ?? `mmschannel${String(Date.now()).slice(-4)}`,
|
||||
name: name.trim(),
|
||||
carrier,
|
||||
endpoint: endpoint.trim(),
|
||||
status,
|
||||
unitPrice: Number(unitPrice || 0),
|
||||
priority: Number(priority || 1),
|
||||
dailyLimit: Number(dailyLimit || 0),
|
||||
description,
|
||||
total: channel?.total ?? 0,
|
||||
successRate: channel?.successRate ?? 0,
|
||||
successCount: channel?.successCount ?? 0,
|
||||
unknownRate: channel?.unknownRate ?? 0,
|
||||
unknownCount: channel?.unknownCount ?? 0,
|
||||
failureRate: channel?.failureRate ?? 0,
|
||||
failureCount: channel?.failureCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button onClick={submit}>保存</Button></>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<div className="mms-channel-modal-title"><h2>{modal.mode === 'create' ? '添加彩信通道' : '编辑彩信通道'}</h2><p>{modal.mode === 'create' ? '添加新的彩信发送通道' : '修改彩信发送通道配置'}</p></div>}
|
||||
>
|
||||
<div className="mms-channel-form">
|
||||
<Input error={nameError} label="通道名称 *" onChange={(event) => setName(event.target.value)} placeholder="如:移动彩信通道A" value={name} />
|
||||
<Select label="运营商" onChange={(event) => setCarrier(event.target.value as Carrier)} options={formCarrierOptions} value={carrier} />
|
||||
<Input className="mms-channel-form__wide" error={endpointError} label="API 端点 *" onChange={(event) => setEndpoint(event.target.value)} placeholder="https://api.example.com/v1/mms" value={endpoint} />
|
||||
<Select label="状态" onChange={(event) => setStatus(event.target.value as MmsChannelStatus)} options={formStatusOptions} value={status} />
|
||||
<Input label="优先级" min="1" onChange={(event) => setPriority(event.target.value)} type="number" value={priority} />
|
||||
<Input label="日限额" min="0" onChange={(event) => setDailyLimit(event.target.value)} type="number" value={dailyLimit} />
|
||||
<Input label="成本单价(元)" min="0" onChange={(event) => setUnitPrice(event.target.value)} step="0.01" type="number" value={unitPrice} />
|
||||
<Textarea className="mms-channel-form__wide" label="描述" onChange={(event) => setDescription(event.target.value)} placeholder="请输入通道描述" rows={4} value={description} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminMmsChannelsPage() {
|
||||
const [channels, setChannels] = useState(initialChannels);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [carrier, setCarrier] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [modal, setModal] = useState<MmsChannelModalState | null>(null);
|
||||
|
||||
const filteredChannels = useMemo(() => channels.filter((channel) => (
|
||||
(!keyword || `${channel.id}${channel.name}${channel.endpoint}`.toLowerCase().includes(keyword.toLowerCase()))
|
||||
&& (carrier === 'all' || channel.carrier === carrier)
|
||||
&& (status === 'all' || channel.status === status)
|
||||
)), [carrier, channels, keyword, status]);
|
||||
|
||||
function upsertChannel(nextChannel: MmsChannel) {
|
||||
setChannels((items) => items.some((item) => item.id === nextChannel.id)
|
||||
? items.map((item) => item.id === nextChannel.id ? nextChannel : item)
|
||||
: [nextChannel, ...items]);
|
||||
setModal(null);
|
||||
}
|
||||
|
||||
function toggleChannel(id: string) {
|
||||
setChannels((items) => items.map((item) => item.id === id
|
||||
? { ...item, status: item.status === 'active' ? 'inactive' : 'active' }
|
||||
: item));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<MmsChannel>> = [
|
||||
{ key: 'identity', title: '通道信息', width: '220px', render: (item) => <div className="mms-channel-identity"><strong>{item.name}</strong><span>{item.id}</span><small>{item.endpoint}</small></div> },
|
||||
{ key: 'carrier', title: '运营商 / 成本', width: '112px', render: (item) => <div className="mms-channel-carrier"><Tag tone={carrierMeta[item.carrier].tone}>{carrierMeta[item.carrier].label}</Tag><strong>¥{item.unitPrice.toFixed(2)}</strong></div> },
|
||||
{ key: 'status', title: '状态', width: '86px', render: (item) => <Tag tone={item.status === 'active' ? 'success' : 'neutral'}>{item.status === 'active' ? '正常' : '停用'}</Tag> },
|
||||
{ key: 'total', title: '今日总数', width: '90px', render: (item) => <strong>{item.total.toLocaleString('zh-CN')}</strong> },
|
||||
{ key: 'quality', title: '今日发送质量', width: '260px', render: (item) => <div className="mms-channel-quality"><Metric count={item.successCount} label="成功" rate={item.successRate} tone="success" /><Metric count={item.unknownCount} label="未知" rate={item.unknownRate} tone="warning" /><Metric count={item.failureCount} label="失败" rate={item.failureRate} tone="danger" /></div> },
|
||||
{ key: 'actions', title: '操作', align: 'right', width: '140px', render: (item) => <div className="mms-channel-actions"><Button aria-label={item.status === 'active' ? '停用' : '启用'} icon={item.status === 'active' ? <Ban size={16} /> : <Power size={16} />} iconOnly onClick={() => toggleChannel(item.id)} title={item.status === 'active' ? '停用' : '启用'} variant="ghost">{item.status === 'active' ? '停用' : '启用'}</Button><Button aria-label="编辑" icon={<Pencil size={16} />} iconOnly onClick={() => setModal({ mode: 'edit', channel: item })} title="编辑" variant="ghost">编辑</Button><Button aria-label="删除" icon={<Trash2 size={16} />} iconOnly onClick={() => setChannels((items) => items.filter((channel) => channel.id !== item.id))} title="删除" variant="danger">删除</Button></div> },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack mms-channel-page">
|
||||
<div className="page-heading">
|
||||
<div><Breadcrumb items={['彩信通道管理']} /></div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => setModal({ mode: 'create' })}>添加通道</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface mms-channel-filter">
|
||||
<Input onChange={(event) => setKeyword(event.target.value)} placeholder="搜索通道名称、ID 或 API 端点" prefix={<Search size={16} />} value={keyword} />
|
||||
<Select onChange={(event) => setCarrier(event.target.value)} options={carrierOptions} value={carrier} />
|
||||
<Select onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<Button onClick={() => { setKeyword(''); setCarrier('all'); setStatus('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface mms-channel-list">
|
||||
<Table columns={columns} data={filteredChannels} emptyText="暂无符合条件的彩信通道" rowKey="id" />
|
||||
<Pagination total={filteredChannels.length} />
|
||||
</div>
|
||||
|
||||
{modal ? <MmsChannelFormModal modal={modal} onClose={() => setModal(null)} onSubmit={upsertChannel} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Download, Eye, Search, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
Select,
|
||||
type DateRangeValue,
|
||||
} from '@/components/ui';
|
||||
|
||||
type SendStatus = 'success' | 'unknown' | 'failed';
|
||||
|
||||
type MmsRecord = {
|
||||
id: string;
|
||||
enterprise: string;
|
||||
application: string;
|
||||
submittedAt: string;
|
||||
templateName: string;
|
||||
templateId: string;
|
||||
frames: number;
|
||||
sizeKb: number;
|
||||
title: string;
|
||||
content: string;
|
||||
image: string;
|
||||
phone: string;
|
||||
carrier: string;
|
||||
region: string;
|
||||
channel: string;
|
||||
status: SendStatus;
|
||||
receiptAt?: string;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<SendStatus, string> = {
|
||||
success: '发送成功',
|
||||
unknown: '未知',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const statusDotClassMap: Record<SendStatus, string> = {
|
||||
success: 'is-success',
|
||||
unknown: 'is-unknown',
|
||||
failed: 'is-failed',
|
||||
};
|
||||
|
||||
const recordsSeed: MmsRecord[] = [
|
||||
{
|
||||
id: 'MMSR202512310001',
|
||||
enterprise: '四川骠骑企业管理',
|
||||
application: '应用1',
|
||||
submittedAt: '2025-12-31 18:00:02',
|
||||
templateName: '春节促销活动模板',
|
||||
templateId: 'TPL001',
|
||||
frames: 3,
|
||||
sizeKb: 856,
|
||||
title: '春节促销活动模板',
|
||||
content: '【骠骑科技】春节促销活动开始啦!精选商品低至5折,多重优惠叠加,点击查看活动详情。',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13675569095',
|
||||
carrier: '中国移动',
|
||||
region: '成都',
|
||||
channel: '移动彩信通道A - mmschannel001',
|
||||
status: 'success',
|
||||
receiptAt: '2025-12-31 18:01:02',
|
||||
},
|
||||
{
|
||||
id: 'MMSR202512310002',
|
||||
enterprise: '重庆进载数智',
|
||||
application: '应用2',
|
||||
submittedAt: '2025-12-31 11:49:10',
|
||||
templateName: '产品发布会邀请函',
|
||||
templateId: 'TPL002',
|
||||
frames: 3,
|
||||
sizeKb: 1245,
|
||||
title: '产品发布会邀请函',
|
||||
content: '【进载数智】诚邀您参加新品发布会,现场将展示全新智能终端与行业解决方案。',
|
||||
image: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '18607638087',
|
||||
carrier: '中国联通',
|
||||
region: '重庆',
|
||||
channel: '联通彩信通道A - mmschannel002',
|
||||
status: 'unknown',
|
||||
},
|
||||
{
|
||||
id: 'MMSR202512310003',
|
||||
enterprise: '行业',
|
||||
application: '应用3',
|
||||
submittedAt: '2025-12-31 11:49:08',
|
||||
templateName: '会员积分兑换通知',
|
||||
templateId: 'TPL003',
|
||||
frames: 2,
|
||||
sizeKb: 512,
|
||||
title: '会员积分兑换通知',
|
||||
content: '【会员中心】您的积分可兑换多款权益礼包,请及时查看并领取。',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '15012345678',
|
||||
carrier: '未知',
|
||||
region: '未知',
|
||||
channel: '',
|
||||
status: 'unknown',
|
||||
},
|
||||
{
|
||||
id: 'MMSR202512310004',
|
||||
enterprise: '超感世纪互三网',
|
||||
application: '应用4',
|
||||
submittedAt: '2025-12-31 11:47:23',
|
||||
templateName: '理财产品推荐',
|
||||
templateId: 'TPL004',
|
||||
frames: 3,
|
||||
sizeKb: 980,
|
||||
title: '理财产品推荐',
|
||||
content: '【南京邮银】为您推荐全新理财产品,图文详情请查看彩信内容。',
|
||||
image: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '15250668026',
|
||||
carrier: '中国电信',
|
||||
region: '南京',
|
||||
channel: '电信彩信通道A - mmschannel003',
|
||||
status: 'failed',
|
||||
receiptAt: '2025-12-31 11:48:23',
|
||||
},
|
||||
{
|
||||
id: 'MMSR202512310005',
|
||||
enterprise: '行业',
|
||||
application: '应用5',
|
||||
submittedAt: '2025-12-31 11:45:18',
|
||||
templateName: '招聘信息模板',
|
||||
templateId: 'TPL005',
|
||||
frames: 2,
|
||||
sizeKb: 640,
|
||||
title: '招聘信息模板',
|
||||
content: '【招聘中心】岗位热招中,欢迎投递简历,查看岗位详情和福利待遇。',
|
||||
image: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13800138000',
|
||||
carrier: '中国移动',
|
||||
region: '上海',
|
||||
channel: '移动彩信通道A - mmschannel001',
|
||||
status: 'success',
|
||||
receiptAt: '2025-12-31 11:46:12',
|
||||
},
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
function StatusLine({ status }: { status: SendStatus }) {
|
||||
return (
|
||||
<span className="admin-sms-record-status">
|
||||
<i className={statusDotClassMap[status]} />
|
||||
{statusLabelMap[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewModal({ record, onClose }: { record: MmsRecord; onClose: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
title={<div className="template-modal-title"><h2>彩信预览</h2><p>{record.templateName}</p></div>}
|
||||
>
|
||||
<div className="mms-preview">
|
||||
<img alt={record.title} src={record.image} />
|
||||
<h3>{record.title}</h3>
|
||||
<p>{record.content}</p>
|
||||
<div className="mms-preview-frames">
|
||||
<span>{record.frames} 帧</span>
|
||||
<span>{record.sizeKb}KB</span>
|
||||
<span>{record.templateId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminMmsRecordsPage() {
|
||||
const [enterprise, setEnterprise] = useState('all');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [templateKeyword, setTemplateKeyword] = useState('');
|
||||
const [channelKeyword, setChannelKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [previewRecord, setPreviewRecord] = useState<MmsRecord | null>(null);
|
||||
|
||||
const enterpriseOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(recordsSeed.map((item) => item.enterprise)));
|
||||
return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, []);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(recordsSeed.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.application)));
|
||||
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, [enterprise]);
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => recordsSeed.filter((item) => {
|
||||
const submittedDate = getDate(item.submittedAt);
|
||||
const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise;
|
||||
const matchesApplication = application === 'all' || item.application === application;
|
||||
const matchesStartDate = !dateRange.start || submittedDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || submittedDate <= dateRange.end;
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
const matchesTemplate = !templateKeyword || item.templateName.includes(templateKeyword) || item.templateId.includes(templateKeyword);
|
||||
const matchesChannel = !channelKeyword || item.channel.includes(channelKeyword);
|
||||
const matchesStatus = status === 'all' || item.status === status;
|
||||
return matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate && matchesPhone && matchesTemplate && matchesChannel && matchesStatus;
|
||||
}),
|
||||
[application, channelKeyword, dateRange.end, dateRange.start, enterprise, phoneKeyword, status, templateKeyword],
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
setEnterprise('all');
|
||||
setApplication('all');
|
||||
setDateRange({});
|
||||
setPhoneKeyword('');
|
||||
setTemplateKeyword('');
|
||||
setChannelKeyword('');
|
||||
setStatus('all');
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-sms-records-page admin-mms-records-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['数据详单', '彩信记录']} />
|
||||
<h1>彩信记录</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-sms-record-filter">
|
||||
<Select
|
||||
label="企业"
|
||||
onChange={(event) => {
|
||||
setEnterprise(event.target.value);
|
||||
setApplication('all');
|
||||
}}
|
||||
options={enterpriseOptions}
|
||||
value={enterprise}
|
||||
/>
|
||||
<Select label="应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交日期" onChange={setDateRange} value={dateRange} />
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
<Input label="彩信模板名称" onChange={(event) => setTemplateKeyword(event.target.value)} value={templateKeyword} />
|
||||
<Input label="通道名称" onChange={(event) => setChannelKeyword(event.target.value)} value={channelKeyword} />
|
||||
<Select
|
||||
label="发送状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '发送成功', value: 'success' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<div className="admin-sms-record-filter__actions">
|
||||
<Button className="admin-mms-record-search-button" icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-sms-record-table-card admin-mms-record-table-card">
|
||||
<div className="admin-sms-record-toolbar">
|
||||
<Button icon={<Download size={16} />} variant="ghost">导出CSV</Button>
|
||||
</div>
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table admin-sms-record-table admin-mms-record-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '190px' }}>发送者</th>
|
||||
<th style={{ width: '260px' }}>彩信模板名称</th>
|
||||
<th style={{ width: '180px' }}>手机号码</th>
|
||||
<th>通道与发送状态</th>
|
||||
<th style={{ textAlign: 'right', width: '160px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="ui-table__empty" colSpan={5}>暂无彩信记录</td>
|
||||
</tr>
|
||||
) : filteredRows.map((record) => (
|
||||
<tr key={record.id}>
|
||||
<td>
|
||||
<div className="admin-sms-record-sender">
|
||||
<strong>{record.enterprise}</strong>
|
||||
<span>{record.application}</span>
|
||||
<small>{record.submittedAt.slice(0, 10)} {record.submittedAt.slice(11)}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-mms-template-cell">
|
||||
<strong>{record.templateName}</strong>
|
||||
<span>ID: {record.templateId}</span>
|
||||
<small>{record.frames} 帧 · {record.sizeKb}KB</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-sms-record-phone">
|
||||
<strong>{record.phone}</strong>
|
||||
<span>{record.region} {record.carrier}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="admin-sms-record-channel">
|
||||
{record.channel ? <strong>{record.channel}</strong> : null}
|
||||
<StatusLine status={record.status} />
|
||||
{record.receiptAt ? <span>{record.receiptAt}</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<button className="admin-mms-preview-link" onClick={() => setPreviewRecord(record)} type="button">
|
||||
<Eye size={16} />
|
||||
模板预览
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Pagination total={filteredRows.length} />
|
||||
</div>
|
||||
|
||||
{previewRecord ? <PreviewModal onClose={() => setPreviewRecord(null)} record={previewRecord} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { BarChart3, CalendarClock, Eye, FileImage, ImageIcon, Search, TrendingUp } from 'lucide-react';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
DateRangeInput,
|
||||
DetailInfoGrid,
|
||||
DetailProgressStats,
|
||||
DetailSection,
|
||||
DetailTitle,
|
||||
getRateTone,
|
||||
Input,
|
||||
Modal,
|
||||
Pagination,
|
||||
ProgressBar,
|
||||
RateCard,
|
||||
RateOverview,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type MmsTaskStatus = 'completed' | 'sending' | 'terminated' | 'failed';
|
||||
type SendType = 'immediate' | 'scheduled';
|
||||
|
||||
type CarrierStat = {
|
||||
name: string;
|
||||
success: number;
|
||||
total: number;
|
||||
rate: number;
|
||||
};
|
||||
|
||||
type CityStat = {
|
||||
city: string;
|
||||
total: number;
|
||||
success: number;
|
||||
};
|
||||
|
||||
type MmsTask = {
|
||||
id: string;
|
||||
enterprise: string;
|
||||
applicationName: string;
|
||||
submittedAt: string;
|
||||
title: string;
|
||||
content: string;
|
||||
image: string;
|
||||
attachment: string;
|
||||
phoneCount: number;
|
||||
sentCount: number;
|
||||
totalCount: number;
|
||||
sendType: SendType;
|
||||
scheduledAt?: string;
|
||||
status: MmsTaskStatus;
|
||||
carrierStats: CarrierStat[];
|
||||
cityStats: CityStat[];
|
||||
};
|
||||
|
||||
const statusToneMap: Record<MmsTaskStatus, 'success' | 'info' | 'neutral' | 'danger'> = {
|
||||
completed: 'success',
|
||||
sending: 'info',
|
||||
terminated: 'neutral',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<MmsTaskStatus, string> = {
|
||||
completed: '已完成',
|
||||
sending: '发送中',
|
||||
terminated: '已终止',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const sendTypeLabels: Record<SendType, string> = {
|
||||
immediate: '立即发送',
|
||||
scheduled: '定时发送',
|
||||
};
|
||||
|
||||
const defaultCarrierStats: CarrierStat[] = [
|
||||
{ name: '中国移动', success: 1450, total: 1502, rate: 96.5 },
|
||||
{ name: '中国联通', success: 1138, total: 1200, rate: 94.8 },
|
||||
{ name: '中国电信', success: 762, total: 800, rate: 95.2 },
|
||||
];
|
||||
|
||||
const defaultCityStats: CityStat[] = [
|
||||
{ city: '成都', total: 1250, success: 1200 },
|
||||
{ city: '重庆', total: 1000, success: 950 },
|
||||
{ city: '绵阳', total: 600, success: 570 },
|
||||
{ city: '泸州', total: 500, success: 475 },
|
||||
{ city: '宜宾', total: 300, success: 285 },
|
||||
];
|
||||
|
||||
const taskSeed: MmsTask[] = [
|
||||
{
|
||||
id: 'MMS202603120001',
|
||||
enterprise: '四川骠骑企业管理',
|
||||
applicationName: '营销应用1',
|
||||
submittedAt: '2026-03-12 09:30:15',
|
||||
title: '春季新品发布会邀请函',
|
||||
content: '【骠骑科技】尊敬的客户,我们诚挚邀请您参加春季新品发布会,现场将展示多款创新产品,精彩活动等您参与!活动时间:3月20日下午2点,期待您的光临!',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '3张图片',
|
||||
phoneCount: 5000,
|
||||
sentCount: 3500,
|
||||
totalCount: 5000,
|
||||
sendType: 'immediate',
|
||||
status: 'sending',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
{
|
||||
id: 'MMS202603120002',
|
||||
enterprise: '重庆进载数智',
|
||||
applicationName: '通知应用',
|
||||
submittedAt: '2026-03-12 10:15:00',
|
||||
title: '会员升级通知',
|
||||
content: '【进载数智】尊敬的VIP会员,恭喜您的会员等级已升级至钻石级别!查看您的专属权益,享受更多优质服务。',
|
||||
image: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '2张图片',
|
||||
phoneCount: 3000,
|
||||
sentCount: 3000,
|
||||
totalCount: 3000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-13 08:00:00',
|
||||
status: 'completed',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
{
|
||||
id: 'MMS202603120003',
|
||||
enterprise: '超感世纪三三网',
|
||||
applicationName: '推广应用2',
|
||||
submittedAt: '2026-03-12 11:20:00',
|
||||
title: '限时优惠活动',
|
||||
content: '【超感世纪】春季大促来袭!全场商品5折起,精选商品低至3折!更有满减优惠,买一送一活动等您参与。',
|
||||
image: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '4张图片',
|
||||
phoneCount: 8000,
|
||||
sentCount: 6000,
|
||||
totalCount: 8000,
|
||||
sendType: 'immediate',
|
||||
status: 'sending',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
{
|
||||
id: 'MMS202603120004',
|
||||
enterprise: '重庆香惠慧',
|
||||
applicationName: '客服应用',
|
||||
submittedAt: '2026-03-12 14:05:00',
|
||||
title: '产品使用指南',
|
||||
content: '【香惠慧】感谢您选择我们的产品!为了帮助您更好地了解和使用我们的服务,特为您准备了产品使用指南。',
|
||||
image: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '1张图片',
|
||||
phoneCount: 2000,
|
||||
sentCount: 2000,
|
||||
totalCount: 2000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-12 16:00:00',
|
||||
status: 'completed',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
{
|
||||
id: 'MMS202603120005',
|
||||
enterprise: '四川骠骑企业管理',
|
||||
applicationName: '活动推广',
|
||||
submittedAt: '2026-03-12 15:30:00',
|
||||
title: '周末特惠活动',
|
||||
content: '【骠骑科技】周末特惠活动开始啦!精美图片抢先看,超值优惠不容错过!点击查看活动详情。',
|
||||
image: 'https://images.unsplash.com/photo-1607082350899-7e105aa886ae?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '3张图片',
|
||||
phoneCount: 4000,
|
||||
sentCount: 1000,
|
||||
totalCount: 4000,
|
||||
sendType: 'immediate',
|
||||
status: 'terminated',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
{
|
||||
id: 'MMS202603110006',
|
||||
enterprise: '重庆进载数智',
|
||||
applicationName: '系统通知',
|
||||
submittedAt: '2026-03-11 17:45:00',
|
||||
title: '系统维护通知',
|
||||
content: '【进载数智】系统维护通知:我们将于今晚进行系统升级,预计耗时2小时。维护期间部分服务可能受影响。',
|
||||
image: 'https://images.unsplash.com/photo-1516321318423-f06f85e504b3?auto=format&fit=crop&w=500&q=80',
|
||||
attachment: '1张图片',
|
||||
phoneCount: 6000,
|
||||
sentCount: 4000,
|
||||
totalCount: 6000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-11 20:00:00',
|
||||
status: 'failed',
|
||||
carrierStats: defaultCarrierStats,
|
||||
cityStats: defaultCityStats,
|
||||
},
|
||||
];
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return value.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function getProgress(task: MmsTask) {
|
||||
return Math.round((task.sentCount / task.totalCount) * 100);
|
||||
}
|
||||
|
||||
function getDeliveredCount(task: MmsTask) {
|
||||
if (task.status === 'completed') {
|
||||
return Math.round(task.totalCount * 0.96);
|
||||
}
|
||||
|
||||
if (task.status === 'failed') {
|
||||
return Math.round(task.sentCount * 0.82);
|
||||
}
|
||||
|
||||
return Math.round(task.sentCount * 0.95);
|
||||
}
|
||||
|
||||
function MmsContent({ task }: { task: MmsTask }) {
|
||||
return (
|
||||
<div className="mms-task-content">
|
||||
<img alt={task.title} src={task.image} />
|
||||
<div>
|
||||
<strong>{task.title}</strong>
|
||||
<p>{task.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetailModal({ task, onClose }: { task: MmsTask; onClose: () => void }) {
|
||||
const progress = getProgress(task);
|
||||
const deliveredCount = getDeliveredCount(task);
|
||||
const overallRate = (deliveredCount / task.totalCount) * 100;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={<DetailTitle title="彩信任务详情" subtitle="查看任务的详细信息和进度。" />}
|
||||
>
|
||||
<div className="task-detail admin-mms-task-detail">
|
||||
<DetailSection title="基本信息" extra={<Tag tone={statusToneMap[task.status]}>{statusLabelMap[task.status]}</Tag>}>
|
||||
<DetailInfoGrid
|
||||
items={[
|
||||
{ label: '任务编号', value: task.id },
|
||||
{ label: '企业名称', value: task.enterprise },
|
||||
{ label: '应用名称', value: task.applicationName },
|
||||
{ label: '提交时间', value: task.submittedAt },
|
||||
{ label: '发送方式', value: sendTypeLabels[task.sendType] },
|
||||
{ label: '号码数', value: `${formatNumber(task.phoneCount)} 个`, tone: 'primary' },
|
||||
{
|
||||
label: '彩信内容',
|
||||
value: (
|
||||
<div className="mms-detail-template">
|
||||
<img alt={task.title} src={task.image} />
|
||||
<div><strong>{task.title}</strong><p>{task.content}</p><small>附件:{task.attachment}</small></div>
|
||||
</div>
|
||||
),
|
||||
full: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="发送进度">
|
||||
<DetailProgressStats
|
||||
label="任务进度"
|
||||
meta={`已发送 ${formatNumber(task.sentCount)} / 总计 ${formatNumber(task.totalCount)}`}
|
||||
percent={progress}
|
||||
status={task.status === 'failed' ? 'terminated' : task.status}
|
||||
stats={[
|
||||
{ label: '提交总数量', value: formatNumber(task.totalCount) },
|
||||
{ label: '已处理数量', value: formatNumber(task.sentCount) },
|
||||
{ label: '发送成功数量', value: formatNumber(deliveredCount) },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={<><TrendingUp size={20} /> 按运营商统计成功率</>}>
|
||||
<RateOverview
|
||||
label="总体成功率"
|
||||
metrics={[
|
||||
{ label: '成功总数', value: formatNumber(deliveredCount) },
|
||||
{ label: '总计', value: formatNumber(task.totalCount) },
|
||||
]}
|
||||
rate={overallRate}
|
||||
tone={getRateTone(overallRate)}
|
||||
/>
|
||||
<div className="carrier-rate-grid">
|
||||
{task.carrierStats.map((item) => (
|
||||
<RateCard
|
||||
key={item.name}
|
||||
meta={<><span>{formatNumber(item.success)}</span><span>/ {formatNumber(item.total)}</span></>}
|
||||
rate={item.rate}
|
||||
title={item.name}
|
||||
tone={getRateTone(item.rate)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<h4>按城市统计成功率</h4>
|
||||
<div className="admin-mms-city-list">
|
||||
{task.cityStats.map((item) => {
|
||||
const rate = (item.success / item.total) * 100;
|
||||
return (
|
||||
<div key={item.city}>
|
||||
<strong>{item.city}</strong>
|
||||
<span>{formatNumber(item.success)} / {formatNumber(item.total)}</span>
|
||||
<b>{rate.toFixed(1)}%</b>
|
||||
<ProgressBar percent={rate} tone={getRateTone(rate)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewModal({ task, onClose }: { task: MmsTask; onClose: () => void }) {
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
onClose={onClose}
|
||||
open
|
||||
title={<div className="template-modal-title"><h2>彩信预览</h2><p>{task.id}</p></div>}
|
||||
>
|
||||
<div className="mms-preview">
|
||||
<img alt={task.title} src={task.image} />
|
||||
<h3>{task.title}</h3>
|
||||
<p>{task.content}</p>
|
||||
<div className="mms-preview-frames"><span>{task.attachment}</span><span>图文彩信</span></div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminMmsTaskProgressPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [enterprise, setEnterprise] = useState('all');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [selectedTask, setSelectedTask] = useState<MmsTask | null>(null);
|
||||
const [previewTask, setPreviewTask] = useState<MmsTask | null>(null);
|
||||
|
||||
const enterpriseOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(taskSeed.map((item) => item.enterprise)));
|
||||
return [{ label: '全部企业', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, []);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(taskSeed.filter((item) => enterprise === 'all' || item.enterprise === enterprise).map((item) => item.applicationName)));
|
||||
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, [enterprise]);
|
||||
|
||||
const filteredTasks = useMemo(
|
||||
() => taskSeed.filter((item) => {
|
||||
const submittedDate = item.submittedAt.slice(0, 10);
|
||||
const matchesKeyword = !keyword || item.id.includes(keyword);
|
||||
const matchesEnterprise = enterprise === 'all' || item.enterprise === enterprise;
|
||||
const matchesApplication = application === 'all' || item.applicationName === application;
|
||||
const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start;
|
||||
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
|
||||
return matchesKeyword && matchesEnterprise && matchesApplication && matchesStartDate && matchesEndDate;
|
||||
}),
|
||||
[application, enterprise, keyword, submittedDateRange.end, submittedDateRange.start],
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
setKeyword('');
|
||||
setEnterprise('all');
|
||||
setApplication('all');
|
||||
setSubmittedDateRange({});
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<MmsTask>> = [
|
||||
{ key: 'id', title: '任务编号', width: '150px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
|
||||
{
|
||||
key: 'enterprise',
|
||||
title: '企业/应用',
|
||||
width: '180px',
|
||||
render: (record) => (
|
||||
<div className="admin-task-enterprise">
|
||||
<strong>{record.enterprise}</strong>
|
||||
<span>{record.applicationName}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'submittedAt', title: '提交时间', width: '116px', render: (record) => <span>{record.submittedAt.slice(0, 10)}<br />{record.submittedAt.slice(11, 16)}</span> },
|
||||
{ key: 'content', title: '模板内容', width: '420px', render: (record) => <MmsContent task={record} /> },
|
||||
{ key: 'phoneCount', title: '号码数', align: 'right', width: '90px', render: (record) => <strong>{formatNumber(record.phoneCount)}</strong> },
|
||||
{
|
||||
key: 'sendType',
|
||||
title: '发送方式',
|
||||
width: '150px',
|
||||
render: (record) => (
|
||||
<div className="admin-task-send-type">
|
||||
<Tag tone={record.sendType === 'immediate' ? 'info' : 'warning'}>
|
||||
{record.sendType === 'scheduled' ? <CalendarClock size={13} /> : null}
|
||||
{sendTypeLabels[record.sendType]}
|
||||
</Tag>
|
||||
{record.scheduledAt ? <span>{record.scheduledAt.slice(0, 10)}<br />{record.scheduledAt.slice(11, 16)}</span> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
title: '进度',
|
||||
width: '180px',
|
||||
render: (record) => {
|
||||
const progress = getProgress(record);
|
||||
return (
|
||||
<div className="batch-progress admin-task-list-progress">
|
||||
<div>
|
||||
<span>{formatNumber(record.sentCount)}/{formatNumber(record.totalCount)}</span>
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${record.status === 'failed' ? 'terminated' : record.status}`} style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'status', title: '状态', width: '100px', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '160px',
|
||||
render: (record) => (
|
||||
<div className="batch-actions mms-task-actions">
|
||||
<Button icon={<Eye size={14} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost">任务详情</Button>
|
||||
<Button icon={<ImageIcon size={14} />} onClick={() => setPreviewTask(record)} size="sm" variant="ghost">彩信预览</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-mms-task-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['发送任务', '彩信任务进度']} />
|
||||
<h1>彩信任务进度</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-filter">
|
||||
<Input label="任务编号" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入任务编号" value={keyword} />
|
||||
<Select
|
||||
label="选择企业"
|
||||
onChange={(event) => {
|
||||
setEnterprise(event.target.value);
|
||||
setApplication('all');
|
||||
}}
|
||||
options={enterpriseOptions}
|
||||
value={enterprise}
|
||||
/>
|
||||
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface admin-task-table-card admin-mms-task-table-card">
|
||||
<Table columns={columns} data={filteredTasks} rowKey="id" />
|
||||
<Pagination total={filteredTasks.length} />
|
||||
</div>
|
||||
|
||||
{selectedTask ? <TaskDetailModal onClose={() => setSelectedTask(null)} task={selectedTask} /> : null}
|
||||
{previewTask ? <PreviewModal onClose={() => setPreviewTask(null)} task={previewTask} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -140,7 +140,7 @@ export function AdminRechargeRecordsPage() {
|
||||
<th style={{ width: '130px' }}>充值金额</th>
|
||||
<th style={{ width: '140px' }}>充值后余额</th>
|
||||
<th style={{ width: '120px' }}>充值类型</th>
|
||||
<th style={{ width: '110px' }}>操作人</th>
|
||||
<th style={{ width: '140px' }}>操作人</th>
|
||||
<th style={{ width: '300px' }}>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -13,12 +13,12 @@ const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'su
|
||||
failed: { label: '有失败', tone: 'danger' },
|
||||
};
|
||||
|
||||
function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (fileName: string, remark: string) => void }) {
|
||||
const [fileName, setFileName] = useState('');
|
||||
function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubmit: (file: File, remark: string) => void }) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [remark, setRemark] = useState('');
|
||||
return (
|
||||
<Modal
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!fileName} onClick={() => onSubmit(fileName, remark)}>确认导入</Button></>}
|
||||
footer={<><Button onClick={onClose} variant="ghost">取消</Button><Button disabled={!file} onClick={() => file && onSubmit(file, remark)}>确认导入</Button></>}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
@@ -27,9 +27,14 @@ function ReceiptImportModal({ onClose, onSubmit }: { onClose: () => void; onSubm
|
||||
<div className="report-receipt-modal">
|
||||
<label className="report-upload-drop">
|
||||
<FileUp size={38} />
|
||||
<strong>{fileName || '选择回执文件'}</strong>
|
||||
<span>支持 Excel、CSV、PDF 等真实回执文件。</span>
|
||||
<input onChange={(event) => setFileName(event.target.files?.[0]?.name ?? '')} style={{ display: 'none' }} type="file" />
|
||||
<strong>{file?.name || '选择回执文件'}</strong>
|
||||
<span>支持 CSV、TSV、TXT 文本回执,需包含状态/结果列。</span>
|
||||
<input
|
||||
accept=".csv,.tsv,.txt,text/csv,text/plain"
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
<Textarea label="导入备注" onChange={(event) => setRemark(event.target.value)} placeholder="记录回执来源、运营商工单号或人工处理说明" rows={4} value={remark} />
|
||||
</div>
|
||||
@@ -87,20 +92,31 @@ export function AdminReportTasksPage() {
|
||||
.catch((failure: Error) => setError(failure.message || '报备任务导出失败'));
|
||||
}
|
||||
|
||||
function importReceipt(fileName: string, remark: string) {
|
||||
function importReceipt(file: File, remark: string) {
|
||||
if (!receiptTask) return;
|
||||
adminApi.importReportReceipt(receiptTask.id, { fileName, reason: remark, statusAfter: 'partial' })
|
||||
const delimiter = file.name.toLowerCase().endsWith('.tsv') ? '\t' : ',';
|
||||
Promise.all([
|
||||
adminApi.uploadFileObject(file, { purpose: 'report_receipt', prefix: `report-receipts/${receiptTask.id}` }),
|
||||
file.text(),
|
||||
])
|
||||
.then(([fileObject, fileContent]) => adminApi.importReportReceipt(receiptTask.id, {
|
||||
fileObjectId: fileObject.id,
|
||||
fileName: file.name,
|
||||
fileContent,
|
||||
delimiter,
|
||||
reason: remark,
|
||||
}))
|
||||
.then(() => {
|
||||
setReceiptTask(null);
|
||||
loadData();
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message));
|
||||
.catch((failure: Error) => setError(failure.message || '报备回执导入失败'));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<ReportTask>> = [
|
||||
{ key: 'id', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.id}</strong> },
|
||||
{ key: 'scope', title: '通道/签名', width: '280px', render: (record) => <div className="admin-task-enterprise"><strong>{record.channel?.name ?? record.channelId}</strong><span>{record.signature?.name ?? record.signatureId}</span></div> },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
|
||||
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={(statusMeta[record.status] ?? { tone: 'info' as const }).tone}>{(statusMeta[record.status] ?? { label: record.status }).label}</Tag> },
|
||||
{ key: 'time', title: '创建时间', width: '190px', render: (record) => record.createdAt ?? '-' },
|
||||
{
|
||||
key: 'actions',
|
||||
|
||||
@@ -63,7 +63,7 @@ export function AdminSensitiveWordsPage() {
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '110px',
|
||||
width: '130px',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteSensitiveWord(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Save } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
|
||||
|
||||
export function AdminSettingsPage() {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={['系统配置']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface content-grid">
|
||||
<div className="form-grid">
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input label="审核超时提醒" defaultValue="30 分钟" />
|
||||
<Input label="单批发送上限" defaultValue="50000" />
|
||||
</div>
|
||||
<Select
|
||||
label="默认风控等级"
|
||||
defaultValue="medium"
|
||||
options={[
|
||||
{ label: '宽松', value: 'low' },
|
||||
{ label: '标准', value: 'medium' },
|
||||
{ label: '严格', value: 'high' },
|
||||
]}
|
||||
/>
|
||||
<Button icon={<Save size={16} />}>保存配置</Button>
|
||||
</div>
|
||||
<aside className="soft-panel">
|
||||
<h3>配置说明</h3>
|
||||
<p className="muted">当前配置只在前端展示,用于模拟运营端系统参数维护。</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, Search } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
|
||||
type MmsAuditStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
type MmsTemplateAudit = {
|
||||
id: string;
|
||||
name: string;
|
||||
application: string;
|
||||
subject: string;
|
||||
sizeKb: number;
|
||||
enterprise: string;
|
||||
submittedAt: string;
|
||||
status: MmsAuditStatus;
|
||||
image: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部状态', value: 'all' },
|
||||
{ label: '待审核', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已拒绝', value: 'rejected' },
|
||||
];
|
||||
|
||||
const statusLabelMap: Record<MmsAuditStatus, string> = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<MmsAuditStatus, 'warning' | 'success' | 'danger'> = {
|
||||
pending: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
};
|
||||
|
||||
const initialMmsAudits: MmsTemplateAudit[] = [
|
||||
{
|
||||
id: 'MMS-TPL-20260319-001',
|
||||
name: '春季上新图文推广',
|
||||
application: '营销活动彩信',
|
||||
subject: '【星云科技】春季新品发布',
|
||||
sizeKb: 1450,
|
||||
enterprise: '北京星云科技有限公司',
|
||||
submittedAt: '2026-03-19 11:15:20',
|
||||
status: 'pending',
|
||||
image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=900&q=80',
|
||||
content: '春季新品重磅发布,限时优惠价299元起,前100名购买送精美礼品一份。',
|
||||
},
|
||||
{
|
||||
id: 'MMS-TPL-20260319-002',
|
||||
name: '端午节大促视频',
|
||||
application: '节日促销彩信',
|
||||
subject: '【蓝海科技】端午狂欢最高满减',
|
||||
sizeKb: 1850,
|
||||
enterprise: '上海蓝海科技有限公司',
|
||||
submittedAt: '2026-03-18 09:30:10',
|
||||
status: 'pending',
|
||||
image: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=900&q=80',
|
||||
content: '端午节会员福利开启,精选商品满299减50,满599减120,数量有限。',
|
||||
},
|
||||
{
|
||||
id: 'MMS-TPL-20260318-001',
|
||||
name: '新用户欢迎礼包',
|
||||
application: '会员运营彩信',
|
||||
subject: '【飞跃传媒】新老用户专享福利',
|
||||
sizeKb: 850,
|
||||
enterprise: '广州飞跃文化传媒有限公司',
|
||||
submittedAt: '2026-03-17 14:20:05',
|
||||
status: 'approved',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=900&q=80',
|
||||
content: '欢迎加入会员中心,新用户可领取专属礼包和本月优惠券。',
|
||||
},
|
||||
{
|
||||
id: 'MMS-TPL-20260317-001',
|
||||
name: '理财产品营销违规',
|
||||
application: '金融营销彩信',
|
||||
subject: '【理财通】高收益理财推荐',
|
||||
sizeKb: 1950,
|
||||
enterprise: '深圳前海贸易有限公司',
|
||||
submittedAt: '2026-03-16 16:45:30',
|
||||
status: 'rejected',
|
||||
image: 'https://images.unsplash.com/photo-1554224155-6726b3ff858f?auto=format&fit=crop&w=900&q=80',
|
||||
content: '精选高收益理财产品推荐,限时申购,活动名额有限。',
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminSignatureAuditPage() {
|
||||
const [records, setRecords] = useState(initialMmsAudits);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [previewRecord, setPreviewRecord] = useState<MmsTemplateAudit | null>(null);
|
||||
|
||||
const filteredRecords = useMemo(
|
||||
() => records.filter((record) => {
|
||||
const matchesKeyword = !keyword || `${record.name}${record.enterprise}`.includes(keyword);
|
||||
const matchesStatus = status === 'all' || record.status === status;
|
||||
return matchesKeyword && matchesStatus;
|
||||
}),
|
||||
[keyword, records, status],
|
||||
);
|
||||
|
||||
function updateStatus(id: string, nextStatus: MmsAuditStatus) {
|
||||
setRecords((items) => items.map((item) => (item.id === id ? { ...item, status: nextStatus } : item)));
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<MmsTemplateAudit>> = [
|
||||
{ key: 'id', title: '模板编号', render: (record) => <span className="muted">{record.id}</span> },
|
||||
{ key: 'name', title: '模板名称', render: (record) => <strong>{record.name}</strong> },
|
||||
{ key: 'application', title: '彩信应用', render: (record) => record.application },
|
||||
{ key: 'subject', title: '彩信主题', render: (record) => record.subject },
|
||||
{ key: 'sizeKb', title: '大小(KB)', render: (record) => record.sizeKb },
|
||||
{ key: 'enterprise', title: '归属企业', render: (record) => record.enterprise },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => record.submittedAt },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="audit-actions">
|
||||
<button className="audit-link" onClick={() => setPreviewRecord(record)} type="button"><Eye size={16} />预览</button>
|
||||
{record.status === 'pending' ? (
|
||||
<>
|
||||
<button className="audit-link audit-link--success" onClick={() => updateStatus(record.id, 'approved')} type="button">通过</button>
|
||||
<button className="audit-link audit-link--danger" onClick={() => updateStatus(record.id, 'rejected')} type="button">拒绝</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-audit-page">
|
||||
<Breadcrumb items={['审核中心', '彩信模板审核']} />
|
||||
|
||||
<div className="surface audit-filter-card">
|
||||
<div className="audit-filter-grid audit-filter-grid--enterprise">
|
||||
<Input label="模板名称/企业名称" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入模板名称或企业名称" value={keyword} />
|
||||
<Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={statusOptions} value={status} />
|
||||
<div className="audit-filter-actions">
|
||||
<Button icon={<Search size={17} />}>查询</Button>
|
||||
<Button onClick={() => { setKeyword(''); setStatus('all'); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="surface audit-table-card mms-audit-table">
|
||||
<Table columns={columns} data={filteredRecords} rowKey="id" />
|
||||
<div className="audit-pagination">
|
||||
<span>共 {filteredRecords.length} 条</span>
|
||||
<Button disabled size="sm" variant="ghost">上一页</Button>
|
||||
<Button size="sm" variant="secondary">1</Button>
|
||||
<Button disabled size="sm" variant="ghost">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={<Button onClick={() => setPreviewRecord(null)}>关闭</Button>}
|
||||
onClose={() => setPreviewRecord(null)}
|
||||
open={Boolean(previewRecord)}
|
||||
title={<div className="template-modal-title"><h2>彩信预览</h2><p>{previewRecord?.name}</p></div>}
|
||||
>
|
||||
{previewRecord ? (
|
||||
<div className="mms-preview">
|
||||
<img alt={previewRecord.name} src={previewRecord.image} />
|
||||
<h3>{previewRecord.subject}</h3>
|
||||
<p>{previewRecord.content}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { adminApi, type ChannelGroup } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select } from '@/components/ui';
|
||||
import { ArrowLeft, RadioTower } from 'lucide-react';
|
||||
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
|
||||
|
||||
type Carrier = 'mobile' | 'unicom' | 'telecom';
|
||||
|
||||
const carrierMeta: Record<Carrier, { label: string; description: string }> = {
|
||||
mobile: { label: '移动', description: '移动号码只会进入移动通道组' },
|
||||
unicom: { label: '联通', description: '联通号码只会进入联通通道组' },
|
||||
telecom: { label: '电信', description: '电信号码只会进入电信通道组' },
|
||||
};
|
||||
|
||||
export function AdminSmsApplicationFormPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -20,70 +28,128 @@ export function AdminSmsApplicationFormPage() {
|
||||
const [unicomGroupId, setUnicomGroupId] = useState('');
|
||||
const [telecomGroupId, setTelecomGroupId] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.listChannelGroups()
|
||||
.then((items) => setGroups(items.filter((item) => item.status !== 'disabled' && item.status !== 'deleted')))
|
||||
.catch((failure: Error) => setError(failure.message || '通道组加载失败'));
|
||||
}, []);
|
||||
let cancelled = false;
|
||||
async function loadForm() {
|
||||
try {
|
||||
const [groupItems, application, routeRules] = await Promise.all([
|
||||
adminApi.listChannelGroups(),
|
||||
isEdit && appId ? adminApi.getEnterpriseApplication(appId) : Promise.resolve<EnterpriseApplication | null>(null),
|
||||
isEdit ? adminApi.listChannelRouteRules() : Promise.resolve<DictionaryItem[]>([]),
|
||||
]);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setGroups(groupItems.filter((item) => item.status !== 'disabled' && item.status !== 'deleted'));
|
||||
if (application) {
|
||||
if (enterpriseId && application.tenantId !== enterpriseId) {
|
||||
setError('应用不属于当前企业,已停止加载');
|
||||
return;
|
||||
}
|
||||
hydrateApplication(application, routeRules);
|
||||
}
|
||||
} catch (failure) {
|
||||
if (!cancelled) {
|
||||
setError(failure instanceof Error ? failure.message : '短信应用加载失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadForm();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [appId, enterpriseId, isEdit]);
|
||||
|
||||
function goBack() {
|
||||
navigate('/admin/enterprise-applications');
|
||||
}
|
||||
|
||||
function submit() {
|
||||
function hydrateApplication(application: EnterpriseApplication, routeRules: DictionaryItem[]) {
|
||||
setAppName(application.name);
|
||||
setScene(application.scene ?? '');
|
||||
setDailyLimit(application.dailyLimit ? String(application.dailyLimit) : '');
|
||||
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(4));
|
||||
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
|
||||
setMismatchPolicy(application.templateMismatchMode ?? 'reject');
|
||||
setIpAddress(application.ipAllowlist?.map((item) => item.ipCidr).join('\n') ?? '');
|
||||
|
||||
const activeRules = routeRules.filter((rule) => (
|
||||
rule.applicationId === application.id
|
||||
&& rule.status !== 'deleted'
|
||||
&& !rule.province
|
||||
&& !rule.channelId
|
||||
));
|
||||
setMobileGroupId(getRouteGroupId(activeRules, 'mobile'));
|
||||
setUnicomGroupId(getRouteGroupId(activeRules, 'unicom'));
|
||||
setTelecomGroupId(getRouteGroupId(activeRules, 'telecom'));
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!enterpriseId) {
|
||||
setError('缺少企业 ID');
|
||||
return;
|
||||
}
|
||||
if (isEdit) {
|
||||
setError('短信应用编辑接口待补,当前不做本地模拟保存');
|
||||
return;
|
||||
}
|
||||
const selectedGroups = [
|
||||
{ carrier: 'mobile', groupId: mobileGroupId },
|
||||
{ carrier: 'unicom', groupId: unicomGroupId },
|
||||
{ carrier: 'telecom', groupId: telecomGroupId },
|
||||
{ carrier: 'mobile' as Carrier, groupId: mobileGroupId },
|
||||
{ carrier: 'unicom' as Carrier, groupId: unicomGroupId },
|
||||
{ carrier: 'telecom' as Carrier, groupId: telecomGroupId },
|
||||
].filter((item) => item.groupId);
|
||||
if (selectedGroups.length === 0) {
|
||||
setError('请至少配置一个运营商通道组');
|
||||
return;
|
||||
}
|
||||
adminApi.createEnterpriseApplication({
|
||||
tenantId: enterpriseId,
|
||||
const payload = {
|
||||
name: appName,
|
||||
scene,
|
||||
dailyLimit: Number(dailyLimit) || undefined,
|
||||
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
|
||||
maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
|
||||
templateMismatchMode: mismatchPolicy,
|
||||
ipAllowlist: ipAddress ? [ipAddress] : [],
|
||||
})
|
||||
.then(async (application) => {
|
||||
await Promise.all(selectedGroups.map((item, index) => adminApi.createChannelRouteRule({
|
||||
tenantId: enterpriseId,
|
||||
applicationId: application.id,
|
||||
groupId: item.groupId,
|
||||
ipAllowlist: parseIpAllowlist(ipAddress),
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const application = isEdit && appId
|
||||
? await adminApi.updateEnterpriseApplication(appId, payload)
|
||||
: await adminApi.createEnterpriseApplication({ tenantId: enterpriseId, ...payload });
|
||||
await adminApi.replaceApplicationRouteRules(application.id, {
|
||||
routes: selectedGroups.map((item, index) => ({
|
||||
carrier: item.carrier,
|
||||
groupId: item.groupId,
|
||||
priority: (index + 1) * 10,
|
||||
status: 'active',
|
||||
})));
|
||||
goBack();
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '短信应用保存失败'));
|
||||
})),
|
||||
});
|
||||
goBack();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '短信应用保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const groupOptionsByCarrier = (carrier: ChannelGroup['carrier']) => [
|
||||
{ label: '不配置', value: '' },
|
||||
...groups.filter((group) => group.carrier === carrier).map((group) => ({ label: group.name, value: group.id })),
|
||||
];
|
||||
const selectedGroupCount = [mobileGroupId, unicomGroupId, telecomGroupId].filter(Boolean).length;
|
||||
const routeCards: Array<{ carrier: Carrier; groupId: string; onChange: (value: string) => void }> = [
|
||||
{ carrier: 'mobile', groupId: mobileGroupId, onChange: setMobileGroupId },
|
||||
{ carrier: 'unicom', groupId: unicomGroupId, onChange: setUnicomGroupId },
|
||||
{ carrier: 'telecom', groupId: telecomGroupId, onChange: setTelecomGroupId },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-app-form-page">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<Breadcrumb items={[isEdit ? '编辑短信应用' : '添加短信应用']} />
|
||||
<p>短信应用写入真实应用表;编辑能力待后端接口补齐后开放。</p>
|
||||
<p>短信应用和三网通道组配置写入真实后台接口。</p>
|
||||
</div>
|
||||
<Button icon={<ArrowLeft size={16} />} onClick={goBack} variant="ghost">返回企业应用管理</Button>
|
||||
</div>
|
||||
@@ -91,7 +157,7 @@ export function AdminSmsApplicationFormPage() {
|
||||
|
||||
<div className="surface admin-app-form-card">
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header"><h3>业务信息</h3></div>
|
||||
<div className="ui-detail-section__header"><h3>业务信息</h3><p>先填写应用基础信息,保存后将生成真实企业应用。</p></div>
|
||||
<div className="admin-app-form-grid">
|
||||
<Input label="应用名称" onChange={(event) => setAppName(event.target.value)} placeholder="请输入应用名称" required value={appName} />
|
||||
<Input label="应用场景" onChange={(event) => setScene(event.target.value)} placeholder="行业通知/营销推广/验证码" value={scene} />
|
||||
@@ -109,18 +175,62 @@ export function AdminSmsApplicationFormPage() {
|
||||
required
|
||||
value={mismatchPolicy}
|
||||
/>
|
||||
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="例如 192.168.1.100/32" value={ipAddress} />
|
||||
<Select label="移动通道组" onChange={(event) => setMobileGroupId(event.target.value)} options={groupOptionsByCarrier('mobile')} value={mobileGroupId} />
|
||||
<Select label="联通通道组" onChange={(event) => setUnicomGroupId(event.target.value)} options={groupOptionsByCarrier('unicom')} value={unicomGroupId} />
|
||||
<Select label="电信通道组" onChange={(event) => setTelecomGroupId(event.target.value)} options={groupOptionsByCarrier('telecom')} value={telecomGroupId} />
|
||||
<Input label="IP 白名单" onChange={(event) => setIpAddress(event.target.value)} placeholder="多个 IP/CIDR 可用逗号、空格或换行分隔" value={ipAddress} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ui-detail-section">
|
||||
<div className="ui-detail-section__header">
|
||||
<div>
|
||||
<h3>运营商通道组</h3>
|
||||
<p>至少选择一个运营商通道组;每个运营商只能绑定同运营商通道组。</p>
|
||||
</div>
|
||||
<Tag tone={selectedGroupCount > 0 ? 'success' : 'warning'}>{selectedGroupCount}/3 已配置</Tag>
|
||||
</div>
|
||||
<div className="admin-app-route-grid">
|
||||
{routeCards.map((card) => {
|
||||
const available = groups.filter((group) => group.carrier === card.carrier);
|
||||
const meta = carrierMeta[card.carrier];
|
||||
return (
|
||||
<div className={['admin-app-route-card', card.groupId ? 'is-selected' : ''].filter(Boolean).join(' ')} key={card.carrier}>
|
||||
<header>
|
||||
<span><RadioTower size={18} /></span>
|
||||
<div>
|
||||
<strong>{meta.label}通道组</strong>
|
||||
<small>{meta.description}</small>
|
||||
</div>
|
||||
<Tag tone={card.groupId ? 'success' : available.length ? 'neutral' : 'warning'}>{card.groupId ? '已选择' : `${available.length} 个可选`}</Tag>
|
||||
</header>
|
||||
<Select
|
||||
label={`${meta.label}通道组`}
|
||||
onChange={(event) => card.onChange(event.target.value)}
|
||||
options={groupOptionsByCarrier(card.carrier)}
|
||||
value={card.groupId}
|
||||
/>
|
||||
{!available.length ? <p>暂无可用{meta.label}通道组,请先在通道组管理创建。</p> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="enterprise-form-footer">
|
||||
<Button disabled={!appName || isEdit} onClick={submit}>{isEdit ? '编辑待补接口' : '创建应用'}</Button>
|
||||
<Button disabled={!appName || selectedGroupCount === 0 || saving} onClick={() => { void submit(); }}>{saving ? '保存中...' : isEdit ? '保存应用' : '创建应用'}</Button>
|
||||
<Button onClick={goBack} variant="ghost">取消</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function getRouteGroupId(routeRules: DictionaryItem[], carrier: Carrier) {
|
||||
const rule = routeRules.find((item) => item.carrier === carrier);
|
||||
return typeof rule?.groupId === 'string' ? rule.groupId : '';
|
||||
}
|
||||
|
||||
function parseIpAllowlist(value: string) {
|
||||
return value
|
||||
.split(/[\s,,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -70,13 +70,13 @@ export function AdminSmsAuditPage() {
|
||||
const columns: Array<TableColumn<RiskReviewTask>> = [
|
||||
{ key: 'taskNo', title: '任务编号', width: '180px', render: (record) => <strong>{record.taskNo}</strong> },
|
||||
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||
{ key: 'phoneTotal', title: '号码数', width: '110px', render: (record) => record.phoneTotal.toLocaleString('zh-CN') },
|
||||
{ key: 'phoneTotal', title: '号码数', width: '130px', render: (record) => record.phoneTotal.toLocaleString('zh-CN') },
|
||||
{ key: 'createdAt', title: '提交时间', width: '190px', render: (record) => record.createdAt },
|
||||
{ key: 'reason', title: '审核原因', render: (record) => record.reviewReason ?? record.rejectReason ?? record.riskHits?.map((item) => item.reason).join(';') ?? '-' },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
width: '110px',
|
||||
width: '130px',
|
||||
render: (record) => <Tag tone={statusTone[record.status] ?? 'warning'}>{statusLabel[record.status] ?? record.status}</Tag>,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -60,8 +60,8 @@ export function AdminSmsRecordsPage() {
|
||||
{ key: 'content', title: '短信内容', render: (record) => <span className="table-long-text">{record.content}</span> },
|
||||
{ key: 'billing', title: '计费', width: '120px', render: (record) => `${record.billingUnits} 条 / ¥${(record.amountCents / 100).toFixed(2)}` },
|
||||
{ key: 'queuedAt', title: '提交时间', width: '190px', render: (record) => record.queuedAt },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag> },
|
||||
{ key: 'actions', title: '操作', width: '100px', align: 'right', render: (record) => <Button onClick={() => setSelectedRecord(record)} size="sm" variant="ghost">详情</Button> },
|
||||
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag> },
|
||||
{ key: 'actions', title: '操作', width: '120px', align: 'right', render: (record) => <Button onClick={() => setSelectedRecord(record)} size="sm" variant="ghost">详情</Button> },
|
||||
];
|
||||
|
||||
function resetFilters() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { BarChart3, CalendarClock, Eye, MapPin, Search, Send, Smartphone, StopCircle, TrendingUp } from 'lucide-react';
|
||||
import { adminApi, type SmsBatchTask, type SmsMessageRecord } from '@/api/adminApi';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
@@ -24,15 +25,15 @@ type CarrierStat = {
|
||||
tone: 'mobile' | 'unicom' | 'telecom';
|
||||
};
|
||||
|
||||
type CityStat = {
|
||||
city: string;
|
||||
province: string;
|
||||
type RegionStat = {
|
||||
region: string;
|
||||
total: number;
|
||||
success: number;
|
||||
};
|
||||
|
||||
type SmsTask = {
|
||||
id: string;
|
||||
backendId: string;
|
||||
enterprise: string;
|
||||
application: string;
|
||||
submittedAt: string;
|
||||
@@ -41,14 +42,16 @@ type SmsTask = {
|
||||
wordCount: number;
|
||||
billingCount: number;
|
||||
sendType: SendType;
|
||||
scheduledAt?: string;
|
||||
scheduledAt?: string | null;
|
||||
submittedCount: number;
|
||||
submittedSuccess: number;
|
||||
sentCount: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
status: TaskStatus;
|
||||
rawStatus: string;
|
||||
carriers: CarrierStat[];
|
||||
cities: CityStat[];
|
||||
regions: RegionStat[];
|
||||
};
|
||||
|
||||
const statusLabels: Record<TaskStatus, string> = {
|
||||
@@ -70,188 +73,102 @@ const sendTypeLabels: Record<SendType, string> = {
|
||||
scheduled: '定时发送',
|
||||
};
|
||||
|
||||
const taskData: SmsTask[] = [
|
||||
{
|
||||
id: 'TASK202603120001',
|
||||
enterprise: '四川骠骑企业管理',
|
||||
application: '营销应用1',
|
||||
submittedAt: '2026-03-12 09:30:15',
|
||||
templateContent: '【骠骑科技】尊敬的{name},您有一笔{amount}元的订单已确认,预计{date}送达。',
|
||||
phoneCount: 10000,
|
||||
wordCount: 68,
|
||||
billingCount: 14250,
|
||||
sendType: 'immediate',
|
||||
submittedCount: 10000,
|
||||
submittedSuccess: 7500,
|
||||
sentCount: 7500,
|
||||
successCount: 7125,
|
||||
status: 'sending',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 6000, success: 5760, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 2500, success: 2350, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 1500, success: 1425, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '成都市', province: '四川省', total: 1500, success: 1440 },
|
||||
{ city: '重庆市', province: '重庆市', total: 1200, success: 1140 },
|
||||
{ city: '北京市', province: '北京市', total: 1000, success: 970 },
|
||||
{ city: '上海市', province: '上海市', total: 1000, success: 960 },
|
||||
{ city: '深圳市', province: '广东省', total: 800, success: 760 },
|
||||
{ city: '广州市', province: '广东省', total: 700, success: 658 },
|
||||
{ city: '杭州市', province: '浙江省', total: 600, success: 576 },
|
||||
{ city: '南京市', province: '江苏省', total: 500, success: 475 },
|
||||
{ city: '武汉市', province: '湖北省', total: 500, success: 470 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'TASK202603120002',
|
||||
enterprise: '重庆进载数智',
|
||||
application: '通知应用',
|
||||
submittedAt: '2026-03-12 10:15:00',
|
||||
templateContent: '【进载数智】亲爱的用户,您的会员即将到期,请及时续费。',
|
||||
phoneCount: 5000,
|
||||
wordCount: 52,
|
||||
billingCount: 5000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-13 08:00:00',
|
||||
submittedCount: 5000,
|
||||
submittedSuccess: 5000,
|
||||
sentCount: 5000,
|
||||
successCount: 4910,
|
||||
status: 'completed',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 3000, success: 2955, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 1200, success: 1170, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 800, success: 785, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '重庆市', province: '重庆市', total: 1200, success: 1180 },
|
||||
{ city: '成都市', province: '四川省', total: 900, success: 884 },
|
||||
{ city: '西安市', province: '陕西省', total: 700, success: 688 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'TASK202603120003',
|
||||
enterprise: '超感世纪三三网',
|
||||
application: '推广应用2',
|
||||
submittedAt: '2026-03-12 11:20:00',
|
||||
templateContent: '【超感世纪】新用户专享优惠,限时抢购中!点击链接查看详情:...',
|
||||
phoneCount: 20000,
|
||||
wordCount: 85,
|
||||
billingCount: 40000,
|
||||
sendType: 'immediate',
|
||||
submittedCount: 20000,
|
||||
submittedSuccess: 12000,
|
||||
sentCount: 12000,
|
||||
successCount: 11160,
|
||||
status: 'sending',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 12000, success: 11160, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 5000, success: 4600, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 3000, success: 2820, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '北京市', province: '北京市', total: 3000, success: 2820 },
|
||||
{ city: '上海市', province: '上海市', total: 2600, success: 2418 },
|
||||
{ city: '广州市', province: '广东省', total: 2300, success: 2139 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'TASK202603120004',
|
||||
enterprise: '重庆香惠慧',
|
||||
application: '客服应用',
|
||||
submittedAt: '2026-03-12 14:05:00',
|
||||
templateContent: '【香惠慧】您的验证码是{code},请在5分钟内完成验证。',
|
||||
phoneCount: 3000,
|
||||
wordCount: 45,
|
||||
billingCount: 3000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-12 16:00:00',
|
||||
submittedCount: 3000,
|
||||
submittedSuccess: 3000,
|
||||
sentCount: 3000,
|
||||
successCount: 2962,
|
||||
status: 'completed',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 1700, success: 1682, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 800, success: 790, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 500, success: 490, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '重庆市', province: '重庆市', total: 1600, success: 1584 },
|
||||
{ city: '成都市', province: '四川省', total: 800, success: 786 },
|
||||
{ city: '贵阳市', province: '贵州省', total: 600, success: 592 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'TASK202603120005',
|
||||
enterprise: '四川骠骑企业管理',
|
||||
application: '活动推广',
|
||||
submittedAt: '2026-03-12 15:30:00',
|
||||
templateContent: '【骠骑科技】周末特惠活动开始啦!全场商品8折起,更多优惠请访问官网。',
|
||||
phoneCount: 8000,
|
||||
wordCount: 72,
|
||||
billingCount: 16000,
|
||||
sendType: 'immediate',
|
||||
submittedCount: 8000,
|
||||
submittedSuccess: 2000,
|
||||
sentCount: 2000,
|
||||
successCount: 1860,
|
||||
status: 'terminated',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 4800, success: 4464, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 2000, success: 1840, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 1200, success: 1116, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '成都市', province: '四川省', total: 1600, success: 1488 },
|
||||
{ city: '绵阳市', province: '四川省', total: 900, success: 837 },
|
||||
{ city: '德阳市', province: '四川省', total: 700, success: 651 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'TASK202603110006',
|
||||
enterprise: '重庆进载数智',
|
||||
application: '系统通知',
|
||||
submittedAt: '2026-03-11 17:45:00',
|
||||
templateContent: '【进载数智】系统维护通知:我们将于{date}进行系统升级,预计耗时2小时。',
|
||||
phoneCount: 15000,
|
||||
wordCount: 63,
|
||||
billingCount: 15000,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-11 20:00:00',
|
||||
submittedCount: 15000,
|
||||
submittedSuccess: 10000,
|
||||
sentCount: 10000,
|
||||
successCount: 8200,
|
||||
status: 'failed',
|
||||
carriers: [
|
||||
{ name: '中国移动', total: 9000, success: 7380, tone: 'mobile' },
|
||||
{ name: '中国联通', total: 3600, success: 2952, tone: 'unicom' },
|
||||
{ name: '中国电信', total: 2400, success: 1968, tone: 'telecom' },
|
||||
],
|
||||
cities: [
|
||||
{ city: '重庆市', province: '重庆市', total: 2600, success: 2132 },
|
||||
{ city: '成都市', province: '四川省', total: 1800, success: 1476 },
|
||||
{ city: '昆明市', province: '云南省', total: 1200, success: 984 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const carrierLabels: Record<string, { label: string; tone: CarrierStat['tone'] }> = {
|
||||
mobile: { label: '中国移动', tone: 'mobile' },
|
||||
unicom: { label: '中国联通', tone: 'unicom' },
|
||||
telecom: { label: '中国电信', tone: 'telecom' },
|
||||
all: { label: '三网通道', tone: 'mobile' },
|
||||
};
|
||||
|
||||
function formatNumber(value: number) {
|
||||
return value.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
return value ? `${value.slice(0, 10)} ${value.slice(11, 16)}` : '-';
|
||||
}
|
||||
|
||||
function normalizeTaskStatus(status: string): TaskStatus {
|
||||
if (['finished', 'completed', 'done'].includes(status)) return 'completed';
|
||||
if (['canceled', 'cancelled', 'terminated'].includes(status)) return 'terminated';
|
||||
if (['failed', 'rejected'].includes(status)) return 'failed';
|
||||
return 'sending';
|
||||
}
|
||||
|
||||
function countMessages(messages: SmsMessageRecord[] | undefined, statuses: string[]) {
|
||||
return (messages ?? []).filter((message) => statuses.includes(message.status)).length;
|
||||
}
|
||||
|
||||
function buildCarrierStats(messages: SmsMessageRecord[] | undefined): CarrierStat[] {
|
||||
const stats = new Map<string, CarrierStat>();
|
||||
(messages ?? []).forEach((message) => {
|
||||
const carrier = message.channel?.carrier ?? 'unknown';
|
||||
const meta = carrierLabels[carrier] ?? { label: carrier || '未知通道', tone: 'mobile' as const };
|
||||
const current = stats.get(carrier) ?? { name: meta.label, total: 0, success: 0, tone: meta.tone };
|
||||
current.total += 1;
|
||||
if (message.status === 'delivered') current.success += 1;
|
||||
stats.set(carrier, current);
|
||||
});
|
||||
return Array.from(stats.values());
|
||||
}
|
||||
|
||||
function buildRegionStats(messages: SmsMessageRecord[] | undefined): RegionStat[] {
|
||||
const stats = new Map<string, RegionStat>();
|
||||
(messages ?? []).forEach((message) => {
|
||||
const region = message.channel?.sendRegion ?? '未分配通道';
|
||||
const current = stats.get(region) ?? { region, total: 0, success: 0 };
|
||||
current.total += 1;
|
||||
if (message.status === 'delivered') current.success += 1;
|
||||
stats.set(region, current);
|
||||
});
|
||||
return Array.from(stats.values()).sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
function mapTask(task: SmsBatchTask): SmsTask {
|
||||
const messages = task.messages ?? [];
|
||||
const submittedStatuses = ['submitted', 'delivered', 'failed', 'unknown', 'timeout', 'submit_failed'];
|
||||
const failedStatuses = ['failed', 'submit_failed', 'rejected', 'timeout'];
|
||||
const submittedCount = task.submittedTotal ?? countMessages(messages, submittedStatuses);
|
||||
const successCount = task.successTotal ?? countMessages(messages, ['delivered']);
|
||||
const failedCount = task.failedTotal ?? countMessages(messages, failedStatuses);
|
||||
const processedCount = submittedCount + (task.unknownTotal ?? 0) + (task.timeoutTotal ?? 0);
|
||||
const billingCount = messages.reduce((sum, message) => sum + (message.billingUnits ?? 0), 0)
|
||||
|| task.phoneTotal * (task.template?.billingUnits ?? Math.max(1, Math.ceil([...task.content].length / 67)));
|
||||
|
||||
return {
|
||||
id: task.taskNo || task.id,
|
||||
backendId: task.id,
|
||||
enterprise: task.tenant?.name ?? task.tenantId,
|
||||
application: task.application?.name ?? task.applicationId ?? '未绑定应用',
|
||||
submittedAt: task.createdAt,
|
||||
templateContent: task.content,
|
||||
phoneCount: task.phoneTotal,
|
||||
wordCount: [...task.content].length,
|
||||
billingCount,
|
||||
sendType: task.scheduledAt ? 'scheduled' : 'immediate',
|
||||
scheduledAt: task.scheduledAt,
|
||||
submittedCount,
|
||||
submittedSuccess: submittedCount,
|
||||
sentCount: Math.max(processedCount, successCount + failedCount),
|
||||
successCount,
|
||||
failedCount,
|
||||
status: normalizeTaskStatus(task.status),
|
||||
rawStatus: task.status,
|
||||
carriers: buildCarrierStats(messages),
|
||||
regions: buildRegionStats(messages),
|
||||
};
|
||||
}
|
||||
|
||||
function getProgress(task: SmsTask) {
|
||||
return Math.round((task.sentCount / task.phoneCount) * 100);
|
||||
return task.phoneCount > 0 ? Math.min(100, Math.round((task.sentCount / task.phoneCount) * 100)) : 0;
|
||||
}
|
||||
|
||||
function getSuccessRate(task: SmsTask) {
|
||||
return (task.successCount / task.submittedCount) * 100;
|
||||
return task.submittedCount > 0 ? (task.successCount / task.submittedCount) * 100 : 0;
|
||||
}
|
||||
|
||||
function getCityRate(city: CityStat) {
|
||||
return (city.success / city.total) * 100;
|
||||
function getRegionRate(region: RegionStat) {
|
||||
return region.total > 0 ? (region.success / region.total) * 100 : 0;
|
||||
}
|
||||
|
||||
function splitSignature(content: string) {
|
||||
@@ -286,6 +203,7 @@ function MetricCard({ label, value, tone }: { label: string; value: string; tone
|
||||
function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void }) {
|
||||
const progress = getProgress(task);
|
||||
const successRate = getSuccessRate(task);
|
||||
const perPhoneBillingUnits = Math.max(1, Math.ceil(task.wordCount / 67));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -314,7 +232,7 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
|
||||
</div>
|
||||
<div>
|
||||
<dt>提交时间</dt>
|
||||
<dd>{task.submittedAt}</dd>
|
||||
<dd>{formatTime(task.submittedAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>发送方式</dt>
|
||||
@@ -327,7 +245,7 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
|
||||
<h3><TrendingUp size={18} />发送进度</h3>
|
||||
<div className="admin-task-progress-card">
|
||||
<div>
|
||||
<span>已发送 {formatNumber(task.sentCount)} / 总计 {formatNumber(task.phoneCount)}</span>
|
||||
<span>已处理 {formatNumber(task.sentCount)} / 总计 {formatNumber(task.phoneCount)}</span>
|
||||
<strong>{progress}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
@@ -347,52 +265,56 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
|
||||
<p className="admin-task-template">{task.templateContent}</p>
|
||||
</div>
|
||||
<dl className="admin-task-template-meta">
|
||||
<div><dt>字符数/计费条数</dt><dd>{task.wordCount} 字符 <b>·</b> {Math.max(1, Math.ceil(task.wordCount / 67))} 条/号码</dd></div>
|
||||
<div><dt>字符数/计费条数</dt><dd>{task.wordCount} 字符 <b>·</b> {perPhoneBillingUnits} 条/号码</dd></div>
|
||||
<div><dt>发送号码数</dt><dd>{formatNumber(task.phoneCount)} 个</dd></div>
|
||||
</dl>
|
||||
<div className="admin-task-billing-note">
|
||||
<span>计费规则:每 67 字为 1 条短信。本次任务单号码 {Math.max(1, Math.ceil(task.wordCount / 67))} 条,预计总计费 {formatNumber(task.billingCount)} 条</span>
|
||||
<span>计费规则:每 67 字为 1 条短信。本次任务单号码 {perPhoneBillingUnits} 条,预计总计费 {formatNumber(task.billingCount)} 条</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="admin-task-card admin-task-card--full">
|
||||
<h3><Smartphone size={18} />运营商分布</h3>
|
||||
<div className="admin-carrier-grid">
|
||||
{task.carriers.map((carrier) => {
|
||||
const rate = (carrier.success / carrier.total) * 100;
|
||||
return (
|
||||
<article className={`admin-carrier-card admin-carrier-card--${carrier.tone}`} key={carrier.name}>
|
||||
<strong>{carrier.name}</strong>
|
||||
<p><span>总数</span><b>{formatNumber(carrier.total)}</b></p>
|
||||
<p><span>成功</span><b>{formatNumber(carrier.success)}</b></p>
|
||||
<div>
|
||||
<em>{rate.toFixed(1)}%</em>
|
||||
<span>成功率</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<h3><Smartphone size={18} />通道运营商分布</h3>
|
||||
{task.carriers.length === 0 ? (
|
||||
<div className="admin-uplink-empty-match">暂无已分配通道记录</div>
|
||||
) : (
|
||||
<div className="admin-carrier-grid">
|
||||
{task.carriers.map((carrier) => {
|
||||
const rate = carrier.total > 0 ? (carrier.success / carrier.total) * 100 : 0;
|
||||
return (
|
||||
<article className={`admin-carrier-card admin-carrier-card--${carrier.tone}`} key={carrier.name}>
|
||||
<strong>{carrier.name}</strong>
|
||||
<p><span>总数</span><b>{formatNumber(carrier.total)}</b></p>
|
||||
<p><span>成功</span><b>{formatNumber(carrier.success)}</b></p>
|
||||
<div>
|
||||
<em>{rate.toFixed(1)}%</em>
|
||||
<span>成功率</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-task-card admin-task-card--full">
|
||||
<h3><MapPin size={18} />城市分布</h3>
|
||||
<h3><MapPin size={18} />发送地区分布</h3>
|
||||
<Table
|
||||
columns={[
|
||||
{ key: 'city', title: '城市', render: (record: CityStat) => <strong>{record.city}</strong> },
|
||||
{ key: 'province', title: '省份', render: (record: CityStat) => <span className="muted">{record.province}</span> },
|
||||
{ key: 'total', title: '总数', align: 'right', render: (record: CityStat) => formatNumber(record.total) },
|
||||
{ key: 'success', title: '成功', align: 'right', render: (record: CityStat) => <span className="admin-success-text">{formatNumber(record.success)}</span> },
|
||||
{ key: 'region', title: '发送地区', render: (record: RegionStat) => <strong>{record.region}</strong> },
|
||||
{ key: 'total', title: '总数', align: 'right', render: (record: RegionStat) => formatNumber(record.total) },
|
||||
{ key: 'success', title: '成功', align: 'right', render: (record: RegionStat) => <span className="admin-success-text">{formatNumber(record.success)}</span> },
|
||||
{
|
||||
key: 'rate',
|
||||
title: '成功率',
|
||||
align: 'right',
|
||||
render: (record: CityStat) => <Tag tone={getCityRate(record) >= 95 ? 'success' : 'info'}>{getCityRate(record).toFixed(1)}%</Tag>,
|
||||
render: (record: RegionStat) => <Tag tone={getRegionRate(record) >= 95 ? 'success' : 'info'}>{getRegionRate(record).toFixed(1)}%</Tag>,
|
||||
},
|
||||
]}
|
||||
data={task.cities}
|
||||
rowKey={(record) => record.city}
|
||||
data={task.regions}
|
||||
emptyText="暂无已分配通道记录"
|
||||
rowKey={(record) => record.region}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
@@ -401,7 +323,7 @@ function TaskDetailModal({ task, onClose }: { task: SmsTask; onClose: () => void
|
||||
}
|
||||
|
||||
export function AdminSmsTaskProgressPage() {
|
||||
const [tasks, setTasks] = useState(taskData);
|
||||
const [tasks, setTasks] = useState<SmsTask[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [enterprise, setEnterprise] = useState('all');
|
||||
const [application, setApplication] = useState('all');
|
||||
@@ -409,6 +331,23 @@ export function AdminSmsTaskProgressPage() {
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<SmsTask | null>(null);
|
||||
const [terminateTarget, setTerminateTarget] = useState<SmsTask | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadTasks() {
|
||||
setLoading(true);
|
||||
adminApi.listAdminBatchTasks()
|
||||
.then((items) => {
|
||||
setTasks(items.map(mapTask));
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '短信任务进度加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks();
|
||||
}, []);
|
||||
|
||||
const enterpriseOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(tasks.map((item) => item.enterprise)));
|
||||
@@ -441,10 +380,13 @@ export function AdminSmsTaskProgressPage() {
|
||||
}
|
||||
|
||||
function terminateTask(taskId: string) {
|
||||
setTasks((current) => current.map((task) => (
|
||||
task.id === taskId ? { ...task, status: 'terminated' } : task
|
||||
)));
|
||||
setTerminateTarget(null);
|
||||
adminApi.terminateAdminBatchTask(taskId)
|
||||
.then(() => {
|
||||
setTerminateTarget(null);
|
||||
setSelectedTask(null);
|
||||
loadTasks();
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '任务终止失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -470,11 +412,13 @@ export function AdminSmsTaskProgressPage() {
|
||||
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
<div className="admin-task-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button icon={<Search size={16} />} onClick={loadTasks}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface admin-task-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table batch-table admin-task-table">
|
||||
@@ -486,22 +430,22 @@ export function AdminSmsTaskProgressPage() {
|
||||
<th style={{ width: '130px' }}>号码数/字符数</th>
|
||||
<th style={{ width: '150px' }}>发送方式</th>
|
||||
<th style={{ width: '190px' }}>进度</th>
|
||||
<th style={{ width: '100px' }}>状态</th>
|
||||
<th style={{ width: '130px' }}>状态</th>
|
||||
<th style={{ textAlign: 'right', width: '170px' }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<tr>
|
||||
<td className="ui-table__empty" colSpan={8}>暂无数据</td>
|
||||
</tr>
|
||||
{loading ? (
|
||||
<tr><td className="ui-table__empty" colSpan={8}>正在加载真实短信任务...</td></tr>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<tr><td className="ui-table__empty" colSpan={8}>暂无短信任务</td></tr>
|
||||
) : filteredTasks.map((record) => {
|
||||
const progress = getProgress(record);
|
||||
const { signature, content } = splitSignature(record.templateContent);
|
||||
const rowClass = hoveredTaskId === record.id ? 'batch-row--hovered' : '';
|
||||
|
||||
return (
|
||||
<Fragment key={record.id}>
|
||||
<Fragment key={record.backendId}>
|
||||
<tr
|
||||
className={['batch-main-row', rowClass].filter(Boolean).join(' ')}
|
||||
onMouseEnter={() => setHoveredTaskId(record.id)}
|
||||
@@ -583,7 +527,7 @@ export function AdminSmsTaskProgressPage() {
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setTerminateTarget(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={() => terminateTask(terminateTarget.id)} variant="danger">确认终止</Button>
|
||||
<Button onClick={() => terminateTask(terminateTarget.backendId)} variant="danger">确认终止</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setTerminateTarget(null)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Smartphone } from 'lucide-react';
|
||||
import { adminApi, type SmsMessageRecord, type SmsUplinkMessage } from '@/api/adminApi';
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
@@ -12,50 +13,27 @@ import {
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type UplinkMessage = {
|
||||
id: string;
|
||||
phone: string;
|
||||
receivedAt: string;
|
||||
content: string;
|
||||
channel: string;
|
||||
accessNo: string;
|
||||
matchedRecord?: MatchedSendRecord;
|
||||
};
|
||||
|
||||
type MatchedSendRecord = {
|
||||
id: string;
|
||||
sentAt: string;
|
||||
enterprise: string;
|
||||
application: string;
|
||||
accessNo: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const matchedRecord: MatchedSendRecord = {
|
||||
id: 'MT20260119001',
|
||||
sentAt: '2026-01-19 12:25:28',
|
||||
enterprise: '上海XXX有限公司',
|
||||
application: 'XXX催收',
|
||||
accessNo: '1069558812',
|
||||
content: '【XXX科技】如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 拒收请回复R',
|
||||
};
|
||||
|
||||
const uplinkMessages: UplinkMessage[] = [
|
||||
{ id: 'MO20260112001', phone: '13500000888', receivedAt: '2026-01-12 19:27:10', content: 'R', channel: '通道名称通道名称通道名称', accessNo: '106912246726', matchedRecord },
|
||||
{ id: 'MO20260112002', phone: '13755558888', receivedAt: '2026-01-12 19:27:19', content: 'R', channel: '通道名称通道名称通道名称', accessNo: '106912246712', matchedRecord },
|
||||
{ id: 'MO20260112003', phone: '18800000555', receivedAt: '2026-01-12 19:27:19', content: '到家了', channel: '通道名称通道名称通道名称', accessNo: '106912246732' },
|
||||
{ id: 'MO20260112004', phone: '', receivedAt: '2026-01-12 19:27:19', content: 'XX', channel: '通道名称通道名称通道名称', accessNo: '106912346745' },
|
||||
{ id: 'MO20260112005', phone: '', receivedAt: '2026-01-12 19:27:19', content: 'XX', channel: '', accessNo: '' },
|
||||
{ id: 'MO20260112006', phone: '', receivedAt: '2026-01-12 19:27:19', content: 'XX', channel: '', accessNo: '' },
|
||||
{ id: 'MO20260112007', phone: '', receivedAt: '2026-01-12 19:27:19', content: '', channel: '', accessNo: '' },
|
||||
{ id: 'MO20260112008', phone: '', receivedAt: '2026-01-12 19:27:19', content: '', channel: '', accessNo: '' },
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
function getDate(value?: string | null) {
|
||||
return value ? value.slice(0, 10) : '';
|
||||
}
|
||||
|
||||
function UplinkDetailModal({ message, onClose }: { message: UplinkMessage; onClose: () => void }) {
|
||||
function getTime(value?: string | null) {
|
||||
return value ? `${value.slice(0, 10)} ${value.slice(11, 19)}` : '-';
|
||||
}
|
||||
|
||||
function UplinkDetailModal({
|
||||
detailError,
|
||||
matchedRecords,
|
||||
matching,
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
detailError: string;
|
||||
matchedRecords: SmsMessageRecord[];
|
||||
matching: boolean;
|
||||
message: SmsUplinkMessage;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
footer={<Button onClick={onClose} variant="ghost">关闭</Button>}
|
||||
@@ -70,19 +48,27 @@ function UplinkDetailModal({ message, onClose }: { message: UplinkMessage; onClo
|
||||
<div className="admin-uplink-info-grid">
|
||||
<div>
|
||||
<span>手机号码</span>
|
||||
<strong>{message.phone || '-'}</strong>
|
||||
<strong>{message.phoneNumber || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>上行时间</span>
|
||||
<strong>{message.receivedAt}</strong>
|
||||
<strong>{getTime(message.receivedAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>上行企业</span>
|
||||
<strong>{message.tenant?.name ?? message.tenantId ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>上行通道</span>
|
||||
<strong>{message.channel || '-'}</strong>
|
||||
<strong>{message.channel?.name ?? message.channelId ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>上行接入号</span>
|
||||
<strong>{message.accessNo || '-'}</strong>
|
||||
<strong>{message.destId || '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>网关消息ID</span>
|
||||
<strong>{message.messageId || '-'}</strong>
|
||||
</div>
|
||||
<div className="admin-uplink-info-grid__full">
|
||||
<span>上行内容</span>
|
||||
@@ -93,36 +79,38 @@ function UplinkDetailModal({ message, onClose }: { message: UplinkMessage; onClo
|
||||
|
||||
<section className="admin-uplink-match-section">
|
||||
<h3>匹配发送记录</h3>
|
||||
<p>搜索到上行短信前7天内的下发成功记录</p>
|
||||
{message.matchedRecord ? (
|
||||
<article className="admin-uplink-match-card">
|
||||
{matching ? <p>正在查询真实下发记录...</p> : null}
|
||||
{detailError ? <p className="form-error">{detailError}</p> : null}
|
||||
{!matching && !message.messageId ? <div className="admin-uplink-empty-match">该上行记录没有网关消息ID,无法匹配下发记录</div> : null}
|
||||
{!matching && message.messageId && matchedRecords.length === 0 && !detailError ? (
|
||||
<div className="admin-uplink-empty-match">暂无匹配发送记录</div>
|
||||
) : null}
|
||||
{matchedRecords.map((record) => (
|
||||
<article className="admin-uplink-match-card" key={record.id}>
|
||||
<div className="admin-uplink-match-grid">
|
||||
<div>
|
||||
<span>发送时间</span>
|
||||
<strong>{message.matchedRecord.sentAt}</strong>
|
||||
<strong>{getTime(record.queuedAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送企业</span>
|
||||
<strong>{message.matchedRecord.enterprise}</strong>
|
||||
<strong>{record.tenant?.name ?? record.tenantId ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送应用</span>
|
||||
<strong>{message.matchedRecord.application}</strong>
|
||||
<strong>{record.application?.name ?? record.applicationId ?? '-'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>客户提交接入号</span>
|
||||
<strong>{message.matchedRecord.accessNo}</strong>
|
||||
<strong>{record.channel?.srcId ?? '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-uplink-match-content">
|
||||
<span>下发内容</span>
|
||||
<p>{message.matchedRecord.content}</p>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
<button type="button">添加到应用黑名单</button>
|
||||
</article>
|
||||
) : (
|
||||
<div className="admin-uplink-empty-match">暂无匹配发送记录</div>
|
||||
)}
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -130,21 +118,58 @@ function UplinkDetailModal({ message, onClose }: { message: UplinkMessage; onClo
|
||||
}
|
||||
|
||||
export function AdminSmsUplinkRecordsPage() {
|
||||
const [messages, setMessages] = useState<SmsUplinkMessage[]>([]);
|
||||
const [matchedRecords, setMatchedRecords] = useState<SmsMessageRecord[]>([]);
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [selectedMessage, setSelectedMessage] = useState<UplinkMessage | null>(null);
|
||||
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [matching, setMatching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [detailError, setDetailError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
adminApi.listAdminUplinkMessages()
|
||||
.then((items) => {
|
||||
setMessages(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '短信上行记录加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function openDetail(message: SmsUplinkMessage) {
|
||||
setSelectedMessage(message);
|
||||
setMatchedRecords([]);
|
||||
setDetailError('');
|
||||
|
||||
if (!message.messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMatching(true);
|
||||
adminApi.listOperationMessages({ tenantId: message.tenantId ?? undefined, messageId: message.messageId })
|
||||
.then((items) => setMatchedRecords(items))
|
||||
.catch((reason: Error) => setDetailError(reason.message || '匹配发送记录加载失败'))
|
||||
.finally(() => setMatching(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredMessages = useMemo(
|
||||
() => uplinkMessages.filter((item) => {
|
||||
() => messages.filter((item) => {
|
||||
const receivedDate = getDate(item.receivedAt);
|
||||
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
const matchesPhone = !phoneKeyword || item.phoneNumber.includes(phoneKeyword);
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
return matchesStartDate && matchesEndDate && matchesPhone && matchesContent;
|
||||
}),
|
||||
[contentKeyword, dateRange.end, dateRange.start, phoneKeyword],
|
||||
[contentKeyword, dateRange.end, dateRange.start, messages, phoneKeyword],
|
||||
);
|
||||
|
||||
function resetFilters() {
|
||||
@@ -153,7 +178,7 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
setContentKeyword('');
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<UplinkMessage>> = [
|
||||
const columns: Array<TableColumn<SmsUplinkMessage>> = [
|
||||
{
|
||||
key: 'select',
|
||||
title: '',
|
||||
@@ -161,18 +186,18 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
align: 'center',
|
||||
render: () => <input aria-label="选择上行记录" className="admin-uplink-checkbox" type="checkbox" />,
|
||||
},
|
||||
{ key: 'phone', title: '手机号码', width: '170px', render: (record) => <strong>{record.phone}</strong> },
|
||||
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <strong>{record.receivedAt}</strong> },
|
||||
{ key: 'phoneNumber', title: '手机号码', width: '170px', render: (record) => <strong>{record.phoneNumber}</strong> },
|
||||
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <strong>{getTime(record.receivedAt)}</strong> },
|
||||
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
|
||||
{ key: 'channel', title: '上行通道', width: '260px', render: (record) => <strong>{record.channel}</strong> },
|
||||
{ key: 'accessNo', title: '上行接入号', width: '180px', render: (record) => <strong>{record.accessNo}</strong> },
|
||||
{ key: 'channel', title: '上行通道', width: '260px', render: (record) => <strong>{record.channel?.name ?? record.channelId}</strong> },
|
||||
{ key: 'accessNo', title: '上行接入号', width: '180px', render: (record) => <strong>{record.destId}</strong> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '140px',
|
||||
align: 'center',
|
||||
render: (record) => (
|
||||
<button className="admin-uplink-detail-link" onClick={() => setSelectedMessage(record)} type="button">查看详情</button>
|
||||
<button className="admin-uplink-detail-link" onClick={() => openDetail(record)} type="button">查看详情</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -191,17 +216,27 @@ export function AdminSmsUplinkRecordsPage() {
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
<Input label="上行内容" onChange={(event) => setContentKeyword(event.target.value)} value={contentKeyword} />
|
||||
<div className="admin-uplink-filter__actions">
|
||||
<Button icon={<Search size={16} />}>查询</Button>
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button onClick={resetFilters} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface admin-uplink-table-card">
|
||||
<Table columns={columns} data={filteredMessages} emptyText="暂无上行短信记录" rowKey="id" />
|
||||
<Table columns={columns} data={loading ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行短信记录...' : '暂无上行短信记录'} rowKey="id" />
|
||||
<Pagination total={filteredMessages.length} />
|
||||
</div>
|
||||
|
||||
{selectedMessage ? <UplinkDetailModal message={selectedMessage} onClose={() => setSelectedMessage(null)} /> : null}
|
||||
{selectedMessage ? (
|
||||
<UplinkDetailModal
|
||||
detailError={detailError}
|
||||
matchedRecords={matchedRecords}
|
||||
matching={matching}
|
||||
message={selectedMessage}
|
||||
onClose={() => setSelectedMessage(null)}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function AdminSystemLogsPage() {
|
||||
|
||||
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
|
||||
{ key: 'time', title: '时间', width: '180px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
|
||||
{ key: 'level', title: '级别', width: '100px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
||||
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
||||
{ key: 'tenant', title: '企业', width: '190px', render: (record) => <strong>{record.tenant}</strong> },
|
||||
{ key: 'module', title: '模块', width: '130px', render: (record) => record.module },
|
||||
{ key: 'operator', title: '操作人', width: '120px', render: (record) => <strong>{record.operator}</strong> },
|
||||
|
||||
@@ -146,7 +146,7 @@ export function AdminUsersPage() {
|
||||
{ key: 'account', title: '邮箱/手机号', width: '230px', render: (record) => <span>{record.email ?? '-'}<br /><small className="muted">{record.phone ?? '-'}</small></span> },
|
||||
{ key: 'role', title: '角色', width: '130px', render: (record) => roleLabel[record.roles[0]?.role.code] ?? record.roles[0]?.role.name ?? '-' },
|
||||
{ key: 'tenant', title: '企业', width: '180px', render: (record) => record.tenant?.name ?? '-' },
|
||||
{ key: 'status', title: '状态', width: '110px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : '禁用'}</Tag> },
|
||||
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '启用' : '禁用'}</Tag> },
|
||||
{ key: 'lastLoginAt', title: '最近登录', width: '190px', render: (record) => record.lastLoginAt ? new Date(record.lastLoginAt).toLocaleString('zh-CN') : '-' },
|
||||
{
|
||||
key: 'actions',
|
||||
|
||||
@@ -1,105 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ClipboardCopy, FileText } from 'lucide-react';
|
||||
import { clientApi, type ApplicationCmppParams, type ClientSmsApplication } from '@/api/adminApi';
|
||||
import { Button, Modal, Tag } from '@/components/ui';
|
||||
|
||||
type LinkStatus = 'connected' | 'disconnected' | 'inactive';
|
||||
type LinkStatus = 'connected' | 'degraded' | 'disconnected' | 'inactive';
|
||||
|
||||
type SmsApplication = {
|
||||
id: string;
|
||||
name: string;
|
||||
appid: string;
|
||||
todaySuccess: number;
|
||||
deliveryRate?: number;
|
||||
price: string;
|
||||
score?: string;
|
||||
status: LinkStatus;
|
||||
params: Array<{ label: string; value: string; highlight?: boolean }>;
|
||||
type ParamRow = {
|
||||
label: string;
|
||||
value: string;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<LinkStatus, string> = {
|
||||
connected: '已连接',
|
||||
degraded: '部分连接',
|
||||
disconnected: '已断',
|
||||
inactive: '未开通',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<LinkStatus, 'success' | 'danger' | 'info'> = {
|
||||
const statusToneMap: Record<LinkStatus, 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
connected: 'success',
|
||||
degraded: 'warning',
|
||||
disconnected: 'danger',
|
||||
inactive: 'info',
|
||||
};
|
||||
|
||||
const applications: SmsApplication[] = [
|
||||
{
|
||||
id: 'app-1',
|
||||
name: '营销推广平台',
|
||||
appid: 'AK_2024010912345678',
|
||||
todaySuccess: 1500,
|
||||
deliveryRate: 95,
|
||||
price: '0.050 元',
|
||||
score: '100分',
|
||||
status: 'connected',
|
||||
params: [
|
||||
{ label: 'ID', value: '113009756' },
|
||||
{ label: '企业名', value: '启瑞物业三网' },
|
||||
{ label: '开通时间', value: '2023-12-13' },
|
||||
{ label: '企业代码', value: 'qrhyyd' },
|
||||
{ label: '账号', value: 'qrhyyd' },
|
||||
{ label: '密码', value: 'm6yZvZKn', highlight: true },
|
||||
{ label: '网关IP', value: '121.40.172.212' },
|
||||
{ label: '网关端口', value: '17890' },
|
||||
{ label: '接入号', value: '106999999' },
|
||||
{ label: '绑定IP', value: '61.129.57.48' },
|
||||
{ label: '连接数', value: '1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app-2',
|
||||
name: '客服系统',
|
||||
appid: 'AK_2024010987654321',
|
||||
todaySuccess: 800,
|
||||
deliveryRate: 90,
|
||||
price: '0.060 元',
|
||||
score: '80分',
|
||||
status: 'disconnected',
|
||||
params: [
|
||||
{ label: 'ID', value: '113009812' },
|
||||
{ label: '企业名', value: '客服系统三网' },
|
||||
{ label: '开通时间', value: '2024-01-09' },
|
||||
{ label: '企业代码', value: 'kfxt' },
|
||||
{ label: '账号', value: 'kfxt' },
|
||||
{ label: '密码', value: 'r8xKvP2m', highlight: true },
|
||||
{ label: '网关IP', value: '121.40.172.213' },
|
||||
{ label: '网关端口', value: '17890' },
|
||||
{ label: '接入号', value: '106988888' },
|
||||
{ label: '绑定IP', value: '61.129.57.49' },
|
||||
{ label: '连接数', value: '1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'app-3',
|
||||
name: '验证码服务',
|
||||
appid: 'AK_2024010811223344',
|
||||
todaySuccess: 0,
|
||||
price: '0.040 元',
|
||||
status: 'inactive',
|
||||
params: [
|
||||
{ label: 'ID', value: '-' },
|
||||
{ label: '企业名', value: '验证码服务' },
|
||||
{ label: '开通时间', value: '-' },
|
||||
{ label: '企业代码', value: '-' },
|
||||
{ label: '账号', value: '-' },
|
||||
{ label: '密码', value: '-' },
|
||||
{ label: '网关IP', value: '-' },
|
||||
{ label: '网关端口', value: '-' },
|
||||
{ label: '接入号', value: '-' },
|
||||
{ label: '绑定IP', value: '-' },
|
||||
{ label: '连接数', value: '-' },
|
||||
],
|
||||
},
|
||||
];
|
||||
function normalizeStatus(application: ClientSmsApplication): LinkStatus {
|
||||
if (application.status !== 'active') {
|
||||
return 'inactive';
|
||||
}
|
||||
return application.cmppStatus ?? 'inactive';
|
||||
}
|
||||
|
||||
function formatPrice(cents?: number | null) {
|
||||
return `${((cents ?? 0) / 100).toFixed(4)} 元`;
|
||||
}
|
||||
|
||||
function mapParams(params: ApplicationCmppParams): ParamRow[] {
|
||||
return [
|
||||
{ label: 'ID', value: params.applicationId },
|
||||
{ label: '企业名', value: params.tenantName },
|
||||
{ label: '应用名称', value: params.applicationName },
|
||||
{ label: 'AppID', value: params.appCode },
|
||||
{ label: '企业代码', value: params.enterpriseCode },
|
||||
{ label: '账号', value: params.account },
|
||||
{ label: '密码', value: params.passwordCipher, highlight: true },
|
||||
{ label: '网关IP', value: params.gatewayHost || '-' },
|
||||
{ label: '网关端口', value: String(params.gatewayPort || '-') },
|
||||
{ label: '接入号', value: params.srcId || '-' },
|
||||
{ label: '连接数', value: String(params.maxConnections || '-') },
|
||||
{ label: '心跳间隔', value: `${params.heartbeatSeconds} 秒` },
|
||||
{ label: '窗口大小', value: String(params.windowSize) },
|
||||
{ label: '协议版本', value: params.protocolVersion },
|
||||
];
|
||||
}
|
||||
|
||||
export function ClientApplicationsPage() {
|
||||
const [selectedApp, setSelectedApp] = useState<SmsApplication | null>(null);
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [selectedApp, setSelectedApp] = useState<ClientSmsApplication | null>(null);
|
||||
const [params, setParams] = useState<ApplicationCmppParams | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [paramsLoading, setParamsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [paramsError, setParamsError] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function loadApplications() {
|
||||
setLoading(true);
|
||||
clientApi.listApplications()
|
||||
.then((items) => {
|
||||
setApplications(items.filter((item) => item.status !== 'deleted'));
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '短信应用加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadApplications();
|
||||
}, []);
|
||||
|
||||
function openParams(application: ClientSmsApplication) {
|
||||
setSelectedApp(application);
|
||||
setParams(null);
|
||||
setParamsError('');
|
||||
setCopied(false);
|
||||
setParamsLoading(true);
|
||||
clientApi.getApplicationCmppParams(application.id)
|
||||
.then((data) => {
|
||||
setParams(data);
|
||||
setParamsError('');
|
||||
})
|
||||
.catch((reason: Error) => setParamsError(reason.message || '接口参数加载失败'))
|
||||
.finally(() => setParamsLoading(false));
|
||||
}
|
||||
|
||||
const selectedRows = useMemo(() => params ? mapParams(params) : [], [params]);
|
||||
|
||||
function copyParams() {
|
||||
if (selectedRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
const text = selectedRows.map((item) => `${item.label}: ${item.value}`).join('\n');
|
||||
void navigator.clipboard.writeText(text).then(() => setCopied(true));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
@@ -111,47 +115,59 @@ export function ClientApplicationsPage() {
|
||||
<span className="muted">共 {applications.length} 个应用</span>
|
||||
</div>
|
||||
|
||||
{loading ? <p className="muted">正在加载短信应用...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
{!loading && !error && applications.length === 0 ? (
|
||||
<div className="surface ui-table__empty">暂无短信应用</div>
|
||||
) : null}
|
||||
|
||||
<div className="sms-app-grid">
|
||||
{applications.map((application) => (
|
||||
<article className="sms-app-card" key={application.id}>
|
||||
<h2>{application.name}</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>appid</dt>
|
||||
<dd>{application.appid}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>今日发送成功</dt>
|
||||
<dd>{application.todaySuccess.toLocaleString('zh-CN')} 条</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>到达率</dt>
|
||||
<dd>{application.deliveryRate ? `${application.deliveryRate}%` : '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>单价</dt>
|
||||
<dd>{application.price}{application.score ? <span>({application.score})</span> : null}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>CMPP链接状态</dt>
|
||||
<dd><Tag tone={statusToneMap[application.status]}>{statusLabelMap[application.status]}</Tag></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<Button onClick={() => setSelectedApp(application)} variant="ghost">查看对接参数</Button>
|
||||
</article>
|
||||
))}
|
||||
{applications.map((application) => {
|
||||
const linkStatus = normalizeStatus(application);
|
||||
return (
|
||||
<article className="sms-app-card" key={application.id}>
|
||||
<h2>{application.name}</h2>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>appid</dt>
|
||||
<dd>{application.id}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>今日发送成功</dt>
|
||||
<dd>{(application.sentToday ?? 0).toLocaleString('zh-CN')} 条</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>到达率</dt>
|
||||
<dd>{application.deliveryRate !== undefined ? `${application.deliveryRate}%` : '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>单价</dt>
|
||||
<dd>{formatPrice(application.customerUnitPrice)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>CMPP链接状态</dt>
|
||||
<dd><Tag tone={statusToneMap[linkStatus]}>{statusLabelMap[linkStatus]}</Tag></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<Button onClick={() => openParams(application)} variant="ghost">查看对接参数</Button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={<Button icon={<ClipboardCopy size={16} />}>复制参数</Button>}
|
||||
footer={<Button disabled={!params} icon={<ClipboardCopy size={16} />} onClick={copyParams}>{copied ? '已复制' : '复制参数'}</Button>}
|
||||
onClose={() => setSelectedApp(null)}
|
||||
open={Boolean(selectedApp)}
|
||||
size="xl"
|
||||
title="接口参数"
|
||||
>
|
||||
{selectedApp ? (
|
||||
{paramsLoading ? <p className="muted">正在加载接口参数...</p> : null}
|
||||
{paramsError ? <p className="form-error">{paramsError}</p> : null}
|
||||
{!paramsLoading && !paramsError && selectedRows.length > 0 ? (
|
||||
<div className="sms-app-param-table">
|
||||
{selectedApp.params.map((item) => (
|
||||
{selectedRows.map((item) => (
|
||||
<div key={item.label}>
|
||||
<span>{item.label}</span>
|
||||
<strong className={item.highlight ? 'text-blue' : undefined}>{item.value}</strong>
|
||||
|
||||
@@ -86,9 +86,9 @@ function mapTask(task: SmsBatchTask): BatchTask {
|
||||
wordCount: [...task.content].length,
|
||||
sendType: task.scheduledAt ? 'scheduled' : 'immediate',
|
||||
scheduledAt: task.scheduledAt,
|
||||
sentCount: task.progressSent,
|
||||
deliveredCount: task.progressDelivered,
|
||||
failedCount: task.progressFailed,
|
||||
sentCount: task.progressSent ?? task.submittedTotal ?? 0,
|
||||
deliveredCount: task.progressDelivered ?? task.successTotal ?? 0,
|
||||
failedCount: task.progressFailed ?? task.failedTotal ?? 0,
|
||||
totalCount: task.progressTotal || task.phoneTotal,
|
||||
templateContent: task.content,
|
||||
status: normalizeTaskStatus(task.status),
|
||||
@@ -157,10 +157,10 @@ export function ClientBatchTasksPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'applicationName', title: '应用名称', width: '110px', render: (record) => record.applicationName },
|
||||
{ key: 'applicationName', title: '应用名称', width: '150px', render: (record) => record.applicationName },
|
||||
{ key: 'submittedAt', title: '提交时间', width: '130px', render: (record) => record.submittedAt },
|
||||
{ key: 'phoneCount', title: '发送号码数', width: '95px', render: (record) => record.phoneCount.toLocaleString('zh-CN') },
|
||||
{ key: 'wordCount', title: '单号码字数', width: '86px', render: (record) => <strong>{record.wordCount} 字</strong> },
|
||||
{ key: 'phoneCount', title: '发送号码数', width: '120px', render: (record) => record.phoneCount.toLocaleString('zh-CN') },
|
||||
{ key: 'wordCount', title: '单号码字数', width: '120px', render: (record) => <strong>{record.wordCount} 字</strong> },
|
||||
{
|
||||
key: 'sendType',
|
||||
title: '发送时间',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
UserCheck,
|
||||
} from 'lucide-react';
|
||||
import { Button, Input, Select, Textarea } from '@/components/ui';
|
||||
import { clientApi, type EnterpriseCertification, type FileObject } from '@/api/adminApi';
|
||||
|
||||
type AuthStep = 'overview' | 'profile' | 'method' | 'recharge' | 'face' | 'faceScan' | 'pending' | 'success' | 'failed';
|
||||
type AuthMethod = 'face' | 'recharge';
|
||||
@@ -22,12 +23,47 @@ const companyInfo = {
|
||||
address: '上海XXX区XX路XX号',
|
||||
};
|
||||
|
||||
function UploadPanel() {
|
||||
type CertificationForm = {
|
||||
companyName: string;
|
||||
licenseNo: string;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactIdCard: string;
|
||||
contactPhone: string;
|
||||
contactEmail: string;
|
||||
legalPerson: string;
|
||||
legalPersonIdCard: string;
|
||||
};
|
||||
|
||||
const emptyCertificationForm: CertificationForm = {
|
||||
companyName: '',
|
||||
licenseNo: '',
|
||||
province: '',
|
||||
city: '',
|
||||
address: '',
|
||||
contactName: '',
|
||||
contactIdCard: '',
|
||||
contactPhone: '',
|
||||
contactEmail: '',
|
||||
legalPerson: '',
|
||||
legalPersonIdCard: '',
|
||||
};
|
||||
|
||||
function UploadPanel({ file, uploading, onFile }: { file: FileObject | null; uploading: boolean; onFile: (file: File | undefined) => void }) {
|
||||
return (
|
||||
<div className="enterprise-upload">
|
||||
<label className="enterprise-upload">
|
||||
<Upload size={38} />
|
||||
<strong>点击上传</strong>
|
||||
</div>
|
||||
<strong>{uploading ? '上传中...' : file?.fileName ?? '点击上传'}</strong>
|
||||
<input
|
||||
accept="image/png,image/jpeg,image/webp,application/pdf"
|
||||
disabled={uploading}
|
||||
onChange={(event) => onFile(event.target.files?.[0])}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,12 +110,118 @@ function AuthHeader({ status }: { status: CertificationStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
function statusFromCertification(certification: EnterpriseCertification | null): CertificationStatus {
|
||||
if (!certification) {
|
||||
return 'uncertified';
|
||||
}
|
||||
if (certification.status === 'approved') {
|
||||
return 'approved';
|
||||
}
|
||||
if (certification.status === 'rejected') {
|
||||
return 'rejected';
|
||||
}
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
export function ClientEnterpriseAuthPage() {
|
||||
const [step, setStep] = useState<AuthStep>('overview');
|
||||
const [method, setMethod] = useState<AuthMethod>('face');
|
||||
const [status, setStatus] = useState<CertificationStatus>('uncertified');
|
||||
const [form, setForm] = useState<CertificationForm>(emptyCertificationForm);
|
||||
const [latestCertification, setLatestCertification] = useState<EnterpriseCertification | null>(null);
|
||||
const [licenseFile, setLicenseFile] = useState<FileObject | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const currentStep = step === 'profile' ? 1 : step === 'method' ? 2 : step === 'recharge' || step === 'face' || step === 'faceScan' ? 3 : step === 'pending' || step === 'success' || step === 'failed' ? 4 : 1;
|
||||
const certificationMaterials = latestCertification?.materials ?? {};
|
||||
const displayCompany = latestCertification?.companyName || form.companyName || companyInfo.name;
|
||||
const displayLicenseNo = latestCertification?.licenseNo || form.licenseNo || companyInfo.code;
|
||||
const displayAddress = String(certificationMaterials.address ?? (form.address || companyInfo.address));
|
||||
const displayLegalPerson = String(certificationMaterials.legalPerson ?? (form.legalPerson || companyInfo.legalPerson));
|
||||
|
||||
function loadCertification() {
|
||||
clientApi.listEnterpriseCertifications()
|
||||
.then((items) => {
|
||||
const latest = items[0] ?? null;
|
||||
setLatestCertification(latest);
|
||||
setStatus(statusFromCertification(latest));
|
||||
if (latest) {
|
||||
const materials = latest.materials ?? {};
|
||||
setForm({
|
||||
companyName: latest.companyName ?? '',
|
||||
licenseNo: latest.licenseNo ?? '',
|
||||
province: String(materials.province ?? ''),
|
||||
city: String(materials.city ?? ''),
|
||||
address: String(materials.address ?? ''),
|
||||
contactName: latest.contactName ?? '',
|
||||
contactIdCard: String(materials.contactIdCard ?? ''),
|
||||
contactPhone: latest.contactPhone ?? '',
|
||||
contactEmail: String(materials.contactEmail ?? ''),
|
||||
legalPerson: String(materials.legalPerson ?? ''),
|
||||
legalPersonIdCard: String(materials.legalPersonIdCard ?? ''),
|
||||
});
|
||||
}
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业认证信息加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadCertification();
|
||||
}, []);
|
||||
|
||||
function updateForm<K extends keyof CertificationForm>(key: K, value: CertificationForm[K]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function uploadLicense(file: File | undefined) {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
clientApi.uploadFileObject(file, { purpose: 'enterprise_certification', prefix: 'enterprise-certifications/license' })
|
||||
.then((fileObject) => {
|
||||
setLicenseFile(fileObject);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '营业执照上传失败'))
|
||||
.finally(() => setUploading(false));
|
||||
}
|
||||
|
||||
function submitCertification() {
|
||||
if (!form.companyName.trim() || !form.licenseNo.trim() || !form.contactName.trim() || !form.contactPhone.trim()) {
|
||||
setError('请填写企业名称、统一社会信用代码、联系人姓名和联系人手机号');
|
||||
setStep('profile');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
clientApi.submitEnterpriseCertification({
|
||||
companyName: form.companyName.trim(),
|
||||
licenseNo: form.licenseNo.trim(),
|
||||
contactName: form.contactName.trim(),
|
||||
contactPhone: form.contactPhone.trim(),
|
||||
materials: {
|
||||
licenseFileObjectId: licenseFile?.id,
|
||||
licenseFileName: licenseFile?.fileName,
|
||||
province: form.province,
|
||||
city: form.city,
|
||||
address: form.address,
|
||||
contactIdCard: form.contactIdCard,
|
||||
contactEmail: form.contactEmail,
|
||||
legalPerson: form.legalPerson,
|
||||
legalPersonIdCard: form.legalPersonIdCard,
|
||||
authMethod: method,
|
||||
},
|
||||
})
|
||||
.then((created) => {
|
||||
setLatestCertification(created);
|
||||
setStatus('pending');
|
||||
setStep('pending');
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '企业认证提交失败'))
|
||||
.finally(() => setSubmitting(false));
|
||||
}
|
||||
|
||||
if (step === 'overview') {
|
||||
const overviewCopy = status === 'approved'
|
||||
@@ -93,6 +235,7 @@ export function ClientEnterpriseAuthPage() {
|
||||
return (
|
||||
<section className="page-stack enterprise-page">
|
||||
<AuthHeader status={status} />
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className={`surface enterprise-status-card enterprise-status-card--${status}`}>
|
||||
<strong>{overviewCopy}</strong>
|
||||
@@ -105,11 +248,11 @@ export function ClientEnterpriseAuthPage() {
|
||||
|
||||
<div className="surface enterprise-info-card">
|
||||
<dl>
|
||||
<div><dt>企业名称:</dt><dd>{status === 'approved' ? companyInfo.name : '待认证'}</dd></div>
|
||||
<div><dt>认证时间:</dt><dd>{status === 'approved' ? companyInfo.certifiedAt : '待审核完成'}</dd></div>
|
||||
<div><dt>统一社会信用代码:</dt><dd>{status === 'approved' ? companyInfo.code : '待认证'}</dd></div>
|
||||
<div><dt>通讯地址:</dt><dd>{status === 'approved' ? companyInfo.address : '待认证'}</dd></div>
|
||||
<div><dt>法定代表人:</dt><dd>{status === 'approved' ? companyInfo.legalPerson : '待认证'}</dd></div>
|
||||
<div><dt>企业名称:</dt><dd>{latestCertification ? displayCompany : '待认证'}</dd></div>
|
||||
<div><dt>认证时间:</dt><dd>{latestCertification?.reviewedAt ?? latestCertification?.submittedAt ?? '待审核完成'}</dd></div>
|
||||
<div><dt>统一社会信用代码:</dt><dd>{latestCertification ? displayLicenseNo : '待认证'}</dd></div>
|
||||
<div><dt>通讯地址:</dt><dd>{latestCertification ? displayAddress : '待认证'}</dd></div>
|
||||
<div><dt>法定代表人:</dt><dd>{latestCertification ? displayLegalPerson : '待认证'}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
@@ -119,6 +262,7 @@ export function ClientEnterpriseAuthPage() {
|
||||
return (
|
||||
<section className="page-stack enterprise-page">
|
||||
<h1 className="enterprise-page-title">企业认证</h1>
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface enterprise-flow-card">
|
||||
<EnterpriseStepper current={currentStep} />
|
||||
@@ -126,40 +270,46 @@ export function ClientEnterpriseAuthPage() {
|
||||
{step === 'profile' ? (
|
||||
<div className="enterprise-form-panel">
|
||||
<label className="enterprise-required">营业执照</label>
|
||||
<UploadPanel />
|
||||
<UploadPanel file={licenseFile} onFile={uploadLicense} uploading={uploading} />
|
||||
<p className="enterprise-help">请上传电子版营业执照,JPG或PNG格式,大小不超过5M</p>
|
||||
|
||||
<Input label="* 企业名称" placeholder="请填写企业全称" hint="请严格按照营业执照上的企业名称进行填写" />
|
||||
<Input label="* 统一社会信用代码/其他组织机构代码" placeholder="请填写统一社会信用代码(若无请填写其他组织机构代码)" />
|
||||
<Input label="* 企业名称" onChange={(event) => updateForm('companyName', event.target.value)} placeholder="请填写企业全称" hint="请严格按照营业执照上的企业名称进行填写" value={form.companyName} />
|
||||
<Input label="* 统一社会信用代码/其他组织机构代码" onChange={(event) => updateForm('licenseNo', event.target.value)} placeholder="请填写统一社会信用代码(若无请填写其他组织机构代码)" value={form.licenseNo} />
|
||||
|
||||
<div className="enterprise-address-selects">
|
||||
<span>* 通讯地址</span>
|
||||
<div>
|
||||
<Select
|
||||
onChange={(event) => updateForm('province', event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择省/直辖市', value: '' },
|
||||
{ label: '上海市', value: 'shanghai' },
|
||||
{ label: '北京市', value: 'beijing' },
|
||||
{ label: '上海市', value: '上海市' },
|
||||
{ label: '北京市', value: '北京市' },
|
||||
{ label: '山东省', value: '山东省' },
|
||||
{ label: '河南省', value: '河南省' },
|
||||
]}
|
||||
defaultValue=""
|
||||
value={form.province}
|
||||
/>
|
||||
<Select
|
||||
onChange={(event) => updateForm('city', event.target.value)}
|
||||
options={[
|
||||
{ label: '请选择', value: '' },
|
||||
{ label: '浦东新区', value: 'pudong' },
|
||||
{ label: '徐汇区', value: 'xuhui' },
|
||||
{ label: '济南市', value: '济南市' },
|
||||
{ label: '郑州市', value: '郑州市' },
|
||||
]}
|
||||
defaultValue=""
|
||||
value={form.city}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea placeholder="请填写详细的通讯地址,可与证件上的地址不一致" rows={5} />
|
||||
<Textarea onChange={(event) => updateForm('address', event.target.value)} placeholder="请填写详细的通讯地址,可与证件上的地址不一致" rows={5} value={form.address} />
|
||||
|
||||
<p className="enterprise-form-note">为方便沟通企业进展情况,需补充联系人(法人或员工都可)信息</p>
|
||||
<Input label="* 企业联系人姓名" placeholder="请填写企业联系人姓名" />
|
||||
<Input label="* 企业联系人身份证号" placeholder="请填写企业联系人身份证号" />
|
||||
<Input label="* 企业联系人手机号" placeholder="请填写企业联系人手机号" />
|
||||
<Input label="企业联系人邮箱" placeholder="请填写企业联系人邮箱" />
|
||||
<Input label="* 企业联系人姓名" onChange={(event) => updateForm('contactName', event.target.value)} placeholder="请填写企业联系人姓名" value={form.contactName} />
|
||||
<Input label="* 企业联系人身份证号" onChange={(event) => updateForm('contactIdCard', event.target.value)} placeholder="请填写企业联系人身份证号" value={form.contactIdCard} />
|
||||
<Input label="* 企业联系人手机号" onChange={(event) => updateForm('contactPhone', event.target.value)} placeholder="请填写企业联系人手机号" value={form.contactPhone} />
|
||||
<Input label="企业联系人邮箱" onChange={(event) => updateForm('contactEmail', event.target.value)} placeholder="请填写企业联系人邮箱" value={form.contactEmail} />
|
||||
|
||||
<div className="enterprise-actions">
|
||||
<Button onClick={() => setStep('overview')} variant="secondary">取消</Button>
|
||||
@@ -212,7 +362,7 @@ export function ClientEnterpriseAuthPage() {
|
||||
</p>
|
||||
|
||||
<h2>付款方信息</h2>
|
||||
<p><span>付款企业</span><strong>XXXXXXX公司</strong></p>
|
||||
<p><span>付款企业</span><strong>{form.companyName || '待填写企业名称'}</strong></p>
|
||||
<small>请使用与企业营业执照名称一致的对公账户进行转账,以便系统进行识别,若填报错误企业信息,转账将无效暨审核不通过</small>
|
||||
|
||||
<div className="enterprise-info-alert">
|
||||
@@ -221,7 +371,7 @@ export function ClientEnterpriseAuthPage() {
|
||||
</div>
|
||||
|
||||
<div className="enterprise-actions enterprise-actions--center">
|
||||
<Button onClick={() => { setStatus('pending'); setStep('pending'); }}>确认并充值</Button>
|
||||
<Button disabled={submitting} onClick={submitCertification}>{submitting ? '提交中...' : '确认并充值'}</Button>
|
||||
<Button onClick={() => setStep('method')} variant="secondary">返回选择认证方式</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,8 +381,8 @@ export function ClientEnterpriseAuthPage() {
|
||||
<div className="enterprise-face-panel">
|
||||
<p><span>认证方式</span><strong>企业法人人脸识别认证</strong></p>
|
||||
<h2>企业法人基本信息</h2>
|
||||
<Input label="* 企业法人姓名" placeholder="请填写企业法人姓名" />
|
||||
<Input label="* 企业法人身份证号" placeholder="请填写企业法人身份证号" />
|
||||
<Input label="* 企业法人姓名" onChange={(event) => updateForm('legalPerson', event.target.value)} placeholder="请填写企业法人姓名" value={form.legalPerson} />
|
||||
<Input label="* 企业法人身份证号" onChange={(event) => updateForm('legalPersonIdCard', event.target.value)} placeholder="请填写企业法人身份证号" value={form.legalPersonIdCard} />
|
||||
|
||||
<div className="enterprise-actions">
|
||||
<Button onClick={() => setStep('method')} variant="secondary">返回选择认证方式</Button>
|
||||
@@ -246,8 +396,8 @@ export function ClientEnterpriseAuthPage() {
|
||||
<p><span>认证方式</span><strong>企业法人人脸识别认证</strong></p>
|
||||
<h2>企业法人基本信息</h2>
|
||||
<dl className="enterprise-legal-summary">
|
||||
<div><dt>企业法人姓名</dt><dd>张三</dd></div>
|
||||
<div><dt>企业法人身份证号</dt><dd>162xxxxxxxxxxxxx</dd></div>
|
||||
<div><dt>企业法人姓名</dt><dd>{form.legalPerson || '-'}</dd></div>
|
||||
<div><dt>企业法人身份证号</dt><dd>{form.legalPersonIdCard || '-'}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div className="enterprise-qr-section">
|
||||
@@ -257,7 +407,7 @@ export function ClientEnterpriseAuthPage() {
|
||||
<span>完成扫描操作后,资料将提交至运营端审核。</span>
|
||||
<div className="enterprise-actions enterprise-actions--center">
|
||||
<Button onClick={() => setStep('face')} variant="secondary">重新填写法人信息</Button>
|
||||
<Button onClick={() => { setStatus('pending'); setStep('pending'); }}>提交审核</Button>
|
||||
<Button disabled={submitting} onClick={submitCertification}>{submitting ? '提交中...' : '提交审核'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -269,8 +419,8 @@ export function ClientEnterpriseAuthPage() {
|
||||
<h2>认证资料已提交,等待运营审核</h2>
|
||||
<p>当前状态:运营端审核中。审核通过后将自动解锁短信发送、签名报备等能力;未通过时可根据驳回原因重新提交。</p>
|
||||
<dl>
|
||||
<div><dt>企业名称:</dt><dd>{companyInfo.name}</dd></div>
|
||||
<div><dt>提交时间:</dt><dd>2026年07月02日 09:58:00</dd></div>
|
||||
<div><dt>企业名称:</dt><dd>{displayCompany}</dd></div>
|
||||
<div><dt>提交时间:</dt><dd>{latestCertification?.submittedAt ?? '-'}</dd></div>
|
||||
<div><dt>审核时效:</dt><dd>1 个工作日内</dd></div>
|
||||
<div><dt>当前节点:</dt><dd>运营端资料审核</dd></div>
|
||||
</dl>
|
||||
@@ -285,11 +435,11 @@ export function ClientEnterpriseAuthPage() {
|
||||
<span><Check size={70} /></span>
|
||||
<h2>认证审核通过</h2>
|
||||
<dl>
|
||||
<div><dt>企业名称:</dt><dd>{companyInfo.name}</dd></div>
|
||||
<div><dt>统一社会信用代码:</dt><dd>{companyInfo.code}</dd></div>
|
||||
<div><dt>法定代表人:</dt><dd>{companyInfo.legalPerson}</dd></div>
|
||||
<div><dt>认证时间:</dt><dd>{companyInfo.certifiedAt}</dd></div>
|
||||
<div><dt>通讯地址:</dt><dd>{companyInfo.address}</dd></div>
|
||||
<div><dt>企业名称:</dt><dd>{displayCompany}</dd></div>
|
||||
<div><dt>统一社会信用代码:</dt><dd>{displayLicenseNo}</dd></div>
|
||||
<div><dt>法定代表人:</dt><dd>{displayLegalPerson}</dd></div>
|
||||
<div><dt>认证时间:</dt><dd>{latestCertification?.reviewedAt ?? latestCertification?.submittedAt ?? '-'}</dd></div>
|
||||
<div><dt>通讯地址:</dt><dd>{displayAddress}</dd></div>
|
||||
</dl>
|
||||
<Button onClick={() => setStep('overview')} variant="secondary">返回概览</Button>
|
||||
</div>
|
||||
@@ -299,7 +449,7 @@ export function ClientEnterpriseAuthPage() {
|
||||
<div className="enterprise-result enterprise-result--failed">
|
||||
<span>!</span>
|
||||
<h2>认证审核未通过</h2>
|
||||
<p>驳回原因:营业执照照片不清晰,企业联系人手机号无法核验。请修改资料后重新提交。</p>
|
||||
<p>驳回原因:{latestCertification?.rejectReason ?? '请根据运营端审核意见修改资料后重新提交。'}</p>
|
||||
<button type="button" onClick={() => setStep('profile')}>重新提交资料 <ChevronRight size={18} /></button>
|
||||
<div className="enterprise-actions enterprise-actions--center">
|
||||
<Button onClick={() => setStep('profile')}>重新提交</Button>
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Clock3, Eye, FileImage, Search, TrendingUp, ZoomIn } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
DetailInfoGrid,
|
||||
DetailProgressStats,
|
||||
DetailSection,
|
||||
DetailTitle,
|
||||
getRateTone,
|
||||
Input,
|
||||
Modal,
|
||||
ProgressBar,
|
||||
QueryPanel,
|
||||
RateCard,
|
||||
RateOverview,
|
||||
Select,
|
||||
Tag,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type MmsTaskStatus = 'completed' | 'sending';
|
||||
type SendType = 'immediate' | 'scheduled';
|
||||
|
||||
type MmsTask = {
|
||||
id: string;
|
||||
status: MmsTaskStatus;
|
||||
applicationName: string;
|
||||
submittedAt: string;
|
||||
title: string;
|
||||
content: string;
|
||||
image: string;
|
||||
phoneCount: number;
|
||||
sentCount: number;
|
||||
totalCount: number;
|
||||
sendType: SendType;
|
||||
scheduledAt?: string;
|
||||
};
|
||||
|
||||
const statusToneMap: Record<MmsTaskStatus, 'success' | 'info'> = {
|
||||
completed: 'success',
|
||||
sending: 'info',
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<MmsTaskStatus, string> = {
|
||||
completed: '已完成',
|
||||
sending: '发送中',
|
||||
};
|
||||
|
||||
const tasksSeed: MmsTask[] = [
|
||||
{
|
||||
id: 'MMSTASK20260317001',
|
||||
status: 'completed',
|
||||
applicationName: '营销活动彩信',
|
||||
submittedAt: '2026-03-17 10:30:15',
|
||||
title: '新春佳节,福气满满',
|
||||
content: '【优品商城】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=500&q=80',
|
||||
phoneCount: 2000,
|
||||
sentCount: 2000,
|
||||
totalCount: 2000,
|
||||
sendType: 'immediate',
|
||||
},
|
||||
{
|
||||
id: 'MMSTASK20260317002',
|
||||
status: 'sending',
|
||||
applicationName: '营销活动彩信',
|
||||
submittedAt: '2026-03-17 11:15:30',
|
||||
title: '重磅新品震撼来袭',
|
||||
content: '【优品商城】优品商城倾力推出全新智能手表,高颜值高性能!限时特惠价299元,前100名购买送蓝牙耳机一份。',
|
||||
image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=500&q=80',
|
||||
phoneCount: 1500,
|
||||
sentCount: 850,
|
||||
totalCount: 1500,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-18 09:00:00',
|
||||
},
|
||||
{
|
||||
id: 'MMSTASK20260317003',
|
||||
status: 'sending',
|
||||
applicationName: '会员服务彩信',
|
||||
submittedAt: '2026-03-17 14:20:45',
|
||||
title: '会员专属优惠来了',
|
||||
content: '【优品商城】尊敬的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅限开放,精选热门产品5折主打优惠。',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=500&q=80',
|
||||
phoneCount: 3000,
|
||||
sentCount: 2100,
|
||||
totalCount: 3000,
|
||||
sendType: 'immediate',
|
||||
},
|
||||
{
|
||||
id: 'MMSTASK20260316001',
|
||||
status: 'completed',
|
||||
applicationName: '节日祝福彩信',
|
||||
submittedAt: '2026-03-16 16:45:00',
|
||||
title: '中秋团圆,月满人圆',
|
||||
content: '【优品商城】月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!精选月饼礼盒8折优惠。',
|
||||
image: 'https://images.unsplash.com/photo-1600861194942-f883de0dfe96?auto=format&fit=crop&w=500&q=80',
|
||||
phoneCount: 1200,
|
||||
sentCount: 1200,
|
||||
totalCount: 1200,
|
||||
sendType: 'scheduled',
|
||||
scheduledAt: '2026-03-17 08:00:00',
|
||||
},
|
||||
{
|
||||
id: 'MMSTASK20260316002',
|
||||
status: 'sending',
|
||||
applicationName: '营销活动彩信',
|
||||
submittedAt: '2026-03-16 18:10:20',
|
||||
title: '周年庆典,感恩回馈',
|
||||
content: '【优品商城】优品商城5周年,感恩有你一路相伴。全场满减、买一送一,参与互动赢取千元购物卡。',
|
||||
image: 'https://images.unsplash.com/photo-1464349095431-e9a21285b5f3?auto=format&fit=crop&w=500&q=80',
|
||||
phoneCount: 800,
|
||||
sentCount: 450,
|
||||
totalCount: 800,
|
||||
sendType: 'immediate',
|
||||
},
|
||||
];
|
||||
|
||||
const carrierStats = [
|
||||
{ name: '中国移动', success: 983, total: 1000, rate: 98.3 },
|
||||
{ name: '中国联通', success: 590, total: 600, rate: 98.33 },
|
||||
{ name: '中国电信', success: 392, total: 400, rate: 98 },
|
||||
];
|
||||
|
||||
const cityStats = [
|
||||
{ city: '北京', total: 400, success: 393 },
|
||||
{ city: '上海', total: 360, success: 354 },
|
||||
{ city: '深圳', total: 320, success: 315 },
|
||||
{ city: '广州', total: 280, success: 275 },
|
||||
{ city: '杭州', total: 240, success: 236 },
|
||||
{ city: '成都', total: 200, success: 196 },
|
||||
{ city: '武汉', total: 200, success: 196 },
|
||||
];
|
||||
|
||||
function getProgress(task: MmsTask) {
|
||||
return Math.round((task.sentCount / task.totalCount) * 100);
|
||||
}
|
||||
|
||||
function getDeliveredCount(task: MmsTask) {
|
||||
if (task.status === 'completed') {
|
||||
return Math.round(task.totalCount * 0.9825);
|
||||
}
|
||||
return task.sentCount;
|
||||
}
|
||||
|
||||
export function ClientMmsBatchTasksPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [application, setApplication] = useState('all');
|
||||
const [submittedDateRange, setSubmittedDateRange] = useState<DateRangeValue>({});
|
||||
const [selectedTask, setSelectedTask] = useState<MmsTask | null>(null);
|
||||
const [previewTask, setPreviewTask] = useState<MmsTask | null>(null);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const names = Array.from(new Set(tasksSeed.map((item) => item.applicationName)));
|
||||
return [{ label: '全部应用', value: 'all' }, ...names.map((name) => ({ label: name, value: name }))];
|
||||
}, []);
|
||||
|
||||
const filteredTasks = tasksSeed.filter((item) => {
|
||||
const matchesKeyword = !keyword || item.id.includes(keyword);
|
||||
const matchesApplication = application === 'all' || item.applicationName === application;
|
||||
const submittedDate = item.submittedAt.slice(0, 10);
|
||||
const matchesStartDate = !submittedDateRange.start || submittedDate >= submittedDateRange.start;
|
||||
const matchesEndDate = !submittedDateRange.end || submittedDate <= submittedDateRange.end;
|
||||
return matchesKeyword && matchesApplication && matchesStartDate && matchesEndDate;
|
||||
});
|
||||
|
||||
const columns: Array<TableColumn<MmsTask>> = [
|
||||
{
|
||||
key: 'id',
|
||||
title: '任务编号',
|
||||
width: '150px',
|
||||
render: (record) => (
|
||||
<div className="batch-task-id">
|
||||
<strong>{record.id}</strong>
|
||||
<Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'applicationName', title: '应用名称', width: '110px', render: (record) => record.applicationName },
|
||||
{ key: 'submittedAt', title: '提交时间', width: '130px', render: (record) => record.submittedAt },
|
||||
{
|
||||
key: 'content',
|
||||
title: '彩信内容',
|
||||
width: '410px',
|
||||
render: (record) => (
|
||||
<div className="mms-task-content">
|
||||
<img alt={record.title} src={record.image} />
|
||||
<div>
|
||||
<strong>{record.title}</strong>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'phoneCount', title: '发送号码数', width: '96px', render: (record) => <strong>{record.phoneCount.toLocaleString('zh-CN')}</strong> },
|
||||
{
|
||||
key: 'sendType',
|
||||
title: '发送时间',
|
||||
width: '125px',
|
||||
render: (record) => (
|
||||
<div className="batch-send-time">
|
||||
<span><Clock3 size={14} />{record.sendType === 'immediate' ? '立即发送' : '定时发送'}</span>
|
||||
{record.scheduledAt ? <small>{record.scheduledAt}</small> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
title: '发送进度',
|
||||
width: '170px',
|
||||
render: (record) => {
|
||||
const percent = getProgress(record);
|
||||
return (
|
||||
<div className="batch-progress">
|
||||
<div>
|
||||
<span>{record.sentCount.toLocaleString('zh-CN')} / {record.totalCount.toLocaleString('zh-CN')}</span>
|
||||
<strong>{percent}%</strong>
|
||||
</div>
|
||||
<div className="batch-progress__track">
|
||||
<span className={`batch-progress__bar batch-progress__bar--${record.status}`} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
width: '150px',
|
||||
render: (record) => (
|
||||
<div className="batch-actions mms-task-actions">
|
||||
<Button icon={<Eye size={14} />} onClick={() => setSelectedTask(record)} size="sm" variant="ghost">详情</Button>
|
||||
<Button icon={<ZoomIn size={14} />} onClick={() => setPreviewTask(record)} size="sm" variant="ghost">预览</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><FileImage size={22} /></span>
|
||||
<h1>查看批量任务</h1>
|
||||
</div>
|
||||
|
||||
<QueryPanel title="查询条件" summary={<>共找到 <strong>{filteredTasks.length}</strong> 条任务记录</>}>
|
||||
<Input label="任务编号" onChange={(event) => setKeyword(event.target.value)} placeholder="输入任务编号搜索" prefix={<Search size={16} />} value={keyword} />
|
||||
<Select label="选择应用" onChange={(event) => setApplication(event.target.value)} options={applicationOptions} value={application} />
|
||||
<DateRangeInput label="提交时间" onChange={setSubmittedDateRange} value={submittedDateRange} />
|
||||
</QueryPanel>
|
||||
|
||||
<div className="surface batch-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table batch-table mms-task-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} style={{ width: column.width, textAlign: column.align ?? 'left' }}>{column.title}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTasks.map((record, index) => (
|
||||
<tr key={record.id}>
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} style={{ textAlign: column.align ?? 'left' }}>{column.render(record, index)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal footer={<Button onClick={() => setSelectedTask(null)}>关闭</Button>} onClose={() => setSelectedTask(null)} open={Boolean(selectedTask)} size="xl" title={<DetailTitle title="任务详情" subtitle={selectedTask?.id} />}>
|
||||
{selectedTask ? (
|
||||
<div className="task-detail">
|
||||
<DetailSection title="基本信息" extra={<Tag tone={statusToneMap[selectedTask.status]}>{statusLabelMap[selectedTask.status]}</Tag>}>
|
||||
<DetailInfoGrid
|
||||
items={[
|
||||
{ label: '任务编号', value: selectedTask.id },
|
||||
{ label: '应用名称', value: selectedTask.applicationName },
|
||||
{ label: '提交时间', value: selectedTask.submittedAt },
|
||||
{ label: '发送方式', value: <span className="task-send-type"><Clock3 size={16} />{selectedTask.sendType === 'immediate' ? '立即发送' : '定时发送'}</span> },
|
||||
{ label: '任务总号码数', value: `${selectedTask.totalCount.toLocaleString('zh-CN')} 个`, tone: 'primary' },
|
||||
{
|
||||
label: '彩信模板内容',
|
||||
value: (
|
||||
<div className="mms-detail-template">
|
||||
<img alt={selectedTask.title} src={selectedTask.image} />
|
||||
<div><strong>{selectedTask.title}</strong><p>{selectedTask.content}</p></div>
|
||||
</div>
|
||||
),
|
||||
full: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="发送统计">
|
||||
<DetailProgressStats
|
||||
label="任务进度"
|
||||
meta={`${selectedTask.sentCount.toLocaleString('zh-CN')} / ${selectedTask.totalCount.toLocaleString('zh-CN')} 已处理`}
|
||||
percent={getProgress(selectedTask)}
|
||||
status={selectedTask.status}
|
||||
stats={[
|
||||
{ label: '提交总数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||||
{ label: '提交成功数量', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||||
{ label: '发送成功数量', value: getDeliveredCount(selectedTask).toLocaleString('zh-CN') },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title={<><TrendingUp size={20} /> 成功率分析</>}>
|
||||
{(() => {
|
||||
const overallRate = (getDeliveredCount(selectedTask) / selectedTask.totalCount) * 100;
|
||||
return (
|
||||
<RateOverview
|
||||
label="总体成功率"
|
||||
metrics={[
|
||||
{ label: '成功总数', value: getDeliveredCount(selectedTask).toLocaleString('zh-CN') },
|
||||
{ label: '总计', value: selectedTask.totalCount.toLocaleString('zh-CN') },
|
||||
]}
|
||||
rate={overallRate}
|
||||
tone={getRateTone(overallRate)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<h4>运营商成功率</h4>
|
||||
<div className="carrier-rate-grid">
|
||||
{carrierStats.map((item) => <RateCard key={item.name} meta={<><span>成功: {item.success}</span><span>总计: {item.total}</span></>} rate={item.rate} title={item.name} tone={getRateTone(item.rate)} />)}
|
||||
</div>
|
||||
<h4>各城市成功率</h4>
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table city-rate-table">
|
||||
<thead><tr><th>城市</th><th>总发送数</th><th>成功数</th><th>成功率</th><th>进度</th></tr></thead>
|
||||
<tbody>
|
||||
{cityStats.map((item) => {
|
||||
const rate = (item.success / item.total) * 100;
|
||||
return (
|
||||
<tr key={item.city}>
|
||||
<td><strong>{item.city}</strong></td>
|
||||
<td>{item.total}</td>
|
||||
<td><span className={`rate-text ui-rate-tone-${getRateTone(rate)}`}>{item.success}</span></td>
|
||||
<td><span className={`rate-text ui-rate-tone-${getRateTone(rate)}`}>{rate.toFixed(2)}%</span></td>
|
||||
<td><ProgressBar percent={rate} tone={getRateTone(rate)} /></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal footer={<Button onClick={() => setPreviewTask(null)}>关闭</Button>} onClose={() => setPreviewTask(null)} open={Boolean(previewTask)} title={<div className="template-modal-title"><h2>彩信预览</h2><p>{previewTask?.id}</p></div>}>
|
||||
{previewTask ? (
|
||||
<div className="mms-preview">
|
||||
<img alt={previewTask.title} src={previewTask.image} />
|
||||
<h3>{previewTask.title}</h3>
|
||||
<p>{previewTask.content}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, FileImage, Search, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
Input,
|
||||
Modal,
|
||||
QueryPanel,
|
||||
Select,
|
||||
Tag,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type MmsSendStatus = 'success' | 'unknown' | 'failed';
|
||||
|
||||
type MmsSendRecord = {
|
||||
id: string;
|
||||
applicationName: string;
|
||||
sentAt: string;
|
||||
title: string;
|
||||
content: string;
|
||||
image: string;
|
||||
phone: string;
|
||||
carrier: '中国移动' | '中国联通' | '中国电信';
|
||||
region: string;
|
||||
status: MmsSendStatus;
|
||||
receipt: 'DELIVRD' | 'UNKNOWN' | 'UNDELIV';
|
||||
receiptAt?: string;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<MmsSendStatus, string> = {
|
||||
success: '成功',
|
||||
unknown: '未知',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<MmsSendStatus, 'success' | 'info' | 'danger'> = {
|
||||
success: 'success',
|
||||
unknown: 'info',
|
||||
failed: 'danger',
|
||||
};
|
||||
|
||||
const mmsSendRows: MmsSendRecord[] = [
|
||||
{
|
||||
id: 'MMSD20260317001',
|
||||
applicationName: '营销活动彩信',
|
||||
sentAt: '2026-03-17 10:30:15',
|
||||
title: '新春佳节,福气满满',
|
||||
content: '【优品商城】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13800138000',
|
||||
carrier: '中国移动',
|
||||
region: '北京市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-17 10:30:20',
|
||||
},
|
||||
{
|
||||
id: 'MMSD20260317002',
|
||||
applicationName: '营销活动彩信',
|
||||
sentAt: '2026-03-17 10:32:25',
|
||||
title: '重磅新品震撼来袭',
|
||||
content: '【优品商城】优品商城倾力推出全新智能手表,高颜值高性能!限时特惠价299元,前100名购买送蓝牙耳机一份。',
|
||||
image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13900139000',
|
||||
carrier: '中国联通',
|
||||
region: '上海市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-17 10:32:30',
|
||||
},
|
||||
{
|
||||
id: 'MMSD20260317003',
|
||||
applicationName: '会员服务彩信',
|
||||
sentAt: '2026-03-17 10:35:40',
|
||||
title: '会员专属优惠来了',
|
||||
content: '【优品商城】尊敬的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅限开放,精选热门产品5折主打优惠,立即购买,先到先得!',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13700137000',
|
||||
carrier: '中国电信',
|
||||
region: '深圳市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-17 10:35:46',
|
||||
},
|
||||
{
|
||||
id: 'MMSD20260317004',
|
||||
applicationName: '节日祝福彩信',
|
||||
sentAt: '2026-03-17 10:38:10',
|
||||
title: '中秋团圆,月满人圆',
|
||||
content: '【优品商城】月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!精选月饼礼盒8折优惠,送礼佳品,立即选购!',
|
||||
image: 'https://images.unsplash.com/photo-1600861194942-f883de0dfe96?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13600136000',
|
||||
carrier: '中国移动',
|
||||
region: '广州市',
|
||||
status: 'unknown',
|
||||
receipt: 'UNKNOWN',
|
||||
},
|
||||
{
|
||||
id: 'MMSD20260317005',
|
||||
applicationName: '营销活动彩信',
|
||||
sentAt: '2026-03-17 10:41:58',
|
||||
title: '周年庆典,感恩回馈',
|
||||
content: '【优品商城】优品商城5周年,感恩有你一路相伴。全场满减、买一送一,参与互动赢取千元购物卡。',
|
||||
image: 'https://images.unsplash.com/photo-1464349095431-e9a21285b5f3?auto=format&fit=crop&w=500&q=80',
|
||||
phone: '13500135000',
|
||||
carrier: '中国联通',
|
||||
region: '杭州市',
|
||||
status: 'failed',
|
||||
receipt: 'UNDELIV',
|
||||
},
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
export function ClientMmsSendDetailPage() {
|
||||
const [applicationName, setApplicationName] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [previewRecord, setPreviewRecord] = useState<MmsSendRecord | null>(null);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const applications = Array.from(new Set(mmsSendRows.map((item) => item.applicationName)));
|
||||
return [{ label: '全部应用', value: 'all' }, ...applications.map((item) => ({ label: item, value: item }))];
|
||||
}, []);
|
||||
|
||||
const filteredRows = mmsSendRows.filter((item) => {
|
||||
const sentDate = getDate(item.sentAt);
|
||||
const matchesApplication = applicationName === 'all' || item.applicationName === applicationName;
|
||||
const matchesStatus = status === 'all' || item.status === status;
|
||||
const matchesStartDate = !dateRange.start || sentDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || sentDate <= dateRange.end;
|
||||
const matchesContent = !contentKeyword || item.title.includes(contentKeyword) || item.content.includes(contentKeyword);
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
return matchesApplication && matchesStatus && matchesStartDate && matchesEndDate && matchesContent && matchesPhone;
|
||||
});
|
||||
|
||||
const columns: Array<TableColumn<MmsSendRecord>> = [
|
||||
{ key: 'applicationName', title: '应用名称', width: '92px', render: (record) => <strong className="send-detail-app-name">{record.applicationName}</strong> },
|
||||
{ key: 'sentAt', title: '发送时间', width: '120px', render: (record) => <span className="send-detail-time">{record.sentAt.slice(0, 10)}<small>{record.sentAt.slice(11)}</small></span> },
|
||||
{
|
||||
key: 'content',
|
||||
title: '彩信内容',
|
||||
width: '450px',
|
||||
render: (record) => (
|
||||
<div className="mms-detail-row-content">
|
||||
<img alt={record.title} src={record.image} />
|
||||
<div>
|
||||
<strong>{record.title}</strong>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'phone', title: '手机号码', width: '128px', render: (record) => <strong>{record.phone}</strong> },
|
||||
{ key: 'carrier', title: '所属运营商', width: '86px', render: (record) => <span className="send-detail-carrier">{record.carrier}</span> },
|
||||
{ key: 'region', title: '号码归属地', width: '80px', render: (record) => <span className="send-detail-region">{record.region.slice(0, 2)}<small>{record.region.slice(2)}</small></span> },
|
||||
{ key: 'status', title: '发送状态', width: '88px', align: 'center', render: (record) => <Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag> },
|
||||
{ key: 'receipt', title: '彩信回执', width: '92px', align: 'center', render: (record) => <strong className={record.receipt === 'DELIVRD' ? 'send-detail-receipt-code' : 'muted'}>{record.receipt}</strong> },
|
||||
{
|
||||
key: 'receiptAt',
|
||||
title: '回执时间',
|
||||
width: '118px',
|
||||
render: (record) => record.receiptAt ? <span className="send-detail-time">{record.receiptAt.slice(0, 10)}<small>{record.receiptAt.slice(11)}</small></span> : <span className="muted">-</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '92px',
|
||||
align: 'center',
|
||||
render: (record) => <Button icon={<Eye size={14} />} onClick={() => setPreviewRecord(record)} size="sm" variant="ghost">预览</Button>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><FileImage size={22} /></span>
|
||||
<h1>彩信发送详情</h1>
|
||||
</div>
|
||||
|
||||
<QueryPanel title="查询条件" summary={<>共找到 <strong>{filteredRows.length}</strong> 条发送记录</>}>
|
||||
<Select label="应用名称" onChange={(event) => setApplicationName(event.target.value)} options={applicationOptions} value={applicationName} />
|
||||
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
|
||||
<Select
|
||||
label="发送状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]}
|
||||
value={status}
|
||||
/>
|
||||
<Input label="彩信内容" onChange={(event) => setContentKeyword(event.target.value)} placeholder="输入关键词搜索" prefix={<Search size={16} />} value={contentKeyword} />
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入手机号搜索" prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
</QueryPanel>
|
||||
|
||||
<div className="surface send-detail-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table send-detail-table mms-send-detail-table">
|
||||
<thead>
|
||||
<tr>{columns.map((column) => <th key={column.key} style={{ width: column.width, textAlign: column.align ?? 'left' }}>{column.title}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr><td className="ui-table__empty" colSpan={columns.length}>暂无发送记录</td></tr>
|
||||
) : filteredRows.map((record, index) => (
|
||||
<tr key={record.id}>
|
||||
{columns.map((column) => <td key={column.key} style={{ textAlign: column.align ?? 'left' }}>{column.render(record, index)}</td>)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal footer={<Button onClick={() => setPreviewRecord(null)}>关闭</Button>} onClose={() => setPreviewRecord(null)} open={Boolean(previewRecord)} title={<div className="template-modal-title"><h2>彩信预览</h2><p>{previewRecord?.phone}</p></div>}>
|
||||
{previewRecord ? (
|
||||
<div className="mms-preview">
|
||||
<img alt={previewRecord.title} src={previewRecord.image} />
|
||||
<h3>{previewRecord.title}</h3>
|
||||
<p>{previewRecord.content}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Download, FileImage, FileText, ImageIcon, Plus, Send, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, DateTimeInput, Input, Select, Tag } from '@/components/ui';
|
||||
|
||||
type SendMode = 'now' | 'scheduled';
|
||||
type ReceiverMode = 'manual' | 'import';
|
||||
|
||||
type Recipient = {
|
||||
id: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
const mmsApplications = [
|
||||
{ label: '营销活动彩信', value: 'marketing' },
|
||||
{ label: '会员运营彩信', value: 'member' },
|
||||
{ label: '客户关怀彩信', value: 'care' },
|
||||
];
|
||||
|
||||
const mmsSignatures = [
|
||||
{ label: '【活动推广】', value: 'promo' },
|
||||
{ label: '【优品发布】', value: 'product' },
|
||||
{ label: '【周年庆典】', value: 'anniversary' },
|
||||
];
|
||||
|
||||
const mmsTemplates = [
|
||||
{
|
||||
label: '春节祝福',
|
||||
value: 'spring',
|
||||
title: '新春佳节,福气满满',
|
||||
content: '尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!',
|
||||
},
|
||||
{
|
||||
label: '新品发布',
|
||||
value: 'product',
|
||||
title: '重磅新品震撼来袭',
|
||||
content: '优品商城倾力推出全新智能手表款高颜值,性能强!限时特惠价299元。',
|
||||
},
|
||||
{
|
||||
label: '节日问候',
|
||||
value: 'festival',
|
||||
title: '中秋团圆,月满人圆',
|
||||
content: '月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!',
|
||||
},
|
||||
];
|
||||
|
||||
export function ClientMmsSendPage() {
|
||||
const [taskName, setTaskName] = useState('');
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [signatureId, setSignatureId] = useState('');
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
const [sendMode, setSendMode] = useState<SendMode>('now');
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [receiverMode, setReceiverMode] = useState<ReceiverMode>('manual');
|
||||
const [recipients, setRecipients] = useState<Recipient[]>([{ id: '1', phone: '' }]);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const selectedSignature = useMemo(
|
||||
() => mmsSignatures.find((item) => item.value === signatureId),
|
||||
[signatureId],
|
||||
);
|
||||
const selectedTemplate = useMemo(
|
||||
() => mmsTemplates.find((item) => item.value === templateId),
|
||||
[templateId],
|
||||
);
|
||||
const validRecipients = recipients.filter((item) => item.phone.trim());
|
||||
const previewTitle = selectedTemplate?.title ?? '请选择签名和模板';
|
||||
const previewText = selectedSignature && selectedTemplate
|
||||
? `${selectedSignature.label}${selectedTemplate.content}`
|
||||
: '请选择签名和模板';
|
||||
const wordCount = previewText.length;
|
||||
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && (receiverMode === 'import' || validRecipients.length > 0) && (sendMode === 'now' || scheduledAt));
|
||||
|
||||
function updateRecipient(id: string, phone: string) {
|
||||
setRecipients((items) => items.map((item) => (item.id === id ? { ...item, phone } : item)));
|
||||
}
|
||||
|
||||
function addRecipient() {
|
||||
setRecipients((items) => [...items, { id: Date.now().toString(), phone: '' }]);
|
||||
}
|
||||
|
||||
function removeRecipient(id: string) {
|
||||
setRecipients((items) => (items.length === 1 ? items : items.filter((item) => item.id !== id)));
|
||||
}
|
||||
|
||||
function submitTask() {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="sms-send-page mms-send-page">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<FileImage size={22} />
|
||||
</span>
|
||||
<h1>发送彩信</h1>
|
||||
{submitted ? <Tag tone="success">发送任务已提交</Tag> : null}
|
||||
</div>
|
||||
|
||||
<div className="sms-send-layout">
|
||||
<div className="sms-send-main">
|
||||
<section className="send-card">
|
||||
<div className="send-card__title">
|
||||
<span>1</span>
|
||||
<h2>基本信息</h2>
|
||||
</div>
|
||||
<Input
|
||||
label="任务名称"
|
||||
onChange={(event) => setTaskName(event.target.value)}
|
||||
placeholder="请输入任务名称,便于后续查找和管理"
|
||||
value={taskName}
|
||||
/>
|
||||
<div className="send-form-row">
|
||||
<Select
|
||||
label="彩信应用"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
options={[{ label: '选择应用', value: '' }, ...mmsApplications]}
|
||||
value={applicationId}
|
||||
/>
|
||||
<Select
|
||||
label="彩信签名"
|
||||
onChange={(event) => setSignatureId(event.target.value)}
|
||||
options={[{ label: '选择签名', value: '' }, ...mmsSignatures]}
|
||||
value={signatureId}
|
||||
/>
|
||||
<Select
|
||||
label="彩信模板"
|
||||
onChange={(event) => setTemplateId(event.target.value)}
|
||||
options={[{ label: '选择模板', value: '' }, ...mmsTemplates.map(({ label, value }) => ({ label, value }))]}
|
||||
value={templateId}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="send-card">
|
||||
<div className="send-card__title">
|
||||
<span>2</span>
|
||||
<h2>发送时间</h2>
|
||||
</div>
|
||||
<div className="radio-row">
|
||||
<label>
|
||||
<input checked={sendMode === 'now'} onChange={() => setSendMode('now')} type="radio" />
|
||||
<span>立即发送</span>
|
||||
</label>
|
||||
<label>
|
||||
<input checked={sendMode === 'scheduled'} onChange={() => setSendMode('scheduled')} type="radio" />
|
||||
<span>定时发送</span>
|
||||
</label>
|
||||
{sendMode === 'scheduled' ? (
|
||||
<DateTimeInput onChange={setScheduledAt} value={scheduledAt} />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="send-card">
|
||||
<div className="send-card__title">
|
||||
<span>3</span>
|
||||
<h2>发送对象</h2>
|
||||
</div>
|
||||
<div className="receiver-tabs">
|
||||
<button
|
||||
className={receiverMode === 'manual' ? 'active' : ''}
|
||||
onClick={() => setReceiverMode('manual')}
|
||||
type="button"
|
||||
>
|
||||
手动输入
|
||||
</button>
|
||||
<button
|
||||
className={receiverMode === 'import' ? 'active' : ''}
|
||||
onClick={() => setReceiverMode('import')}
|
||||
type="button"
|
||||
>
|
||||
导入表格
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{receiverMode === 'manual' ? (
|
||||
<>
|
||||
<div className="mms-send-tip">💡 彩信模板不支持信号,只需输入接收人手机号即可</div>
|
||||
<div className="receiver-table">
|
||||
<div className="receiver-table__head">
|
||||
<span>序号</span>
|
||||
<span>手机号码</span>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
{recipients.map((item, index) => (
|
||||
<div className="receiver-table__row" key={item.id}>
|
||||
<span>{index + 1}</span>
|
||||
<input
|
||||
inputMode="tel"
|
||||
onChange={(event) => updateRecipient(item.id, event.target.value)}
|
||||
placeholder="请输入手机号"
|
||||
value={item.phone}
|
||||
/>
|
||||
<button
|
||||
aria-label="删除接收人"
|
||||
disabled={recipients.length === 1}
|
||||
onClick={() => removeRecipient(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="add-recipient" onClick={addRecipient} type="button">
|
||||
<Plus size={16} />
|
||||
征集接收人
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="mms-import-mode">
|
||||
<div className="mms-import-rules">
|
||||
<div>
|
||||
<strong>📋 表格文件格式要求</strong>
|
||||
<p>第一列:手机号码</p>
|
||||
<p>支持 .xlsx 和 .csv 格式</p>
|
||||
<p>每行一个手机号</p>
|
||||
<p>彩信模板不支持指标,需填写指标列</p>
|
||||
</div>
|
||||
<Button icon={<Download size={16} />}>下载模板</Button>
|
||||
</div>
|
||||
<div className="import-panel mms-import-panel">
|
||||
<div className="import-panel__icon">
|
||||
<Upload size={28} />
|
||||
</div>
|
||||
<strong>点击上传或拖拽文件到此处</strong>
|
||||
<span>支持 .xlsx、.csv 格式</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="send-submit-row">
|
||||
<Button disabled={!canSubmit} icon={<Send size={18} />} onClick={submitTask}>
|
||||
提交发送任务
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="sms-preview-card mms-preview-card">
|
||||
<div className="preview-title">
|
||||
<FileImage size={19} />
|
||||
<h2>彩信预览</h2>
|
||||
</div>
|
||||
<div className="mms-message-preview">
|
||||
<div>
|
||||
<strong>{previewTitle}</strong>
|
||||
<p>{previewText}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-stats">
|
||||
<div>
|
||||
<span>字数统计</span>
|
||||
<strong>{wordCount}字</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>单价</span>
|
||||
<strong>¥0.30/人</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-note">💡 彩信按0.30元/条,支持图片、视频、音频等多媒体内容</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Edit3, FilePenLine, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Table, Tag } from '@/components/ui';
|
||||
import type { TableColumn } from '@/components/ui/Table';
|
||||
|
||||
type ReportStatus = 'approved' | 'pending' | 'rejected' | 'waiting';
|
||||
|
||||
type MmsSignature = {
|
||||
id: string;
|
||||
name: string;
|
||||
application: string;
|
||||
mobile: ReportStatus;
|
||||
unicom: ReportStatus;
|
||||
telecom: ReportStatus;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<ReportStatus, string> = {
|
||||
approved: '已通过',
|
||||
pending: '审核中',
|
||||
rejected: '已驳回',
|
||||
waiting: '待报备',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<ReportStatus, 'success' | 'info' | 'danger' | 'neutral'> = {
|
||||
approved: 'success',
|
||||
pending: 'info',
|
||||
rejected: 'danger',
|
||||
waiting: 'neutral',
|
||||
};
|
||||
|
||||
const initialSignatures: MmsSignature[] = [
|
||||
{
|
||||
id: 'mms-sig-1',
|
||||
name: '【科技公司】',
|
||||
application: '营销推广平台',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'approved',
|
||||
editable: false,
|
||||
},
|
||||
{
|
||||
id: 'mms-sig-2',
|
||||
name: '【客户服务】',
|
||||
application: '客户服务系统',
|
||||
mobile: 'approved',
|
||||
unicom: 'pending',
|
||||
telecom: 'approved',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
id: 'mms-sig-3',
|
||||
name: '【验证码】',
|
||||
application: '安全验证平台',
|
||||
mobile: 'waiting',
|
||||
unicom: 'waiting',
|
||||
telecom: 'waiting',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
id: 'mms-sig-4',
|
||||
name: '【促销活动】',
|
||||
application: '电商平台',
|
||||
mobile: 'rejected',
|
||||
unicom: 'approved',
|
||||
telecom: 'pending',
|
||||
editable: true,
|
||||
},
|
||||
{
|
||||
id: 'mms-sig-5',
|
||||
name: '【会员中心】',
|
||||
application: '会员管理系统',
|
||||
mobile: 'pending',
|
||||
unicom: 'pending',
|
||||
telecom: 'pending',
|
||||
editable: true,
|
||||
},
|
||||
];
|
||||
|
||||
function UploadBox({ label, compact = false }: { label?: string; compact?: boolean }) {
|
||||
return (
|
||||
<div className={compact ? 'signature-upload signature-upload--compact' : 'signature-upload'}>
|
||||
{label ? <span>{label}</span> : null}
|
||||
<Upload size={compact ? 30 : 42} />
|
||||
<strong>{compact ? '上传文件' : '点击上传 或拖拽文件到此处'}</strong>
|
||||
{!compact ? <small>支持 PNG、JPG、JPEG 格式,大小不超过 3M</small> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarrierStatusTag({ status }: { status: ReportStatus }) {
|
||||
return <Tag tone={statusToneMap[status]}>{statusLabelMap[status]}</Tag>;
|
||||
}
|
||||
|
||||
function MmsSignatureForm({ signature }: { signature?: MmsSignature }) {
|
||||
return (
|
||||
<div className="signature-form">
|
||||
<section>
|
||||
<h3>基本信息</h3>
|
||||
<div className="signature-alert">
|
||||
<Info size={18} />
|
||||
<span>签名需履行报备,并遵照管理部门审核结果方可使用。请用PNG、JPG或JPEG格式的正版文件,且大小不超过3M。</span>
|
||||
</div>
|
||||
<div className="signature-form-grid">
|
||||
<Select
|
||||
defaultValue={signature ? 'company' : ''}
|
||||
label="* 签名依据"
|
||||
options={[
|
||||
{ label: '请选择签名依据', value: '' },
|
||||
{ label: '企事业单位证明', value: 'company' },
|
||||
{ label: '商标注册证', value: 'trademark' },
|
||||
{ label: '授权委托书', value: 'authorization' },
|
||||
]}
|
||||
/>
|
||||
<Input label="* 彩信签名" defaultValue={signature?.name ?? ''} placeholder="请输入彩信签名,如【XXXX公司】" />
|
||||
</div>
|
||||
<UploadBox label="* 资质凭证" />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>公司信息</h3>
|
||||
<div className="signature-form-grid">
|
||||
<Input label="* 公司名称" defaultValue={signature?.application ?? ''} placeholder="请输入公司名称" />
|
||||
<Input label="* 统一社会信用代码" placeholder="请输入统一社会信用代码" />
|
||||
<Input label="* 法人姓名" placeholder="请输入法人姓名" />
|
||||
<Input label="法人身份证号" placeholder="请输入法人身份证号" />
|
||||
<UploadBox compact label="法人身份证照片-人像面" />
|
||||
<UploadBox compact label="法人身份证照片-国徽面" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>责任人信息</h3>
|
||||
<div className="signature-form-grid">
|
||||
<Input label="* 责任人姓名" placeholder="请输入责任人姓名" />
|
||||
<Input label="* 责任人手机号" placeholder="请输入责任人手机号" />
|
||||
<Input label="* 责任人身份证号" placeholder="请输入责任人身份证号" />
|
||||
<Input label="责任人邮箱" placeholder="请输入责任人邮箱" />
|
||||
<UploadBox compact label="责任人身份证照片-人像面" />
|
||||
<UploadBox compact label="责任人身份证照片-国徽面" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientMmsSignatureReportPage() {
|
||||
const [signatures, setSignatures] = useState(initialSignatures);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [modalState, setModalState] = useState<{ mode: 'add' | 'edit'; signature?: MmsSignature } | null>(null);
|
||||
|
||||
const filteredSignatures = signatures.filter((item) => (
|
||||
item.name.includes(keyword) || item.application.includes(keyword)
|
||||
));
|
||||
|
||||
function deleteSignature(id: string) {
|
||||
setSignatures((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<MmsSignature>>>(() => [
|
||||
{
|
||||
key: 'name',
|
||||
title: '签名',
|
||||
render: (record) => <strong className="mms-signature-name">{record.name}</strong>,
|
||||
},
|
||||
{
|
||||
key: 'application',
|
||||
title: '所属应用',
|
||||
render: (record) => <span className="muted">{record.application}</span>,
|
||||
},
|
||||
{
|
||||
key: 'mobile',
|
||||
title: '移动',
|
||||
render: (record) => <CarrierStatusTag status={record.mobile} />,
|
||||
},
|
||||
{
|
||||
key: 'unicom',
|
||||
title: '联通',
|
||||
render: (record) => <CarrierStatusTag status={record.unicom} />,
|
||||
},
|
||||
{
|
||||
key: 'telecom',
|
||||
title: '电信',
|
||||
render: (record) => <CarrierStatusTag status={record.telecom} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '220px',
|
||||
render: (record) => (
|
||||
<div className="mms-signature-actions">
|
||||
<Button
|
||||
disabled={!record.editable}
|
||||
icon={<Edit3 size={16} />}
|
||||
onClick={() => setModalState({ mode: 'edit', signature: record })}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="mms-signature-delete"
|
||||
icon={<Trash2 size={16} />}
|
||||
onClick={() => deleteSignature(record.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="signature-page-header">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<FilePenLine size={22} />
|
||||
</span>
|
||||
<h1>签名报备</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalState({ mode: 'add' })}>添加签名</Button>
|
||||
</div>
|
||||
|
||||
<div className="signature-search-row">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索签名名称或应用"
|
||||
prefix={<Search size={17} />}
|
||||
value={keyword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mms-signature-table-card">
|
||||
<Table columns={columns} data={filteredSignatures} rowKey="id" />
|
||||
</div>
|
||||
|
||||
<div className="mms-pagination">
|
||||
<button disabled type="button"><</button>
|
||||
<button className="active" type="button">1</button>
|
||||
<button type="button">2</button>
|
||||
<button type="button">></button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setModalState(null)}>取消</Button>
|
||||
<Button onClick={() => setModalState(null)}>确认</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setModalState(null)}
|
||||
open={Boolean(modalState)}
|
||||
size="xl"
|
||||
title={<div className="signature-modal-title"><h2>{modalState?.mode === 'edit' ? '编辑签名' : '添加签名'}</h2><p>{modalState?.mode === 'edit' ? '修改彩信签名的相关信息' : '新增彩信签名的相关信息'}</p></div>}
|
||||
>
|
||||
<MmsSignatureForm signature={modalState?.signature} />
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,467 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, FileText, ImageIcon, Music, Plus, Search, Trash2, Video } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
|
||||
type CarrierStatus = 'approved' | 'pending' | 'rejected';
|
||||
type TemplateAccent = 'green' | 'blue' | 'gray';
|
||||
type FrameType = 'text' | 'image' | 'video' | 'audio';
|
||||
|
||||
type MmsFrame = {
|
||||
id: string;
|
||||
type: FrameType;
|
||||
text?: string;
|
||||
};
|
||||
|
||||
type MmsTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
application: string;
|
||||
signature: string;
|
||||
title: string;
|
||||
image: string;
|
||||
content: string;
|
||||
mobile: CarrierStatus;
|
||||
unicom: CarrierStatus;
|
||||
telecom: CarrierStatus;
|
||||
updatedAt: string;
|
||||
accent: TemplateAccent;
|
||||
frames: MmsFrame[];
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<CarrierStatus, string> = {
|
||||
approved: '已通过',
|
||||
pending: '审核中',
|
||||
rejected: '已驳回',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<CarrierStatus, 'success' | 'info' | 'danger'> = {
|
||||
approved: 'success',
|
||||
pending: 'info',
|
||||
rejected: 'danger',
|
||||
};
|
||||
|
||||
const frameTypeOptions = [
|
||||
{ label: '文字', value: 'text' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '视频', value: 'video' },
|
||||
{ label: '音频', value: 'audio' },
|
||||
];
|
||||
|
||||
const frameIconMap: Record<FrameType, typeof FileText> = {
|
||||
text: FileText,
|
||||
image: ImageIcon,
|
||||
video: Video,
|
||||
audio: Music,
|
||||
};
|
||||
|
||||
const frameFormatMap: Record<FrameType, string> = {
|
||||
text: '',
|
||||
image: '支持格式:jpg, jpeg, png, gif',
|
||||
video: '支持格式:mp4, mpg, 3gp, 3gpp',
|
||||
audio: '支持格式:mp3, mpeg3',
|
||||
};
|
||||
|
||||
const initialTemplates: MmsTemplate[] = [
|
||||
{
|
||||
id: 'mms-tpl-1',
|
||||
name: '春节祝福',
|
||||
code: 'MMS_1a2b3c4d5e6f',
|
||||
application: '营销活动彩信',
|
||||
signature: '【活动推广】',
|
||||
title: '新春佳节,福气满满',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【活动推广】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'approved',
|
||||
updatedAt: '2028-01-15 10:30:00',
|
||||
accent: 'green',
|
||||
frames: [
|
||||
{ id: 'frame-1', type: 'text', text: '请输入文字内容' },
|
||||
{ id: 'frame-2', type: 'image' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mms-tpl-2',
|
||||
name: '新品发布',
|
||||
code: 'MMS_2b3c4d5e6f7a',
|
||||
application: '营销活动彩信',
|
||||
signature: '【优品发布】',
|
||||
title: '重磅新品震撼来袭',
|
||||
image: 'https://images.unsplash.com/photo-1434494878577-86c23bcb06b9?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【优品发布】优品商城倾力推出全新智能手表款高颜值,性能强!限时特惠价299元,前100名购买送蓝牙耳机一份。图片展示高端产品,点击立即抢购!',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'pending',
|
||||
updatedAt: '2028-01-16 14:20:00',
|
||||
accent: 'blue',
|
||||
frames: [
|
||||
{ id: 'frame-3', type: 'text', text: '新品发布文案' },
|
||||
{ id: 'frame-4', type: 'image' },
|
||||
{ id: 'frame-5', type: 'video' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mms-tpl-3',
|
||||
name: '会员专享',
|
||||
code: 'MMS_3c4d5e6f7a8b',
|
||||
application: '会员运营彩信',
|
||||
signature: '【节日特惠】',
|
||||
title: '会员专属优惠来了',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【节日特惠】尊享的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅开放,精选热门产品5折主打优惠,立即购买,先到先得!',
|
||||
mobile: 'pending',
|
||||
unicom: 'pending',
|
||||
telecom: 'pending',
|
||||
updatedAt: '2028-01-14 09:15:00',
|
||||
accent: 'gray',
|
||||
frames: [
|
||||
{ id: 'frame-6', type: 'image' },
|
||||
{ id: 'frame-7', type: 'text', text: '会员专享优惠说明' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mms-tpl-4',
|
||||
name: '促销活动',
|
||||
code: 'MMS_4d5e6f7a8b9c',
|
||||
application: '营销活动彩信',
|
||||
signature: '【限时特惠】',
|
||||
title: '限时抢购,低至3折',
|
||||
image: 'https://images.unsplash.com/photo-1607083206968-13611e3d76db?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【限时特惠】优品商城年中大促火热进行中!全场3折起,满299减50,满599减120。精选商品限时抢购,数量有限,先到先得!',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'approved',
|
||||
updatedAt: '2028-01-13 16:45:00',
|
||||
accent: 'green',
|
||||
frames: [
|
||||
{ id: 'frame-8', type: 'text', text: '促销活动介绍' },
|
||||
{ id: 'frame-9', type: 'image' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mms-tpl-5',
|
||||
name: '节日问候',
|
||||
code: 'MMS_5e6f7a8b9c0d',
|
||||
application: '客户关怀彩信',
|
||||
signature: '【节日祝福】',
|
||||
title: '中秋团圆,月满人圆',
|
||||
image: 'https://images.unsplash.com/photo-1600861194942-f883de0dfe96?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【节日祝福】月圆中秋,情满人间。优品商城全体员工祝您中秋快乐,阖家团圆!精选月饼礼盒8折优惠,送礼佳品,立即选购!',
|
||||
mobile: 'approved',
|
||||
unicom: 'pending',
|
||||
telecom: 'approved',
|
||||
updatedAt: '2028-01-12 11:00:00',
|
||||
accent: 'blue',
|
||||
frames: [
|
||||
{ id: 'frame-10', type: 'image' },
|
||||
{ id: 'frame-11', type: 'audio' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mms-tpl-6',
|
||||
name: '品牌活动',
|
||||
code: 'MMS_6f7a8b9c0d1e',
|
||||
application: '品牌运营彩信',
|
||||
signature: '【周年庆典】',
|
||||
title: '周年庆典,感恩回馈',
|
||||
image: 'https://images.unsplash.com/photo-1464349095431-e9a21285b5f3?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【周年庆典】优品商城5周年庆典,感恩回馈!全场满减,买一送一,更有神秘大奖等你来拿。参与互动赢取千元购物卡,机会难得!',
|
||||
mobile: 'approved',
|
||||
unicom: 'approved',
|
||||
telecom: 'approved',
|
||||
updatedAt: '2028-01-11 15:30:00',
|
||||
accent: 'green',
|
||||
frames: [
|
||||
{ id: 'frame-12', type: 'video' },
|
||||
{ id: 'frame-13', type: 'text', text: '品牌周年庆介绍' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function FrameEditor({
|
||||
frame,
|
||||
index,
|
||||
onRemove,
|
||||
onTypeChange,
|
||||
}: {
|
||||
frame: MmsFrame;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
onTypeChange: (type: FrameType) => void;
|
||||
}) {
|
||||
const Icon = frameIconMap[frame.type];
|
||||
|
||||
return (
|
||||
<div className="mms-frame">
|
||||
<div className="mms-frame__top">
|
||||
<strong>第 {index + 1} 帧</strong>
|
||||
<Select
|
||||
className="mms-frame-type"
|
||||
onChange={(event) => onTypeChange(event.target.value as FrameType)}
|
||||
options={frameTypeOptions}
|
||||
value={frame.type}
|
||||
/>
|
||||
<button aria-label="删除帧" onClick={onRemove} type="button">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{frame.type === 'text' ? (
|
||||
<Textarea placeholder="请输入文字内容" defaultValue={frame.text} />
|
||||
) : (
|
||||
<div className="mms-file-drop">
|
||||
<Icon size={22} />
|
||||
<div>
|
||||
<strong>选择文件</strong>
|
||||
<span>未选择任何文件</span>
|
||||
</div>
|
||||
<small>{frameFormatMap[frame.type]}</small>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MmsTemplateModal({
|
||||
mode,
|
||||
template,
|
||||
onClose,
|
||||
}: {
|
||||
mode: 'create' | 'edit';
|
||||
template?: MmsTemplate;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [frames, setFrames] = useState<MmsFrame[]>(template?.frames ?? [
|
||||
{ id: 'new-frame-1', type: 'text' },
|
||||
{ id: 'new-frame-2', type: 'text' },
|
||||
]);
|
||||
|
||||
const totalSize = useMemo(() => {
|
||||
const textSize = frames.filter((frame) => frame.type === 'text').length * 0.2;
|
||||
const mediaSize = frames.filter((frame) => frame.type !== 'text').length * 180;
|
||||
return Math.min(2000, textSize + mediaSize).toFixed(1);
|
||||
}, [frames]);
|
||||
|
||||
function addFrame() {
|
||||
if (frames.length >= 9) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFrames((items) => [...items, { id: `new-frame-${Date.now()}`, type: 'text' }]);
|
||||
}
|
||||
|
||||
function removeFrame(id: string) {
|
||||
setFrames((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
function changeFrameType(id: string, type: FrameType) {
|
||||
setFrames((items) => items.map((item) => (item.id === id ? { ...item, type } : item)));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setPreviewOpen(true)}>预览</Button>
|
||||
<Button variant="ghost" onClick={onClose}>取消</Button>
|
||||
<Button className="mms-save-button" onClick={onClose}>确认</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={
|
||||
<div className="mms-template-modal-title">
|
||||
<h2>{mode === 'edit' ? '编辑彩信模板' : '创建彩信模板'}</h2>
|
||||
<p>彩信通过视频短信渠道发送,最多支持9帧,每帧可以是文字、图片、视频或者音频,内容总大小不超过2000KB。提交后需三大运营商审核,审核时间1-3个工作日。</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="mms-template-form">
|
||||
<div className="mms-template-form-grid">
|
||||
<Input label="彩信模板名称 *" defaultValue={template?.name ?? ''} placeholder="春节祝福" />
|
||||
<Select
|
||||
defaultValue={template?.application ?? '营销活动彩信'}
|
||||
label="彩信应用 *"
|
||||
options={[
|
||||
{ label: '营销活动彩信', value: '营销活动彩信' },
|
||||
{ label: '会员运营彩信', value: '会员运营彩信' },
|
||||
{ label: '客户关怀彩信', value: '客户关怀彩信' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
defaultValue={template?.signature ?? '【活动推广】'}
|
||||
label="签名 *"
|
||||
options={[
|
||||
{ label: '【活动推广】', value: '【活动推广】' },
|
||||
{ label: '【优品发布】', value: '【优品发布】' },
|
||||
{ label: '【周年庆典】', value: '【周年庆典】' },
|
||||
]}
|
||||
/>
|
||||
<Input label="彩信标题 *" defaultValue={template?.title ?? ''} placeholder="新春佳节,福气满满" />
|
||||
|
||||
<div className="mms-frame-header">
|
||||
<div>
|
||||
<strong>彩信内容 *</strong>
|
||||
<span>({frames.length}/9 帧,已使用 {totalSize}KB/2000KB)</span>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={addFrame} variant="ghost">添加帧</Button>
|
||||
</div>
|
||||
|
||||
<div className="mms-frame-list">
|
||||
{frames.map((frame, index) => (
|
||||
<FrameEditor
|
||||
frame={frame}
|
||||
index={index}
|
||||
key={frame.id}
|
||||
onRemove={() => removeFrame(frame.id)}
|
||||
onTypeChange={(type) => changeFrameType(frame.id, type)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={<Button onClick={() => setPreviewOpen(false)}>关闭</Button>}
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
open={previewOpen}
|
||||
size="md"
|
||||
title={<div className="template-modal-title"><h2>当前模板预览</h2><p>{template?.name ?? '新建彩信模板'}</p></div>}
|
||||
>
|
||||
<div className="mms-preview">
|
||||
{template?.image ? <img alt={template.name} src={template.image} /> : null}
|
||||
<h3>{template?.title ?? '新春佳节,福气满满'}</h3>
|
||||
<p>{template?.content ?? '这里展示当前彩信模板的文字、图片、视频或音频帧内容。保存前可先核对标题、签名和各帧顺序。'}</p>
|
||||
<div className="mms-preview-frames">
|
||||
{frames.map((frame, index) => {
|
||||
const Icon = frameIconMap[frame.type];
|
||||
return (
|
||||
<span key={frame.id}>
|
||||
<Icon size={15} />
|
||||
第 {index + 1} 帧 · {frameTypeOptions.find((option) => option.value === frame.type)?.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientMmsTemplatesPage() {
|
||||
const [templates, setTemplates] = useState(initialTemplates);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [modalState, setModalState] = useState<{ mode: 'create' | 'edit'; template?: MmsTemplate } | null>(null);
|
||||
const [previewTemplate, setPreviewTemplate] = useState<MmsTemplate | null>(null);
|
||||
|
||||
const filteredTemplates = templates.filter((item) => (
|
||||
item.name.includes(keyword) || item.application.includes(keyword) || item.title.includes(keyword)
|
||||
));
|
||||
|
||||
function deleteTemplate(id: string) {
|
||||
setTemplates((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="mms-template-header">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon">
|
||||
<FileText size={22} />
|
||||
</span>
|
||||
<div>
|
||||
<h1>彩信模板列表</h1>
|
||||
<p>共 {templates.length} 个模板</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mms-template-toolbar">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索模板名称或应用"
|
||||
prefix={<Search size={17} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalState({ mode: 'create' })}>创建彩信模板</Button>
|
||||
</div>
|
||||
|
||||
<div className="mms-template-grid">
|
||||
{filteredTemplates.map((template) => (
|
||||
<article className={`mms-template-card mms-template-card--${template.accent}`} key={template.id}>
|
||||
<span className="mms-template-app-tag">{template.application}</span>
|
||||
<div className="mms-template-card__body">
|
||||
<div className="mms-template-meta">
|
||||
<h2>{template.name}</h2>
|
||||
<p className="mms-template-code">{template.code}</p>
|
||||
<h3>{template.title}</h3>
|
||||
</div>
|
||||
<img alt={template.name} src={template.image} />
|
||||
<p className="mms-template-content">{template.content}</p>
|
||||
<div className="mms-template-status">
|
||||
<span>三网审核状态</span>
|
||||
<div>
|
||||
<section>
|
||||
<small>移动:</small>
|
||||
<Tag tone={statusToneMap[template.mobile]}>{statusLabelMap[template.mobile]}</Tag>
|
||||
</section>
|
||||
<section>
|
||||
<small>联通:</small>
|
||||
<Tag tone={statusToneMap[template.unicom]}>{statusLabelMap[template.unicom]}</Tag>
|
||||
</section>
|
||||
<section>
|
||||
<small>电信:</small>
|
||||
<Tag tone={statusToneMap[template.telecom]}>{statusLabelMap[template.telecom]}</Tag>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="mms-template-card__footer">
|
||||
<span>{template.updatedAt}</span>
|
||||
<div>
|
||||
<button onClick={() => setPreviewTemplate(template)} type="button"><Eye size={17} />预览</button>
|
||||
<button onClick={() => setModalState({ mode: 'edit', template })} type="button">编辑</button>
|
||||
<button onClick={() => deleteTemplate(template.id)} type="button"><Trash2 size={16} />删除</button>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mms-pagination">
|
||||
<button disabled type="button"><</button>
|
||||
<button className="active" type="button">1</button>
|
||||
<button type="button">2</button>
|
||||
<button type="button">></button>
|
||||
</div>
|
||||
|
||||
{modalState ? (
|
||||
<MmsTemplateModal
|
||||
mode={modalState.mode}
|
||||
onClose={() => setModalState(null)}
|
||||
template={modalState.template}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
footer={<Button onClick={() => setPreviewTemplate(null)}>关闭</Button>}
|
||||
onClose={() => setPreviewTemplate(null)}
|
||||
open={Boolean(previewTemplate)}
|
||||
size="md"
|
||||
title={<div className="template-modal-title"><h2>彩信预览</h2><p>{previewTemplate?.name}</p></div>}
|
||||
>
|
||||
{previewTemplate ? (
|
||||
<div className="mms-preview">
|
||||
<img alt={previewTemplate.name} src={previewTemplate.image} />
|
||||
<h3>{previewTemplate.title}</h3>
|
||||
<p>{previewTemplate.content}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Eye, FileImage, Search, Smartphone } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
DetailInfoGrid,
|
||||
DetailSection,
|
||||
Input,
|
||||
Modal,
|
||||
QueryPanel,
|
||||
Table,
|
||||
type DateRangeValue,
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type MmsUplinkMessage = {
|
||||
id: string;
|
||||
phone: string;
|
||||
receivedAt: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type MatchedMmsRecord = {
|
||||
id: string;
|
||||
sentAt: string;
|
||||
applicationName: string;
|
||||
title: string;
|
||||
image: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const uplinkMessages: MmsUplinkMessage[] = [
|
||||
{ id: 'MMSMO20260316001', phone: '13500000888', receivedAt: '2026-03-16 10:27:10', content: 'R' },
|
||||
{ id: 'MMSMO20260316002', phone: '13800138000', receivedAt: '2026-03-16 14:20:35', content: 'TD' },
|
||||
{ id: 'MMSMO20260316003', phone: '13900139000', receivedAt: '2026-03-16 09:15:42', content: '查询活动' },
|
||||
{ id: 'MMSMO20260316004', phone: '13700137000', receivedAt: '2026-03-16 10:05:18', content: 'R' },
|
||||
{ id: 'MMSMO20260316005', phone: '13600136000', receivedAt: '2026-03-16 11:30:25', content: '退订' },
|
||||
{ id: 'MMSMO20260316006', phone: '13400134000', receivedAt: '2026-03-16 13:45:10', content: '1' },
|
||||
];
|
||||
|
||||
const matchedMmsRecords: MatchedMmsRecord[] = [
|
||||
{
|
||||
id: 'MMSD20260314001',
|
||||
sentAt: '2026-03-14 12:25:28',
|
||||
applicationName: '营销活动彩信',
|
||||
title: '新春佳节,福气满满',
|
||||
image: 'https://images.unsplash.com/photo-1519671482749-fd09be7ccebf?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【活动推广】尊敬的客户,新春佳节来临之际,优品商城全体员工祝您新春快乐、万事如意!点击查看精美贺卡和新春优惠活动详情。拒收请回复R',
|
||||
},
|
||||
{
|
||||
id: 'MMSD20260315002',
|
||||
sentAt: '2026-03-15 09:18:42',
|
||||
applicationName: '会员服务彩信',
|
||||
title: '会员专属优惠来了',
|
||||
image: 'https://images.unsplash.com/photo-1567427017947-545c5f8d16ad?auto=format&fit=crop&w=900&q=80',
|
||||
content: '【优品商城】尊敬的黄金会员,您享受一波50%的特价惊喜购!本月专享活动仅限开放,精选热门产品5折主打优惠。拒收请回复R',
|
||||
},
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
|
||||
export function ClientMmsUplinkMessagesPage() {
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [selectedMessage, setSelectedMessage] = useState<MmsUplinkMessage | null>(null);
|
||||
|
||||
const filteredMessages = uplinkMessages.filter((item) => {
|
||||
const receivedDate = getDate(item.receivedAt);
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
|
||||
return matchesPhone && matchesContent && matchesStartDate && matchesEndDate;
|
||||
});
|
||||
|
||||
const columns = useMemo<Array<TableColumn<MmsUplinkMessage>>>(() => [
|
||||
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phone}</strong> },
|
||||
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <span className="muted">{record.receivedAt}</span> },
|
||||
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '160px',
|
||||
align: 'center',
|
||||
render: (record) => (
|
||||
<Button icon={<Eye size={15} />} onClick={() => setSelectedMessage(record)} size="sm" variant="ghost">
|
||||
查看详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><FileImage size={22} /></span>
|
||||
<h1>查看上行彩信</h1>
|
||||
</div>
|
||||
|
||||
<QueryPanel title="查询条件" summary={<>共找到 <strong>{filteredMessages.length}</strong> 条上行记录</>}>
|
||||
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="输入手机号搜索" prefix={<Smartphone size={16} />} value={phoneKeyword} />
|
||||
<DateRangeInput label="上行时间" onChange={setDateRange} value={dateRange} />
|
||||
<Input label="上行内容" onChange={(event) => setContentKeyword(event.target.value)} placeholder="输入关键词搜索" prefix={<Search size={16} />} value={contentKeyword} />
|
||||
</QueryPanel>
|
||||
|
||||
<div className="surface uplink-table-card">
|
||||
<Table columns={columns} data={filteredMessages} emptyText="暂无上行彩信记录" rowKey="id" />
|
||||
<div className="mms-uplink-pagination">
|
||||
<span>显示 {filteredMessages.length} 条记录</span>
|
||||
<div>
|
||||
<Button disabled size="sm" variant="secondary">上一页</Button>
|
||||
<Button disabled size="sm" variant="secondary">下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
footer={<Button variant="ghost" onClick={() => setSelectedMessage(null)}>关闭</Button>}
|
||||
onClose={() => setSelectedMessage(null)}
|
||||
open={Boolean(selectedMessage)}
|
||||
size="xl"
|
||||
title="上行彩信详情"
|
||||
>
|
||||
{selectedMessage ? (
|
||||
<div className="uplink-detail mms-uplink-detail">
|
||||
<DetailSection title="上行信息">
|
||||
<DetailInfoGrid
|
||||
items={[
|
||||
{ label: '手机号码', value: selectedMessage.phone },
|
||||
{ label: '上行时间', value: selectedMessage.receivedAt },
|
||||
{ label: '上行内容', value: selectedMessage.content, full: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="匹配发送记录">
|
||||
<p className="uplink-detail-hint">搜索到上行彩信前7天内的下发成功记录</p>
|
||||
<div className="uplink-match-list">
|
||||
{matchedMmsRecords.map((record) => (
|
||||
<article className="uplink-match-card mms-uplink-match-card" key={record.id}>
|
||||
<div className="uplink-match-grid">
|
||||
<div>
|
||||
<span>发送时间</span>
|
||||
<strong>{record.sentAt}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送应用</span>
|
||||
<strong>{record.applicationName}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="uplink-match-content">
|
||||
<span>彩信标题</span>
|
||||
<strong>{record.title}</strong>
|
||||
</div>
|
||||
<div className="mms-uplink-image">
|
||||
<span>彩信图片</span>
|
||||
<img alt={record.title} src={record.image} />
|
||||
</div>
|
||||
<div className="uplink-match-content">
|
||||
<span>下发内容</span>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
<div className="uplink-match-actions">
|
||||
<Button size="sm" variant="ghost">添加到应用黑名单</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { FileText, Search, Smartphone } from 'lucide-react';
|
||||
import { clientApi, type SmsMessageRecord } from '@/api/adminApi';
|
||||
import {
|
||||
DateRangeInput,
|
||||
Input,
|
||||
@@ -9,203 +10,95 @@ import {
|
||||
type DateRangeValue,
|
||||
} from '@/components/ui';
|
||||
|
||||
type SendStatus = 'success' | 'unknown' | 'failed';
|
||||
|
||||
type SmsSendDetail = {
|
||||
id: string;
|
||||
applicationName: string;
|
||||
sentAt: string;
|
||||
content: string;
|
||||
wordCount: number;
|
||||
billingCount: number;
|
||||
phone: string;
|
||||
carrier: '中国移动' | '中国联通' | '中国电信';
|
||||
region: string;
|
||||
status: SendStatus;
|
||||
receipt: 'DELIVRD' | 'UNKNOWN' | 'UNDELIV';
|
||||
receiptAt?: string;
|
||||
};
|
||||
|
||||
const statusLabelMap: Record<SendStatus, string> = {
|
||||
success: '成功',
|
||||
const statusLabelMap: Record<string, string> = {
|
||||
delivered: '成功',
|
||||
queued: '排队中',
|
||||
submitted: '已提交',
|
||||
accepted: '已受理',
|
||||
unknown: '未知',
|
||||
failed: '失败',
|
||||
rejected: '失败',
|
||||
timeout: '超时',
|
||||
};
|
||||
|
||||
const statusToneMap: Record<SendStatus, 'success' | 'info' | 'danger'> = {
|
||||
success: 'success',
|
||||
unknown: 'info',
|
||||
const statusToneMap: Record<string, 'success' | 'info' | 'danger' | 'neutral'> = {
|
||||
delivered: 'success',
|
||||
queued: 'info',
|
||||
submitted: 'info',
|
||||
accepted: 'info',
|
||||
unknown: 'neutral',
|
||||
failed: 'danger',
|
||||
rejected: 'danger',
|
||||
timeout: 'danger',
|
||||
};
|
||||
|
||||
const sendDetailRows: SmsSendDetail[] = [
|
||||
{
|
||||
id: 'SMSD20260316001',
|
||||
applicationName: '营销推广平台',
|
||||
sentAt: '2026-03-16 10:30:15',
|
||||
content: '【启瑞物业】尊敬的业主,您本月物业费500元,请及时缴纳。感谢您的配合!',
|
||||
wordCount: 45,
|
||||
billingCount: 1,
|
||||
phone: '13800138000',
|
||||
carrier: '中国移动',
|
||||
region: '北京市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:30:18',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316002',
|
||||
applicationName: '客服系统',
|
||||
sentAt: '2026-03-16 10:32:20',
|
||||
content: '【客服中心】尊敬的张先生,您已成功预约上门维修服务,时间:2026-03-18 14:00。',
|
||||
wordCount: 48,
|
||||
billingCount: 1,
|
||||
phone: '13900139000',
|
||||
carrier: '中国联通',
|
||||
region: '上海市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:32:25',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316003',
|
||||
applicationName: '验证码服务',
|
||||
sentAt: '2026-03-16 10:35:40',
|
||||
content: '【验证码】您的验证码是123456,5分钟内有效,请勿泄露给他人。',
|
||||
wordCount: 34,
|
||||
billingCount: 1,
|
||||
phone: '13700137000',
|
||||
carrier: '中国电信',
|
||||
region: '深圳市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:35:43',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316004',
|
||||
applicationName: '营销推广平台',
|
||||
sentAt: '2026-03-16 10:38:10',
|
||||
content: '【启瑞物业】您好!春季业主大会将于2026-03-20在小区会议室举行,欢迎参加。',
|
||||
wordCount: 46,
|
||||
billingCount: 1,
|
||||
phone: '13600136000',
|
||||
carrier: '中国移动',
|
||||
region: '广州市',
|
||||
status: 'unknown',
|
||||
receipt: 'UNKNOWN',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316005',
|
||||
applicationName: '验证码服务',
|
||||
sentAt: '2026-03-16 10:40:30',
|
||||
content: '【验证码】您的验证码是654321,5分钟内有效,请勿泄露给他人。',
|
||||
wordCount: 34,
|
||||
billingCount: 1,
|
||||
phone: '13500135000',
|
||||
carrier: '中国联通',
|
||||
region: '杭州市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:40:33',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316006',
|
||||
applicationName: '订单通知系统',
|
||||
sentAt: '2026-03-16 10:45:12',
|
||||
content: '【订单通知】您的订单已发货,请留意物流信息。',
|
||||
wordCount: 28,
|
||||
billingCount: 1,
|
||||
phone: '18800188000',
|
||||
carrier: '中国电信',
|
||||
region: '成都市',
|
||||
status: 'failed',
|
||||
receipt: 'UNDELIV',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316007',
|
||||
applicationName: '营销推广平台',
|
||||
sentAt: '2026-03-16 10:48:01',
|
||||
content: '【启瑞物业】尊敬的客户,值此佳节之际,祝您节日快乐,阖家幸福。',
|
||||
wordCount: 39,
|
||||
billingCount: 1,
|
||||
phone: '15900159000',
|
||||
carrier: '中国移动',
|
||||
region: '武汉市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:48:06',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316008',
|
||||
applicationName: '客服系统',
|
||||
sentAt: '2026-03-16 10:51:36',
|
||||
content: '【客服中心】您的服务工单已受理,工作人员将在24小时内联系您。',
|
||||
wordCount: 36,
|
||||
billingCount: 1,
|
||||
phone: '15000150000',
|
||||
carrier: '中国联通',
|
||||
region: '南京市',
|
||||
status: 'unknown',
|
||||
receipt: 'UNKNOWN',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316009',
|
||||
applicationName: '订单通知系统',
|
||||
sentAt: '2026-03-16 10:55:44',
|
||||
content: '【订单通知】您的退款申请已提交,预计1-3个工作日内到账。',
|
||||
wordCount: 35,
|
||||
billingCount: 1,
|
||||
phone: '18900189000',
|
||||
carrier: '中国电信',
|
||||
region: '西安市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:55:49',
|
||||
},
|
||||
{
|
||||
id: 'SMSD20260316010',
|
||||
applicationName: '验证码服务',
|
||||
sentAt: '2026-03-16 10:59:18',
|
||||
content: '【验证码】您的登录验证码为908172,请在5分钟内完成验证。',
|
||||
wordCount: 33,
|
||||
billingCount: 1,
|
||||
phone: '13200132000',
|
||||
carrier: '中国移动',
|
||||
region: '重庆市',
|
||||
status: 'success',
|
||||
receipt: 'DELIVRD',
|
||||
receiptAt: '2026-03-16 10:59:21',
|
||||
},
|
||||
];
|
||||
const carrierLabelMap: Record<string, string> = {
|
||||
mobile: '中国移动',
|
||||
unicom: '中国联通',
|
||||
telecom: '中国电信',
|
||||
all: '三网',
|
||||
};
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
function getDate(value?: string | null) {
|
||||
return value ? value.slice(0, 10) : '';
|
||||
}
|
||||
|
||||
function getReceipt(record: SmsMessageRecord) {
|
||||
const latest = record.receiptRecords?.[0] as { rawStatus?: string; receiptStatus?: string; deliveredAt?: string } | undefined;
|
||||
return {
|
||||
status: latest?.rawStatus ?? latest?.receiptStatus ?? record.receiptStatus ?? '-',
|
||||
time: latest?.deliveredAt ?? record.deliveredAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function ClientSendDetailPage() {
|
||||
const [applicationName, setApplicationName] = useState('all');
|
||||
const [records, setRecords] = useState<SmsMessageRecord[]>([]);
|
||||
const [applicationId, setApplicationId] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
clientApi.listMessages({
|
||||
applicationId: applicationId === 'all' ? undefined : applicationId,
|
||||
phoneNumber: phoneKeyword || undefined,
|
||||
status: status === 'all' ? undefined : status,
|
||||
})
|
||||
.then((items) => {
|
||||
setRecords(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '短信发送详情加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [applicationId, phoneKeyword, status]);
|
||||
|
||||
const applicationOptions = useMemo(() => {
|
||||
const applications = Array.from(new Set(sendDetailRows.map((item) => item.applicationName)));
|
||||
const applications = new Map<string, string>();
|
||||
records.forEach((item) => {
|
||||
if (item.applicationId) {
|
||||
applications.set(item.applicationId, item.application?.name ?? item.applicationId);
|
||||
}
|
||||
});
|
||||
return [
|
||||
{ label: '全部应用', value: 'all' },
|
||||
...applications.map((item) => ({ label: item, value: item })),
|
||||
...Array.from(applications.entries()).map(([value, label]) => ({ label, value })),
|
||||
];
|
||||
}, []);
|
||||
}, [records]);
|
||||
|
||||
const filteredRows = sendDetailRows.filter((item) => {
|
||||
const matchesApplication = applicationName === 'all' || item.applicationName === applicationName;
|
||||
const matchesStatus = status === 'all' || item.status === status;
|
||||
const sentDate = getDate(item.sentAt);
|
||||
const filteredRows = records.filter((item) => {
|
||||
const sentDate = getDate(item.queuedAt);
|
||||
const matchesStartDate = !dateRange.start || sentDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || sentDate <= dateRange.end;
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
return matchesApplication && matchesStatus && matchesStartDate && matchesEndDate && matchesContent && matchesPhone;
|
||||
return matchesStartDate && matchesEndDate && matchesContent;
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -221,14 +114,14 @@ export function ClientSendDetailPage() {
|
||||
title="查询条件"
|
||||
summary={<>共找到 <strong>{filteredRows.length}</strong> 条发送记录</>}
|
||||
>
|
||||
<Select label="应用名称" onChange={(event) => setApplicationName(event.target.value)} options={applicationOptions} value={applicationName} />
|
||||
<Select label="应用名称" onChange={(event) => setApplicationId(event.target.value)} options={applicationOptions} value={applicationId} />
|
||||
<DateRangeInput label="发送时间" onChange={setDateRange} value={dateRange} />
|
||||
<Select
|
||||
label="发送状态"
|
||||
onChange={(event) => setStatus(event.target.value)}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '成功', value: 'success' },
|
||||
{ label: '成功', value: 'delivered' },
|
||||
{ label: '未知', value: 'unknown' },
|
||||
{ label: '失败', value: 'failed' },
|
||||
]}
|
||||
@@ -250,72 +143,74 @@ export function ClientSendDetailPage() {
|
||||
/>
|
||||
</QueryPanel>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface send-detail-table-card">
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table send-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '112px' }}>应用名称</th>
|
||||
<th style={{ width: '150px' }}>应用名称</th>
|
||||
<th style={{ width: '128px' }}>发送时间</th>
|
||||
<th style={{ width: '90px', textAlign: 'center' }}>字符数/条数</th>
|
||||
<th style={{ width: '120px', textAlign: 'center' }}>字符数/条数</th>
|
||||
<th style={{ width: '130px' }}>手机号码</th>
|
||||
<th style={{ width: '90px' }}>所属运营商</th>
|
||||
<th style={{ width: '90px' }}>号码归属地</th>
|
||||
<th style={{ width: '96px', textAlign: 'center' }}>发送状态</th>
|
||||
<th style={{ width: '95px', textAlign: 'center' }}>短信回执</th>
|
||||
<th style={{ width: '120px' }}>所属运营商</th>
|
||||
<th style={{ width: '120px' }}>发送地区</th>
|
||||
<th style={{ width: '120px', textAlign: 'center' }}>发送状态</th>
|
||||
<th style={{ width: '120px', textAlign: 'center' }}>短信回执</th>
|
||||
<th style={{ width: '128px' }}>回执时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="ui-table__empty" colSpan={9}>暂无发送记录</td>
|
||||
</tr>
|
||||
) : filteredRows.map((record) => (
|
||||
<Fragment key={record.id}>
|
||||
<tr className="send-detail-main-row">
|
||||
<td><strong className="send-detail-app-name">{record.applicationName}</strong></td>
|
||||
<td>
|
||||
<span className="send-detail-time">
|
||||
{record.sentAt.slice(0, 10)}
|
||||
<small>{record.sentAt.slice(11)}</small>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span className="send-detail-count">
|
||||
<strong>{record.wordCount}字</strong>
|
||||
<small>{record.billingCount}条</small>
|
||||
</span>
|
||||
</td>
|
||||
<td><strong>{record.phone}</strong></td>
|
||||
<td><strong className="send-detail-carrier">{record.carrier}</strong></td>
|
||||
<td>
|
||||
<span className="send-detail-region">
|
||||
{record.region.slice(0, 2)}
|
||||
<small>{record.region.slice(2)}</small>
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status]}>{statusLabelMap[record.status]}</Tag></td>
|
||||
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{record.receipt}</strong></td>
|
||||
<td>
|
||||
{record.receiptAt ? (
|
||||
{loading ? (
|
||||
<tr><td className="ui-table__empty" colSpan={9}>正在加载真实发送记录...</td></tr>
|
||||
) : filteredRows.length === 0 ? (
|
||||
<tr><td className="ui-table__empty" colSpan={9}>暂无发送记录</td></tr>
|
||||
) : filteredRows.map((record) => {
|
||||
const receipt = getReceipt(record);
|
||||
const carrier = record.channel?.carrier ? carrierLabelMap[record.channel.carrier] ?? record.channel.carrier : '-';
|
||||
const region = record.channel?.sendRegion ?? '-';
|
||||
return (
|
||||
<Fragment key={record.id}>
|
||||
<tr className="send-detail-main-row">
|
||||
<td><strong className="send-detail-app-name">{record.application?.name ?? record.applicationId ?? '-'}</strong></td>
|
||||
<td>
|
||||
<span className="send-detail-time">
|
||||
{record.receiptAt.slice(0, 10)}
|
||||
<small>{record.receiptAt.slice(11)}</small>
|
||||
{record.queuedAt.slice(0, 10)}
|
||||
<small>{record.queuedAt.slice(11, 19)}</small>
|
||||
</span>
|
||||
) : <span className="muted">-</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="send-detail-content-row">
|
||||
<td colSpan={9}>
|
||||
<div className="send-detail-content-block">
|
||||
<span>短信内容</span>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
))}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span className="send-detail-count">
|
||||
<strong>{[...record.content].length}字</strong>
|
||||
<small>{record.billingUnits}条</small>
|
||||
</span>
|
||||
</td>
|
||||
<td><strong>{record.phoneNumber}</strong></td>
|
||||
<td><strong className="send-detail-carrier">{carrier}</strong></td>
|
||||
<td><span className="send-detail-region">{region}</span></td>
|
||||
<td style={{ textAlign: 'center' }}><Tag tone={statusToneMap[record.status] ?? 'info'}>{statusLabelMap[record.status] ?? record.status}</Tag></td>
|
||||
<td style={{ textAlign: 'center' }}><strong className="send-detail-receipt-code">{receipt.status}</strong></td>
|
||||
<td>
|
||||
{receipt.time ? (
|
||||
<span className="send-detail-time">
|
||||
{receipt.time.slice(0, 10)}
|
||||
<small>{receipt.time.slice(11, 19)}</small>
|
||||
</span>
|
||||
) : <span className="muted">-</span>}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="send-detail-content-row">
|
||||
<td colSpan={9}>
|
||||
<div className="send-detail-content-block">
|
||||
<span>短信内容</span>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, FileText, Plus, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { Button, DateTimeInput, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type SmsBatchTask } from '@/api/adminApi';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type ImportPreviewResponse, type SmsBatchTask } from '@/api/adminApi';
|
||||
|
||||
type Recipient = {
|
||||
id: string;
|
||||
@@ -27,6 +27,10 @@ export function ClientSendPage() {
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [receiverMode, setReceiverMode] = useState<ReceiverMode>('manual');
|
||||
const [recipients, setRecipients] = useState<Recipient[]>([{ id: '1', phone: '' }]);
|
||||
const [importContent, setImportContent] = useState('');
|
||||
const [importFileName, setImportFileName] = useState('');
|
||||
const [importPreview, setImportPreview] = useState<ImportPreviewResponse | null>(null);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [submittedRecord, setSubmittedRecord] = useState<SmsBatchTask | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,13 +56,16 @@ export function ClientSendPage() {
|
||||
item.name.includes(templateKeyword) || item.content.includes(templateKeyword)
|
||||
));
|
||||
const validRecipients = recipients.filter((item) => item.phone.trim());
|
||||
const importedValidCount = importPreview?.validCount ?? 0;
|
||||
const receiverCount = receiverMode === 'manual' ? validRecipients.length : importedValidCount;
|
||||
const previewText = selectedSignature && messageContent
|
||||
? `【${selectedSignature.name}】${messageContent}`
|
||||
: messageContent;
|
||||
const wordCount = previewText.length;
|
||||
const smsParts = wordCount > 0 ? Math.max(1, Math.ceil(wordCount / 70)) : 0;
|
||||
const estimatedCount = validRecipients.length * smsParts;
|
||||
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && validRecipients.length > 0 && (sendMode === 'now' || scheduledAt));
|
||||
const estimatedCount = receiverCount * smsParts;
|
||||
const requiredVariables = selectedTemplate?.variables?.map((item) => item.name) ?? [];
|
||||
const canSubmit = Boolean(taskName && applicationId && signatureId && templateId && receiverCount > 0 && (sendMode === 'now' || scheduledAt));
|
||||
|
||||
function updateRecipient(id: string, phone: string) {
|
||||
setRecipients((items) => items.map((item) => (item.id === id ? { ...item, phone } : item)));
|
||||
@@ -84,15 +91,28 @@ export function ClientSendPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
clientApi.createBatchTask({
|
||||
applicationId,
|
||||
templateId,
|
||||
content: previewText,
|
||||
category: selectedTemplate?.category ?? taskName,
|
||||
phones: validRecipients.map((item) => item.phone.trim()),
|
||||
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
|
||||
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
|
||||
})
|
||||
const submitRequest = receiverMode === 'manual'
|
||||
? clientApi.createBatchTask({
|
||||
applicationId,
|
||||
templateId,
|
||||
content: previewText,
|
||||
category: selectedTemplate?.category ?? taskName,
|
||||
phones: validRecipients.map((item) => item.phone.trim()),
|
||||
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
|
||||
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
|
||||
})
|
||||
: clientApi.confirmImport({
|
||||
applicationId,
|
||||
templateId,
|
||||
content: previewText,
|
||||
category: selectedTemplate?.category ?? taskName,
|
||||
importContent,
|
||||
requiredVariables,
|
||||
sendMode: sendMode === 'now' ? 'immediate' : 'scheduled',
|
||||
scheduledAt: sendMode === 'scheduled' ? scheduledAt : undefined,
|
||||
});
|
||||
|
||||
submitRequest
|
||||
.then((task) => {
|
||||
setSubmittedRecord(task);
|
||||
setError('');
|
||||
@@ -100,6 +120,30 @@ export function ClientSendPage() {
|
||||
.catch((reason: Error) => setError(reason.message || '发送任务提交失败'));
|
||||
}
|
||||
|
||||
async function previewImportFile(file: File) {
|
||||
setImportLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const content = await file.text();
|
||||
const preview = await clientApi.previewImport({
|
||||
content,
|
||||
fileName: file.name,
|
||||
delimiter: file.name.endsWith('.tsv') ? '\t' : ',',
|
||||
requiredVariables,
|
||||
});
|
||||
setImportContent(content);
|
||||
setImportFileName(file.name);
|
||||
setImportPreview(preview);
|
||||
} catch (reason) {
|
||||
setImportContent('');
|
||||
setImportFileName('');
|
||||
setImportPreview(null);
|
||||
setError(reason instanceof Error ? reason.message : '导入预览失败');
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="sms-send-page">
|
||||
<div className="sms-send-title">
|
||||
@@ -233,12 +277,42 @@ export function ClientSendPage() {
|
||||
<FileText size={26} />
|
||||
</div>
|
||||
<strong>导入表格</strong>
|
||||
<span>支持 .xlsx / .csv 文件,当前原型仅展示上传入口。</span>
|
||||
<Button variant="ghost">选择文件</Button>
|
||||
<span>支持 CSV / TSV / TXT 文本文件。第一列为手机号,后续列名可对应模板变量。</span>
|
||||
<input
|
||||
accept=".csv,.tsv,.txt,text/csv,text/plain"
|
||||
id="sms-import-file"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
void previewImportFile(file);
|
||||
}
|
||||
event.currentTarget.value = '';
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
/>
|
||||
<Button disabled={importLoading || !templateId} onClick={() => document.getElementById('sms-import-file')?.click()} variant="ghost">
|
||||
{importLoading ? '解析中...' : '选择文件'}
|
||||
</Button>
|
||||
{importFileName ? <span>文件:{importFileName}</span> : null}
|
||||
{importPreview ? (
|
||||
<div className="detail-grid">
|
||||
<div><span>总行数</span><strong>{importPreview.totalRows}</strong></div>
|
||||
<div><span>有效号码</span><strong>{importPreview.validCount}</strong></div>
|
||||
<div><span>错误行</span><strong>{importPreview.errorCount}</strong></div>
|
||||
<div><span>变量校验</span><strong>{requiredVariables.length ? requiredVariables.join(', ') : '无必填变量'}</strong></div>
|
||||
{importPreview.errors.length ? (
|
||||
<div className="detail-grid__wide">
|
||||
<span>错误明细</span>
|
||||
<strong>{importPreview.errors.slice(0, 5).map((item) => `第${item.rowNumber}行${item.phoneNumber ? ` ${item.phoneNumber}` : ''}:${item.reason}`).join(';')}</strong>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="send-tip">已添加 {validRecipients.length} 个接收号码</p>
|
||||
<p className="send-tip">已添加 {receiverCount} 个接收号码</p>
|
||||
</section>
|
||||
|
||||
<div className="send-submit-row">
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Save } from 'lucide-react';
|
||||
import { Button, Input } from '@/components/ui';
|
||||
|
||||
export function ClientSettingsPage() {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">账户</p>
|
||||
<h1>账号设置</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface content-grid">
|
||||
<div className="form-grid">
|
||||
<Input label="企业名称" defaultValue="上海云舟科技有限公司" />
|
||||
<div className="form-grid form-grid--two">
|
||||
<Input label="联系人" defaultValue="赵先生" />
|
||||
<Input label="联系电话" defaultValue="13800000000" />
|
||||
</div>
|
||||
<Button icon={<Save size={16} />}>保存设置</Button>
|
||||
</div>
|
||||
<aside className="soft-panel">
|
||||
<h3>安全提示</h3>
|
||||
<p className="muted">当前为纯前端原型,账号设置仅用于展示交互形态,不会提交到后端。</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -54,12 +54,9 @@ export function ClientSignaturesPage() {
|
||||
try {
|
||||
const signature = await clientApi.createSignature({ applicationId: applicationId || undefined, name, purpose });
|
||||
if (file) {
|
||||
const fileObject = await clientApi.createFileObject({
|
||||
objectKey: `signature-materials/${signature.id}/${Date.now()}-${file.name}`,
|
||||
fileName: file.name,
|
||||
contentType: file.type || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
const fileObject = await clientApi.uploadFileObject(file, {
|
||||
purpose: 'signature_material',
|
||||
prefix: `signature-materials/${signature.id}`,
|
||||
});
|
||||
await clientApi.createSignatureMaterial(signature.id, {
|
||||
fileObjectId: fileObject.id,
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ClientSystemLogsPage() {
|
||||
|
||||
const columns = useMemo<Array<TableColumn<OperationLogItem>>>(() => [
|
||||
{ key: 'time', title: '时间', width: '190px', render: (record) => <span className="muted">{new Date(record.time).toLocaleString('zh-CN')}</span> },
|
||||
{ key: 'level', title: '级别', width: '110px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
||||
{ key: 'level', title: '级别', width: '120px', render: (record) => <Tag tone={levelToneMap[record.level]}>{levelLabelMap[record.level]}</Tag> },
|
||||
{ key: 'module', title: '模块', width: '150px', render: (record) => <strong>{record.module}</strong> },
|
||||
{ key: 'operator', title: '操作人', width: '130px', render: (record) => <strong>{record.operator}</strong> },
|
||||
{ key: 'action', title: '操作', width: '160px', render: (record) => <strong>{record.action}</strong> },
|
||||
|
||||
@@ -1,7 +1,22 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Modal, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
|
||||
type TemplateVariable = {
|
||||
name: string;
|
||||
example?: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
type TemplateFormState = {
|
||||
applicationId: string;
|
||||
signatureId: string;
|
||||
name: string;
|
||||
category: string;
|
||||
content: string;
|
||||
variables: TemplateVariable[];
|
||||
};
|
||||
|
||||
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
|
||||
approved: 'success',
|
||||
@@ -18,27 +33,161 @@ const statusLabel: Record<string, string> = {
|
||||
disabled: '已禁用',
|
||||
};
|
||||
|
||||
function extractVariables(content: string) {
|
||||
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])));
|
||||
const recommendedVariables = [
|
||||
['验证码', 'code'],
|
||||
['手机号', 'phone'],
|
||||
['姓名', 'name'],
|
||||
['日期', 'date'],
|
||||
['金额', 'amount'],
|
||||
['时间', 'time'],
|
||||
['余额', 'balance'],
|
||||
['地址', 'address'],
|
||||
['快递单号', 'trackingNumber'],
|
||||
['链接', 'link'],
|
||||
];
|
||||
|
||||
function extractVariables(content: string): TemplateVariable[] {
|
||||
return Array.from(new Set(Array.from(content.matchAll(/\$\{([^}]+)\}/g)).map((match) => match[1])))
|
||||
.map((name) => ({ name, required: true }));
|
||||
}
|
||||
|
||||
function billingUnits(content: string) {
|
||||
if (!content) return 1;
|
||||
return content.length <= 70 ? 1 : Math.ceil(content.length / 67);
|
||||
}
|
||||
|
||||
function TemplateModal({
|
||||
applications,
|
||||
item,
|
||||
onClose,
|
||||
onSubmit,
|
||||
signatures,
|
||||
}: {
|
||||
applications: ClientSmsApplication[];
|
||||
item?: ClientSmsTemplate;
|
||||
onClose: () => void;
|
||||
onSubmit: (state: TemplateFormState) => void;
|
||||
signatures: ClientSmsSignature[];
|
||||
}) {
|
||||
const [customVariable, setCustomVariable] = useState('');
|
||||
const [variablesOpen, setVariablesOpen] = useState(false);
|
||||
const [form, setForm] = useState<TemplateFormState>({
|
||||
applicationId: item?.applicationId ?? '',
|
||||
signatureId: item?.signatureId ?? '',
|
||||
name: item?.name ?? '',
|
||||
category: item?.category ?? '行业通知',
|
||||
content: item?.content ?? '',
|
||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||
});
|
||||
const application = applications.find((candidate) => candidate.id === form.applicationId);
|
||||
const availableSignatures = signatures.filter((signature) => (
|
||||
signature.auditStatus === 'approved'
|
||||
&& (!application || signature.tenantId === application.tenantId)
|
||||
&& (!signature.applicationId || signature.applicationId === form.applicationId)
|
||||
));
|
||||
const variables = form.variables.length ? form.variables : extractVariables(form.content);
|
||||
|
||||
function update<Key extends keyof TemplateFormState>(key: Key, value: TemplateFormState[Key]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function setContent(content: string) {
|
||||
setForm((current) => ({ ...current, content, variables: extractVariables(content) }));
|
||||
}
|
||||
|
||||
function insertVariable(name: string) {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) return;
|
||||
setContent(`${form.content}\${${normalized}}`);
|
||||
}
|
||||
|
||||
function updateVariableExample(name: string, example: string) {
|
||||
update('variables', variables.map((variable) => variable.name === name ? { ...variable, example } : variable));
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.applicationId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}>提交审核</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={onClose}
|
||||
open
|
||||
size="xl"
|
||||
title={item ? '编辑短信模板' : '添加短信模板'}
|
||||
>
|
||||
<div className="template-form">
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => update('applicationId', event.target.value)}
|
||||
options={[{ label: '请选择应用', value: '' }, ...applications.map((app) => ({ label: app.name, value: app.id }))]}
|
||||
value={form.applicationId}
|
||||
/>
|
||||
<Select
|
||||
label="短信签名"
|
||||
onChange={(event) => update('signatureId', event.target.value)}
|
||||
options={[{ label: '不绑定签名', value: '' }, ...availableSignatures.map((signature) => ({ label: signature.name, value: signature.id }))]}
|
||||
value={form.signatureId}
|
||||
/>
|
||||
<Input label="模板名称" onChange={(event) => update('name', event.target.value)} placeholder="请输入模板名称" value={form.name} />
|
||||
<Input label="模板分类" onChange={(event) => update('category', event.target.value)} placeholder="行业通知/营销推广/验证码" value={form.category} />
|
||||
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" rows={6} value={form.content} />
|
||||
<div className="template-form-meta">
|
||||
<button onClick={() => setVariablesOpen((current) => !current)} type="button">
|
||||
<Plus size={16} /> {variablesOpen ? '收起变量面板' : '插入变量'}
|
||||
</button>
|
||||
<span>{form.content.length} 字符,计费 {billingUnits(form.content)} 条</span>
|
||||
</div>
|
||||
{variablesOpen ? (
|
||||
<div className="template-variable-panel">
|
||||
<h3>推荐变量</h3>
|
||||
<div className="template-variable-buttons">
|
||||
{recommendedVariables.map(([label, value]) => (
|
||||
<button key={value} onClick={() => insertVariable(value)} type="button">{label} ({value})</button>
|
||||
))}
|
||||
</div>
|
||||
<h3>自定义变量</h3>
|
||||
<div className="template-custom-variable">
|
||||
<Input onChange={(event) => setCustomVariable(event.target.value)} placeholder="英文字符或数字" value={customVariable} />
|
||||
<Button onClick={() => { insertVariable(customVariable); setCustomVariable(''); }}>插入</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="template-variable-panel">
|
||||
<h3>变量示例</h3>
|
||||
{variables.length ? variables.map((variable) => (
|
||||
<Input
|
||||
key={variable.name}
|
||||
label={`\${${variable.name}}`}
|
||||
onChange={(event) => updateVariableExample(variable.name, event.target.value)}
|
||||
placeholder="请输入变量示例值"
|
||||
value={variable.example ?? ''}
|
||||
/>
|
||||
)) : <p className="muted">模板内容中暂无变量。</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientTemplatesPage() {
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
|
||||
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [applicationId, setApplicationId] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [modalTemplate, setModalTemplate] = useState<ClientSmsTemplate | 'new' | null>(null);
|
||||
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
Promise.all([clientApi.listApplications(), clientApi.listTemplates()])
|
||||
.then(([applicationItems, templateItems]) => {
|
||||
Promise.all([clientApi.listApplications(), clientApi.listTemplates(), clientApi.listSignatures()])
|
||||
.then(([applicationItems, templateItems, signatureItems]) => {
|
||||
setApplications(applicationItems.filter((item) => item.status === 'active'));
|
||||
setTemplates(templateItems.filter((item) => item.auditStatus !== 'deleted' && item.auditStatus !== 'disabled'));
|
||||
setSignatures(signatureItems);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '短信模板加载失败'))
|
||||
@@ -50,21 +199,29 @@ export function ClientTemplatesPage() {
|
||||
}, []);
|
||||
|
||||
const filteredTemplates = useMemo(() => templates.filter((item) => (
|
||||
!keyword || [item.name, item.content, item.application?.name].join(' ').includes(keyword)
|
||||
!keyword || [item.name, item.content, item.application?.name, item.signature?.name].join(' ').includes(keyword)
|
||||
)), [keyword, templates]);
|
||||
|
||||
function createTemplate() {
|
||||
const variables = extractVariables(content).map((variable) => ({ name: variable, required: true }));
|
||||
clientApi.createTemplate({ applicationId, name, content, variables })
|
||||
.then((created) => clientApi.submitTemplate(created.id))
|
||||
.then(() => {
|
||||
setModalOpen(false);
|
||||
setApplicationId('');
|
||||
setName('');
|
||||
setContent('');
|
||||
loadData();
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '模板提交失败'));
|
||||
async function saveTemplate(state: TemplateFormState) {
|
||||
const existing = modalTemplate && modalTemplate !== 'new' ? modalTemplate : null;
|
||||
try {
|
||||
const payload = {
|
||||
applicationId: state.applicationId,
|
||||
signatureId: state.signatureId || undefined,
|
||||
name: state.name,
|
||||
content: state.content,
|
||||
category: state.category,
|
||||
variables: state.variables,
|
||||
};
|
||||
const template = existing
|
||||
? await clientApi.updateTemplate(existing.id, { ...payload, signatureId: state.signatureId || null })
|
||||
: await clientApi.createTemplate(payload);
|
||||
await clientApi.submitTemplate(template.id);
|
||||
setModalTemplate(null);
|
||||
loadData();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '模板提交失败');
|
||||
}
|
||||
}
|
||||
|
||||
function disableTemplate(id: string) {
|
||||
@@ -87,22 +244,22 @@ export function ClientTemplatesPage() {
|
||||
<div className="template-toolbar">
|
||||
<Input
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索模板名称、应用或内容"
|
||||
placeholder="搜索模板名称、应用、签名或内容"
|
||||
prefix={<Search size={17} />}
|
||||
value={keyword}
|
||||
/>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalOpen(true)}>添加短信模板</Button>
|
||||
<Button icon={<Plus size={17} />} onClick={() => setModalTemplate('new')}>添加短信模板</Button>
|
||||
</div>
|
||||
{loading ? <p className="muted">正在加载短信模板...</p> : null}
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="template-card-grid">
|
||||
{filteredTemplates.map((template) => {
|
||||
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content);
|
||||
const variables = template.variables?.map((item) => item.name) ?? extractVariables(template.content).map((item) => item.name);
|
||||
return (
|
||||
<article className="template-card template-card--green" key={template.id}>
|
||||
<h2>{template.name}</h2>
|
||||
<p className="muted">{template.application?.name ?? template.applicationId}</p>
|
||||
<p className="muted">{template.application?.name ?? template.applicationId} / {template.signature?.name ?? '未绑定签名'}</p>
|
||||
<Tag tone={statusTone[template.auditStatus] ?? 'info'}>{statusLabel[template.auditStatus] ?? template.auditStatus}</Tag>
|
||||
<p className="template-content">{template.content}</p>
|
||||
<div className="template-vars">
|
||||
@@ -110,8 +267,12 @@ export function ClientTemplatesPage() {
|
||||
{variables.length > 0 ? variables.map((item) => <strong key={item}>${`{${item}}`}</strong>) : <span className="muted">无变量</span>}
|
||||
</div>
|
||||
<div className="template-card-footer">
|
||||
<span>{template.updatedAt}</span>
|
||||
<span>{new Date(template.updatedAt).toLocaleString('zh-CN')}</span>
|
||||
<div>
|
||||
<button onClick={() => setModalTemplate(template)} type="button">
|
||||
<Edit3 size={14} />
|
||||
编辑
|
||||
</button>
|
||||
<button onClick={() => disableTemplate(template.id)} type="button">
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
@@ -124,29 +285,15 @@ export function ClientTemplatesPage() {
|
||||
</div>
|
||||
{!loading && !error && filteredTemplates.length === 0 ? <p className="muted">暂无短信模板。</p> : null}
|
||||
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setModalOpen(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={!applicationId || !name || !content} onClick={createTemplate}>提交审核</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setModalOpen(false)}
|
||||
open={modalOpen}
|
||||
size="xl"
|
||||
title="添加短信模板"
|
||||
>
|
||||
<div className="template-form">
|
||||
<Select
|
||||
label="短信应用"
|
||||
onChange={(event) => setApplicationId(event.target.value)}
|
||||
options={[{ label: '请选择应用', value: '' }, ...applications.map((item) => ({ label: item.name, value: item.id }))]}
|
||||
value={applicationId}
|
||||
/>
|
||||
<Input label="模板名称" onChange={(event) => setName(event.target.value)} placeholder="请输入模板名称" value={name} />
|
||||
<Textarea label="模板内容" onChange={(event) => setContent(event.target.value)} placeholder="变量格式:${code}" rows={5} value={content} />
|
||||
</div>
|
||||
</Modal>
|
||||
{modalTemplate ? (
|
||||
<TemplateModal
|
||||
applications={applications}
|
||||
item={modalTemplate === 'new' ? undefined : modalTemplate}
|
||||
onClose={() => setModalTemplate(null)}
|
||||
onSubmit={(state) => { void saveTemplate(state); }}
|
||||
signatures={signatures}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Eye, MessageSquareReply, Search, Smartphone } from 'lucide-react';
|
||||
import { clientApi, type SmsMessageRecord, type SmsUplinkMessage } from '@/api/adminApi';
|
||||
import {
|
||||
Button,
|
||||
DateRangeInput,
|
||||
@@ -13,68 +14,69 @@ import {
|
||||
type TableColumn,
|
||||
} from '@/components/ui';
|
||||
|
||||
type UplinkMessage = {
|
||||
id: string;
|
||||
phone: string;
|
||||
receivedAt: string;
|
||||
content: string;
|
||||
};
|
||||
function getDate(value?: string | null) {
|
||||
return value ? value.slice(0, 10) : '';
|
||||
}
|
||||
|
||||
type MatchedSendRecord = {
|
||||
id: string;
|
||||
sentAt: string;
|
||||
applicationName: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const uplinkMessages: UplinkMessage[] = [
|
||||
{ id: 'MO20260112001', phone: '13500000888', receivedAt: '2026-01-12 10:27:10', content: 'R' },
|
||||
{ id: 'MO20260315001', phone: '13800138000', receivedAt: '2026-03-15 14:20:35', content: 'TD' },
|
||||
{ id: 'MO20260316001', phone: '13900139000', receivedAt: '2026-03-16 09:15:42', content: '查询余额' },
|
||||
{ id: 'MO20260316002', phone: '13700137000', receivedAt: '2026-03-16 10:05:18', content: 'R' },
|
||||
{ id: 'MO20260316003', phone: '13600136000', receivedAt: '2026-03-16 11:30:25', content: '退订' },
|
||||
{ id: 'MO20260316004', phone: '13400134000', receivedAt: '2026-03-16 13:45:10', content: '1' },
|
||||
{ id: 'MO20260316005', phone: '13300133000', receivedAt: '2026-03-16 14:20:55', content: '取消预约' },
|
||||
{ id: 'MO20260316006', phone: '13200132000', receivedAt: '2026-03-16 15:10:30', content: 'R' },
|
||||
];
|
||||
|
||||
const matchedSendRecords: MatchedSendRecord[] = [
|
||||
{
|
||||
id: 'MT20260119001',
|
||||
sentAt: '2026-01-19 12:25:28',
|
||||
applicationName: 'XXX催收',
|
||||
content: '【XXX科技】如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 拒收请回复R',
|
||||
},
|
||||
{
|
||||
id: 'MT20260119002',
|
||||
sentAt: '2026-01-19 12:25:28',
|
||||
applicationName: 'XXX催收',
|
||||
content: '【XXX科技】如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行 如果内容很长,换行',
|
||||
},
|
||||
];
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
function getTime(value?: string | null) {
|
||||
return value ? `${value.slice(0, 10)} ${value.slice(11, 19)}` : '-';
|
||||
}
|
||||
|
||||
export function ClientUplinkMessagesPage() {
|
||||
const [messages, setMessages] = useState<SmsUplinkMessage[]>([]);
|
||||
const [matchedRecords, setMatchedRecords] = useState<SmsMessageRecord[]>([]);
|
||||
const [phoneKeyword, setPhoneKeyword] = useState('');
|
||||
const [contentKeyword, setContentKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [selectedMessage, setSelectedMessage] = useState<UplinkMessage | null>(null);
|
||||
const [selectedMessage, setSelectedMessage] = useState<SmsUplinkMessage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [matching, setMatching] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [detailError, setDetailError] = useState('');
|
||||
|
||||
const filteredMessages = uplinkMessages.filter((item) => {
|
||||
function loadData() {
|
||||
setLoading(true);
|
||||
clientApi.listUplinkMessages()
|
||||
.then((items) => {
|
||||
setMessages(items);
|
||||
setError('');
|
||||
})
|
||||
.catch((reason: Error) => setError(reason.message || '上行短信加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
function openDetail(message: SmsUplinkMessage) {
|
||||
setSelectedMessage(message);
|
||||
setMatchedRecords([]);
|
||||
setDetailError('');
|
||||
|
||||
if (!message.messageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMatching(true);
|
||||
clientApi.listMessages({ messageId: message.messageId })
|
||||
.then((items) => setMatchedRecords(items))
|
||||
.catch((reason: Error) => setDetailError(reason.message || '匹配发送记录加载失败'))
|
||||
.finally(() => setMatching(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredMessages = messages.filter((item) => {
|
||||
const receivedDate = getDate(item.receivedAt);
|
||||
const matchesPhone = !phoneKeyword || item.phone.includes(phoneKeyword);
|
||||
const matchesPhone = !phoneKeyword || item.phoneNumber.includes(phoneKeyword);
|
||||
const matchesContent = !contentKeyword || item.content.includes(contentKeyword);
|
||||
const matchesStartDate = !dateRange.start || receivedDate >= dateRange.start;
|
||||
const matchesEndDate = !dateRange.end || receivedDate <= dateRange.end;
|
||||
return matchesPhone && matchesContent && matchesStartDate && matchesEndDate;
|
||||
});
|
||||
|
||||
const columns = useMemo<Array<TableColumn<UplinkMessage>>>(() => [
|
||||
{ key: 'phone', title: '手机号码', width: '180px', render: (record) => <strong>{record.phone}</strong> },
|
||||
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <span className="muted">{record.receivedAt}</span> },
|
||||
const columns = useMemo<Array<TableColumn<SmsUplinkMessage>>>(() => [
|
||||
{ key: 'phoneNumber', title: '手机号码', width: '180px', render: (record) => <strong>{record.phoneNumber}</strong> },
|
||||
{ key: 'receivedAt', title: '上行时间', width: '220px', render: (record) => <span className="muted">{getTime(record.receivedAt)}</span> },
|
||||
{ key: 'content', title: '上行内容', render: (record) => <span className="uplink-content">{record.content}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
@@ -82,7 +84,7 @@ export function ClientUplinkMessagesPage() {
|
||||
width: '160px',
|
||||
align: 'center',
|
||||
render: (record) => (
|
||||
<Button icon={<Eye size={15} />} onClick={() => setSelectedMessage(record)} size="sm" variant="ghost">
|
||||
<Button icon={<Eye size={15} />} onClick={() => openDetail(record)} size="sm" variant="ghost">
|
||||
查看详情
|
||||
</Button>
|
||||
),
|
||||
@@ -116,8 +118,10 @@ export function ClientUplinkMessagesPage() {
|
||||
/>
|
||||
</QueryPanel>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
|
||||
<div className="surface uplink-table-card">
|
||||
<Table columns={columns} data={filteredMessages} emptyText="暂无上行记录" rowKey="id" />
|
||||
<Table columns={columns} data={loading ? [] : filteredMessages} emptyText={loading ? '正在加载真实上行记录...' : '暂无上行记录'} rowKey="id" />
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
@@ -132,38 +136,44 @@ export function ClientUplinkMessagesPage() {
|
||||
<DetailSection title="上行信息">
|
||||
<DetailInfoGrid
|
||||
items={[
|
||||
{ label: '手机号码', value: selectedMessage.phone },
|
||||
{ label: '上行时间', value: selectedMessage.receivedAt },
|
||||
{ label: '手机号码', value: selectedMessage.phoneNumber },
|
||||
{ label: '上行时间', value: getTime(selectedMessage.receivedAt) },
|
||||
{ label: '接入号码', value: selectedMessage.destId },
|
||||
{ label: '网关消息ID', value: selectedMessage.messageId || '-' },
|
||||
{ label: '上行内容', value: selectedMessage.content, full: true },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="匹配发送记录">
|
||||
<p className="uplink-detail-hint">搜索到上行短信前7天内的下发成功记录</p>
|
||||
<div className="uplink-match-list">
|
||||
{matchedSendRecords.map((record) => (
|
||||
<article className="uplink-match-card" key={record.id}>
|
||||
<div className="uplink-match-grid">
|
||||
<div>
|
||||
<span>发送时间</span>
|
||||
<strong>{record.sentAt}</strong>
|
||||
{matching ? <p className="uplink-detail-hint">正在查询真实下发记录...</p> : null}
|
||||
{detailError ? <p className="form-error">{detailError}</p> : null}
|
||||
{!matching && !selectedMessage.messageId ? <p className="uplink-detail-hint">该上行记录没有网关消息ID,无法匹配下发记录。</p> : null}
|
||||
{!matching && selectedMessage.messageId && matchedRecords.length === 0 && !detailError ? (
|
||||
<p className="uplink-detail-hint">未匹配到真实下发记录。</p>
|
||||
) : null}
|
||||
{matchedRecords.length > 0 ? (
|
||||
<div className="uplink-match-list">
|
||||
{matchedRecords.map((record) => (
|
||||
<article className="uplink-match-card" key={record.id}>
|
||||
<div className="uplink-match-grid">
|
||||
<div>
|
||||
<span>发送时间</span>
|
||||
<strong>{getTime(record.queuedAt)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送应用</span>
|
||||
<strong>{record.application?.name ?? record.applicationId ?? '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>发送应用</span>
|
||||
<strong>{record.applicationName}</strong>
|
||||
<div className="uplink-match-content">
|
||||
<span>下发内容</span>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="uplink-match-content">
|
||||
<span>下发内容</span>
|
||||
<p>{record.content}</p>
|
||||
</div>
|
||||
<div className="uplink-match-actions">
|
||||
<Button size="sm" variant="ghost">添加到应用黑名单</Button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</DetailSection>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -16,6 +16,11 @@ type TableProps<T> = {
|
||||
};
|
||||
|
||||
export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }: TableProps<T>) {
|
||||
const minimumTableWidth = columns.reduce((sum, column) => {
|
||||
const match = column.width?.match(/^(\d+)px$/);
|
||||
return sum + (match ? Number(match[1]) : 0);
|
||||
}, 0);
|
||||
|
||||
function getRowKey(record: T) {
|
||||
if (typeof rowKey === 'function') {
|
||||
return rowKey(record);
|
||||
@@ -26,13 +31,18 @@ export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }:
|
||||
|
||||
return (
|
||||
<div className="ui-table-wrap">
|
||||
<table className="ui-table">
|
||||
<table className="ui-table" style={{ minWidth: minimumTableWidth ? `${minimumTableWidth}px` : undefined }}>
|
||||
<colgroup>
|
||||
{columns.map((column) => (
|
||||
<col key={column.key} style={{ width: column.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
style={{ width: column.width, textAlign: column.align ?? 'left' }}
|
||||
style={{ minWidth: column.width, width: column.width, textAlign: column.align ?? 'left' }}
|
||||
>
|
||||
{column.title}
|
||||
</th>
|
||||
@@ -52,7 +62,7 @@ export function Table<T>({ columns, data, rowKey, emptyText = '暂无数据' }:
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={column.key}
|
||||
style={{ textAlign: column.align ?? 'left' }}
|
||||
style={{ minWidth: column.width, textAlign: column.align ?? 'left', width: column.width }}
|
||||
>
|
||||
{column.render(record, index)}
|
||||
</td>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
@@ -25,11 +30,33 @@ import {
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { adminApi } from '@/api/adminApi';
|
||||
import { readSession } from '@/api/session';
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
|
||||
export function AdminLayout() {
|
||||
const session = readSession();
|
||||
const [pendingAuditCount, setPendingAuditCount] = useState(0);
|
||||
const loadPendingAuditCount = useCallback(() => {
|
||||
adminApi.getDashboard()
|
||||
.then((dashboard) => setPendingAuditCount(dashboard.pendingAuditCount ?? 0))
|
||||
.catch(() => setPendingAuditCount(0));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.portal !== 'admin') {
|
||||
return;
|
||||
}
|
||||
loadPendingAuditCount();
|
||||
const timer = window.setInterval(loadPendingAuditCount, 30000);
|
||||
const onFocus = () => loadPendingAuditCount();
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
}, [loadPendingAuditCount, session?.portal]);
|
||||
|
||||
if (session?.portal !== 'admin') {
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
@@ -43,10 +70,7 @@ export function AdminLayout() {
|
||||
userName={session.user.displayName}
|
||||
userRole="平台管理员"
|
||||
auditNotifications={[
|
||||
{ label: '企业认证审核', count: 3, to: '/admin/enterprise-audit' },
|
||||
{ label: '短信审核', count: 8, to: '/admin/sms-audit' },
|
||||
{ label: '短信模板审核', count: 5, to: '/admin/templates' },
|
||||
{ label: '签名审核', count: 2, to: '/admin/enterprise-signatures' },
|
||||
{ label: '待处理审核', count: pendingAuditCount, to: '/admin/sms-audit' },
|
||||
]}
|
||||
navSections={[
|
||||
{
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { clearSession } from '@/api/session';
|
||||
@@ -144,10 +143,6 @@ export function AppShell({
|
||||
>
|
||||
<ToggleIcon size={18} />
|
||||
</button>
|
||||
<label className="topbar-search">
|
||||
<Search size={17} />
|
||||
<input placeholder="搜索模板、客户、发送记录" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
|
||||
+12
-27
@@ -15,19 +15,13 @@ import { AdminEnterpriseSignaturesPage } from '@/apps/admin/AdminEnterpriseSigna
|
||||
import { AdminEnterpriseTemplatesPage } from '@/apps/admin/AdminEnterpriseTemplatesPage';
|
||||
import { AdminGlobalBlacklistPage } from '@/apps/admin/AdminGlobalBlacklistPage';
|
||||
import { AdminHome } from '@/apps/admin/AdminHome';
|
||||
import { AdminMmsApplicationFormPage } from '@/apps/admin/AdminMmsApplicationFormPage';
|
||||
import { AdminMmsChannelsPage } from '@/apps/admin/AdminMmsChannelsPage';
|
||||
import { AdminMmsRecordsPage } from '@/apps/admin/AdminMmsRecordsPage';
|
||||
import { AdminMmsTaskProgressPage } from '@/apps/admin/AdminMmsTaskProgressPage';
|
||||
import { AdminMonitorPage } from '@/apps/admin/AdminMonitorPage';
|
||||
import { AdminPhoneSegmentsPage } from '@/apps/admin/AdminPhoneSegmentsPage';
|
||||
import { AdminRechargeRecordsPage } from '@/apps/admin/AdminRechargeRecordsPage';
|
||||
import { AdminReportRecordsPage } from '@/apps/admin/AdminReportRecordsPage';
|
||||
import { AdminReportTasksPage } from '@/apps/admin/AdminReportTasksPage';
|
||||
import { AdminSensitiveWordsPage } from '@/apps/admin/AdminSensitiveWordsPage';
|
||||
import { AdminSettingsPage } from '@/apps/admin/AdminSettingsPage';
|
||||
import { AdminSmsAuditPage } from '@/apps/admin/AdminSmsAuditPage';
|
||||
import { AdminSignatureAuditPage } from '@/apps/admin/AdminSignatureAuditPage';
|
||||
import { AdminSmsApplicationFormPage } from '@/apps/admin/AdminSmsApplicationFormPage';
|
||||
import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage';
|
||||
import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage';
|
||||
@@ -42,15 +36,8 @@ import { ClientBillingPage } from '@/apps/client/ClientBillingPage';
|
||||
import { ClientEnterpriseAuthPage } from '@/apps/client/ClientEnterpriseAuthPage';
|
||||
import { ClientHome } from '@/apps/client/ClientHome';
|
||||
import { ClientInvoicesPage } from '@/apps/client/ClientInvoicesPage';
|
||||
import { ClientMmsBatchTasksPage } from '@/apps/client/ClientMmsBatchTasksPage';
|
||||
import { ClientMmsSignatureReportPage } from '@/apps/client/ClientMmsSignatureReportPage';
|
||||
import { ClientMmsSendDetailPage } from '@/apps/client/ClientMmsSendDetailPage';
|
||||
import { ClientMmsSendPage } from '@/apps/client/ClientMmsSendPage';
|
||||
import { ClientMmsTemplatesPage } from '@/apps/client/ClientMmsTemplatesPage';
|
||||
import { ClientMmsUplinkMessagesPage } from '@/apps/client/ClientMmsUplinkMessagesPage';
|
||||
import { ClientSendDetailPage } from '@/apps/client/ClientSendDetailPage';
|
||||
import { ClientSendPage } from '@/apps/client/ClientSendPage';
|
||||
import { ClientSettingsPage } from '@/apps/client/ClientSettingsPage';
|
||||
import { ClientSignaturesPage } from '@/apps/client/ClientSignaturesPage';
|
||||
import { ClientSystemLogsPage } from '@/apps/client/ClientSystemLogsPage';
|
||||
import { ClientTemplatesPage } from '@/apps/client/ClientTemplatesPage';
|
||||
@@ -76,15 +63,14 @@ export function AppRoutes() {
|
||||
<Route path="applications" element={<ClientApplicationsPage />} />
|
||||
<Route path="templates" element={<ClientTemplatesPage />} />
|
||||
<Route path="signatures" element={<ClientSignaturesPage />} />
|
||||
<Route path="mms-signatures" element={<ClientMmsSignatureReportPage />} />
|
||||
<Route path="mms-templates" element={<ClientMmsTemplatesPage />} />
|
||||
<Route path="mms-send" element={<ClientMmsSendPage />} />
|
||||
<Route path="mms-batch-tasks" element={<ClientMmsBatchTasksPage />} />
|
||||
<Route path="mms-send-detail" element={<ClientMmsSendDetailPage />} />
|
||||
<Route path="mms-uplink-messages" element={<ClientMmsUplinkMessagesPage />} />
|
||||
<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="invoices" element={<ClientInvoicesPage />} />
|
||||
<Route path="settings" element={<ClientSettingsPage />} />
|
||||
<Route path="enterprise-auth" element={<ClientEnterpriseAuthPage />} />
|
||||
<Route path="users" element={<ClientUsersPage />} />
|
||||
<Route path="system-logs" element={<ClientSystemLogsPage />} />
|
||||
@@ -100,8 +86,8 @@ export function AppRoutes() {
|
||||
<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={<AdminMmsApplicationFormPage />} />
|
||||
<Route path="customers/:enterpriseId/mms-apps/:appId/edit" element={<AdminMmsApplicationFormPage />} />
|
||||
<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 />} />
|
||||
@@ -110,15 +96,15 @@ export function AppRoutes() {
|
||||
<Route path="enterprise-signatures" element={<AdminEnterpriseSignaturesPage />} />
|
||||
<Route path="enterprise-templates" element={<AdminEnterpriseTemplatesPage />} />
|
||||
<Route path="templates" element={<AdminTemplateAuditPage />} />
|
||||
<Route path="signatures" element={<AdminSignatureAuditPage />} />
|
||||
<Route path="signatures" element={<PagePlaceholder />} />
|
||||
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
|
||||
<Route path="sms-audit" element={<AdminSmsAuditPage />} />
|
||||
<Route path="report-tasks" element={<AdminReportTasksPage />} />
|
||||
<Route path="report-records" element={<AdminReportRecordsPage />} />
|
||||
<Route path="sms-task-progress" element={<AdminSmsTaskProgressPage />} />
|
||||
<Route path="mms-task-progress" element={<AdminMmsTaskProgressPage />} />
|
||||
<Route path="mms-task-progress" element={<PagePlaceholder />} />
|
||||
<Route path="sms-records" element={<AdminSmsRecordsPage />} />
|
||||
<Route path="mms-records" element={<AdminMmsRecordsPage />} />
|
||||
<Route path="mms-records" element={<PagePlaceholder />} />
|
||||
<Route path="sms-uplink-records" element={<AdminSmsUplinkRecordsPage />} />
|
||||
<Route path="recharge-records" element={<AdminRechargeRecordsPage />} />
|
||||
<Route path="channels" element={<AdminChannelsPage />} />
|
||||
@@ -126,7 +112,7 @@ export function AppRoutes() {
|
||||
<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={<AdminMmsChannelsPage />} />
|
||||
<Route path="mms-channels" element={<PagePlaceholder />} />
|
||||
<Route path="enterprise-blacklist" element={<AdminEnterpriseBlacklistPage />} />
|
||||
<Route path="global-blacklist" element={<AdminGlobalBlacklistPage />} />
|
||||
<Route path="sensitive-words" element={<AdminSensitiveWordsPage />} />
|
||||
@@ -135,7 +121,6 @@ export function AppRoutes() {
|
||||
<Route path="drainage-fields" element={<AdminDrainageFieldsPage />} />
|
||||
<Route path="system-logs" element={<AdminSystemLogsPage />} />
|
||||
<Route path="billing" element={<AdminBillingPage />} />
|
||||
<Route path="settings" element={<AdminSettingsPage />} />
|
||||
<Route path="*" element={<PagePlaceholder />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
+217
-39
@@ -378,7 +378,8 @@ h3 {
|
||||
|
||||
.app-shell--collapsed .brand-copy,
|
||||
.app-shell--collapsed .side-nav-section p,
|
||||
.app-shell--collapsed .side-nav-group-toggle,
|
||||
.app-shell--collapsed .side-nav-group-label,
|
||||
.app-shell--collapsed .side-nav-group-chevron,
|
||||
.app-shell--collapsed .side-nav a span,
|
||||
.app-shell--collapsed .sidebar-footer {
|
||||
display: none;
|
||||
@@ -399,16 +400,47 @@ h3 {
|
||||
}
|
||||
|
||||
.app-shell--collapsed .side-nav {
|
||||
gap: var(--space-2);
|
||||
margin-right: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 0;
|
||||
scrollbar-width: none;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.app-shell--collapsed .side-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-shell--collapsed .side-nav-section {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.app-shell--collapsed .side-nav-group-toggle {
|
||||
border-radius: var(--radius-md);
|
||||
display: inline-flex;
|
||||
height: 38px;
|
||||
justify-content: center;
|
||||
margin-bottom: 0;
|
||||
min-height: 38px;
|
||||
padding: 0;
|
||||
width: 38px;
|
||||
}
|
||||
|
||||
.app-shell--collapsed .side-nav-list {
|
||||
gap: 6px;
|
||||
justify-items: center;
|
||||
padding-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-shell--collapsed .side-nav-section:has(.side-nav-group-toggle) .side-nav-list {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.app-shell--collapsed .side-nav a {
|
||||
height: 44px;
|
||||
justify-content: center;
|
||||
@@ -457,39 +489,6 @@ h3 {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
align-items: center;
|
||||
background: var(--color-surface-muted);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-text-subtle);
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
height: var(--control-height-md);
|
||||
padding: 0 var(--space-3);
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
width: min(380px, 38vw);
|
||||
}
|
||||
|
||||
.topbar-search:focus-within {
|
||||
background: var(--color-surface);
|
||||
border-color: var(--color-selected);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.topbar-search input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
outline: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar-search input::placeholder {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
align-items: center;
|
||||
background: var(--color-surface);
|
||||
@@ -747,6 +746,85 @@ h3 {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.ui-table-wrap {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ui-table {
|
||||
border-collapse: collapse;
|
||||
min-width: max-content;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-table th,
|
||||
.ui-table td {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ui-modal {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
inset: 0;
|
||||
justify-items: center;
|
||||
padding: var(--space-6);
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.ui-modal__mask {
|
||||
background: rgba(15, 23, 42, 0.48);
|
||||
border: 0;
|
||||
cursor: default;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.ui-modal__panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 24px 70px rgba(18, 18, 26, 0.18);
|
||||
display: grid;
|
||||
max-height: calc(100vh - 48px);
|
||||
max-width: min(920px, calc(100vw - 48px));
|
||||
min-width: min(520px, calc(100vw - 48px));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: min(640px, calc(100vw - 48px));
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ui-modal__panel--xl {
|
||||
max-width: min(980px, calc(100vw - 48px));
|
||||
width: min(860px, calc(100vw - 48px));
|
||||
}
|
||||
|
||||
.ui-modal__header,
|
||||
.ui-modal__footer {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
justify-content: space-between;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
}
|
||||
|
||||
.ui-modal__title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-modal__body {
|
||||
max-height: calc(100vh - 220px);
|
||||
overflow: auto;
|
||||
padding: 0 var(--space-6) var(--space-6);
|
||||
}
|
||||
|
||||
.ui-modal__footer {
|
||||
border-top: 1px solid var(--color-border);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.page-actions,
|
||||
.table-actions {
|
||||
align-items: center;
|
||||
@@ -754,6 +832,35 @@ h3 {
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.table-mono-id,
|
||||
.table-strong-text,
|
||||
.table-long-text {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.table-mono-id {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-strong-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-long-text {
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.table-long-text--sms-template {
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
display: -webkit-box;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
@@ -4704,7 +4811,8 @@ h3 {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.enterprise-upload-panel button {
|
||||
.enterprise-upload-panel button,
|
||||
.enterprise-upload-button {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px dashed var(--color-border-strong);
|
||||
@@ -4714,6 +4822,7 @@ h3 {
|
||||
gap: var(--space-2);
|
||||
height: 142px;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.enterprise-upload-panel p {
|
||||
@@ -5160,6 +5269,79 @@ h3 {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.app-create-modal {
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.app-create-modal__hint {
|
||||
background: var(--color-selected-soft);
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.app-create-modal__hint strong {
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.app-create-modal__hint span {
|
||||
color: var(--color-text-muted);
|
||||
line-height: var(--line-height-base);
|
||||
}
|
||||
|
||||
.admin-app-route-grid {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-app-route-card {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.admin-app-route-card.is-selected {
|
||||
background: var(--color-selected-soft);
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.admin-app-route-card header {
|
||||
align-items: flex-start;
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.admin-app-route-card header > span:first-child {
|
||||
align-items: center;
|
||||
background: #ffffff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-selected);
|
||||
display: inline-flex;
|
||||
height: 34px;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
}
|
||||
|
||||
.admin-app-route-card header strong,
|
||||
.admin-app-route-card header small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-app-route-card header small,
|
||||
.admin-app-route-card p {
|
||||
color: var(--color-text-muted);
|
||||
line-height: var(--line-height-base);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-switch {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
@@ -8252,10 +8434,6 @@ h3 {
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.topbar-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.topbar-left,
|
||||
.topbar-actions {
|
||||
justify-content: space-between;
|
||||
|
||||
Reference in New Issue
Block a user