refactor: strengthen client boundaries and quality gates
This commit is contained in:
+312
-121
@@ -1,10 +1,45 @@
|
||||
import { request, requestBlob, requestForm, withQuery } from '../core/httpClient';
|
||||
import type { ApplicationCmppParams, CaptchaResponse, ClientApplicationReportField, ClientSignatureWorkspace, ClientSmsApplication, ClientSmsSignatureView, ClientSmsTemplate, DashboardResponse, DeleteTargetRequest, DeletionPreflight, DeletionResult, DeletionTargetType, EnterpriseCertification, FileObject, HttpApiConfigResponse, HttpApiCredential, HttpApiRequestLog, HttpWebhookDelivery, HttpWebhookEndpoint, ImportPreviewResponse, ManagedUser, OperationLogResponse, PagedResult, RechargeOrder, SmsBatchTask, SmsDrainageInfo, SmsMessageRecord, SmsUplinkMessage, SystemLogExportResult, UserPayload } from '../types';
|
||||
import type {
|
||||
ApplicationCmppParams,
|
||||
CaptchaResponse,
|
||||
ClientApplicationReportField,
|
||||
ClientSignatureWorkspace,
|
||||
ClientSmsApplication,
|
||||
ClientSmsSignatureView,
|
||||
ClientSmsTemplate,
|
||||
DashboardResponse,
|
||||
DeleteTargetRequest,
|
||||
DeletionPreflight,
|
||||
DeletionResult,
|
||||
DeletionTargetType,
|
||||
EnterpriseCertification,
|
||||
FileObject,
|
||||
HttpApiConfigResponse,
|
||||
HttpApiCredential,
|
||||
HttpApiRequestLog,
|
||||
HttpWebhookDelivery,
|
||||
HttpWebhookEndpoint,
|
||||
ImportPreviewResponse,
|
||||
ManagedUser,
|
||||
OperationLogResponse,
|
||||
PagedResult,
|
||||
RechargeOrder,
|
||||
SmsBatchTask,
|
||||
SmsDrainageInfo,
|
||||
SmsMessageRecord,
|
||||
SmsUplinkMessage,
|
||||
SystemLogExportResult,
|
||||
} from '../types';
|
||||
import { assertUploadFileSize } from '@/utils/fileUpload';
|
||||
import type { LoginSession } from '../session';
|
||||
import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, redirectToPortalLogin } from '../session';
|
||||
import {
|
||||
clearSession,
|
||||
dispatchSessionEvent,
|
||||
hasRecentUserActivity,
|
||||
readSession,
|
||||
redirectToPortalLogin,
|
||||
} from '../session';
|
||||
import { readErrorBody } from '../core/httpClient';
|
||||
import { DEFAULT_CLIENT_TENANT_ID } from '../types';
|
||||
|
||||
// Client methods moved intact during R1; tenant and session behavior still flows
|
||||
// through the shared HTTP client and the existing upload path below.
|
||||
@@ -12,126 +47,277 @@ export const clientApi = {
|
||||
getCaptcha: () => request<CaptchaResponse>('/client/auth/captcha'),
|
||||
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||
request<LoginSession>('/client/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listUsers: (
|
||||
query: { displayName?: string; login?: string; status?: string } = {},
|
||||
tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID,
|
||||
) => request<ManagedUser[]>(withQuery('/client/users', query), { tenantId }),
|
||||
createUser: (body: UserPayload, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser>('/client/users', { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
updateUser: (id: string, body: Omit<UserPayload, 'password'>, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser>(`/client/users/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
|
||||
changeUserStatus: (id: string, status: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser>(`/client/users/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status, operatorId }) }),
|
||||
deleteUser: (id: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ManagedUser>(`/client/users/${id}`, { method: 'DELETE', tenantId, body: JSON.stringify({ operatorId }) }),
|
||||
changeUserPassword: (id: string, password: string, operatorId?: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
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) =>
|
||||
listUsers: (query: { displayName?: string; login?: string; status?: string } = {}) =>
|
||||
request<ManagedUser[]>(withQuery('/client/users', query)),
|
||||
createUser: (body: {
|
||||
username?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
status?: string;
|
||||
}) => request<ManagedUser>('/client/users', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateUser: (
|
||||
id: string,
|
||||
body: { username?: string; email?: string; phone?: string; displayName?: string; status?: string },
|
||||
) => request<ManagedUser>(`/client/users/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeUserStatus: (id: string, status: string) =>
|
||||
request<ManagedUser>(`/client/users/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
|
||||
deleteUser: (id: string) => request<ManagedUser>(`/client/users/${id}`, { method: 'DELETE' }),
|
||||
changeUserPassword: (id: string, password: string) =>
|
||||
request<ManagedUser>(`/client/users/${id}/password`, { method: 'POST', body: JSON.stringify({ password }) }),
|
||||
getDashboard: () => request<DashboardResponse>('/client/operations/dashboard'),
|
||||
listEnterpriseCertifications: () => request<EnterpriseCertification[]>('/client/enterprise-certification'),
|
||||
submitEnterpriseCertification: (body: {
|
||||
companyName: string;
|
||||
licenseNo?: string;
|
||||
contactName?: string;
|
||||
contactPhone?: string;
|
||||
materials?: Record<string, unknown>;
|
||||
}) =>
|
||||
request<EnterpriseCertification>('/client/enterprise-certification', {
|
||||
method: 'POST',
|
||||
tenantId,
|
||||
body: JSON.stringify({ ...body, tenantId }),
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
listSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string; page?: number; pageSize?: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<OperationLogResponse>(withQuery('/client/operations/system-logs', query), { tenantId }),
|
||||
exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) =>
|
||||
request<SystemLogExportResult>('/client/operations/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||
listOrders: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<RechargeOrder[]>('/client/billing/orders', { tenantId }),
|
||||
listOrdersPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<PagedResult<RechargeOrder>>(withQuery('/client/billing/orders', query), { tenantId }),
|
||||
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
|
||||
listApplicationsPage: (query: { page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<PagedResult<ClientSmsApplication>>(withQuery('/client/applications', query), { tenantId }),
|
||||
listApplicationOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsApplication[]>('/client/application-options', { tenantId }),
|
||||
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
|
||||
getApplicationHttpApiConfig: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiConfigResponse>(`/client/applications/${applicationId}/http-api`, { tenantId }),
|
||||
listHttpApiCredentials: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiCredential[]>(`/client/applications/${applicationId}/http-api/credentials`, { tenantId }),
|
||||
createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiCredential>(`/client/applications/${applicationId}/http-api/credentials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
revokeHttpApiCredential: (applicationId: string, credentialId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||
listHttpWebhooks: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookEndpoint[]>(`/client/applications/${applicationId}/http-api/webhooks`, { tenantId }),
|
||||
saveHttpWebhook: (applicationId: string, eventType: 'receipt' | 'uplink', body: { url: string; rotateSecret?: boolean; status?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookEndpoint>(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
|
||||
listHttpApiRequests: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpApiRequestLog[]>(`/client/applications/${applicationId}/http-api/requests`, { tenantId }),
|
||||
listHttpWebhookDeliveries: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<HttpWebhookDelivery[]>(`/client/applications/${applicationId}/http-api/webhook-deliveries`, { tenantId }),
|
||||
retryHttpWebhookDelivery: (applicationId: string, deliveryId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request<{ id: string; status: string }>(`/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||
listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientApplicationReportField[]>(withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }), { tenantId }),
|
||||
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage', tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType }), { tenantId }),
|
||||
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignatureView[]>('/client/signatures', { tenantId }),
|
||||
listSignatureOptions: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignatureView[]>('/client/signature-options', { tenantId }),
|
||||
getSignatureWorkspace: (query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSignatureWorkspace>(withQuery('/client/signatures-workspace', query), { tenantId }),
|
||||
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignatureView>('/client/signatures', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
|
||||
updateSignature: (id: string, body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignatureView>(`/client/signatures/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
|
||||
submitSignature: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignatureView>(`/client/signatures/${id}/submit`, { method: 'POST', tenantId, body: JSON.stringify({}) }),
|
||||
changeSignatureStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsSignatureView>(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
|
||||
createSignatureMaterial: (id: string, body: { fileObjectId?: string; materialType: string; title: string; description?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<Record<string, unknown>>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsDrainageInfo>(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
|
||||
changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
|
||||
listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<ClientSmsTemplate[]>(withQuery('/client/templates', {
|
||||
status: query.status,
|
||||
keyword: query.keyword,
|
||||
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
|
||||
}), { tenantId }),
|
||||
listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<PagedResult<ClientSmsTemplate>>(withQuery('/client/templates', {
|
||||
keyword: query.keyword,
|
||||
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
}), { 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) =>
|
||||
request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
|
||||
getDeletionPreflight: (type: Exclude<DeletionTargetType, 'channel'>, id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<DeletionPreflight>(`/client/deletions/${type}/${id}/preflight`, { tenantId }),
|
||||
deleteGovernedTarget: (type: Exclude<DeletionTargetType, 'channel'>, id: string, body: DeleteTargetRequest, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
|
||||
listBatchTasks: (query: { status?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query), { tenantId }),
|
||||
listBatchTasksPage: (query: { status?: string; keyword?: string; applicationKeyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<PagedResult<SmsBatchTask>>(withQuery('/client/send/batch-tasks', query), { tenantId }),
|
||||
cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
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: { applicationId?: string; 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; contentKeyword?: string; status?: string; queuedAtFrom?: string; queuedAtTo?: string; page?: number; pageSize?: number } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<PagedResult<SmsMessageRecord>>(withQuery('/client/operations/messages', query), { tenantId }),
|
||||
listUplinkMessages: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
|
||||
listUplinkMessagesPage: (query: { channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page: number; pageSize: number }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
request<PagedResult<SmsUplinkMessage>>(withQuery('/client/operations/uplink-messages', query), { tenantId }),
|
||||
listSystemLogs: (query: {
|
||||
keyword?: string;
|
||||
level?: string;
|
||||
module?: string;
|
||||
range?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) => request<OperationLogResponse>(withQuery('/client/operations/system-logs', query)),
|
||||
exportSystemLogs: (query: {
|
||||
keyword?: string;
|
||||
level?: string;
|
||||
module?: string;
|
||||
range?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
}) =>
|
||||
request<SystemLogExportResult>('/client/operations/system-logs/exports', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(query),
|
||||
}),
|
||||
listOrders: () => request<RechargeOrder[]>('/client/billing/orders'),
|
||||
listOrdersPage: (query: { page: number; pageSize: number }) =>
|
||||
request<PagedResult<RechargeOrder>>(withQuery('/client/billing/orders', query)),
|
||||
listApplications: () => request<ClientSmsApplication[]>('/client/applications'),
|
||||
listApplicationsPage: (query: { page: number; pageSize: number }) =>
|
||||
request<PagedResult<ClientSmsApplication>>(withQuery('/client/applications', query)),
|
||||
listApplicationOptions: () => request<ClientSmsApplication[]>('/client/application-options'),
|
||||
getApplicationCmppParams: (applicationId: string) =>
|
||||
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`),
|
||||
getApplicationHttpApiConfig: (applicationId: string) =>
|
||||
request<HttpApiConfigResponse>(`/client/applications/${applicationId}/http-api`),
|
||||
listHttpApiCredentials: (applicationId: string) =>
|
||||
request<HttpApiCredential[]>(`/client/applications/${applicationId}/http-api/credentials`),
|
||||
createHttpApiCredential: (applicationId: string, body: { name?: string; expiresAt?: string }) =>
|
||||
request<HttpApiCredential>(`/client/applications/${applicationId}/http-api/credentials`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
revokeHttpApiCredential: (applicationId: string, credentialId: string) =>
|
||||
request<{ id: string; status: string }>(
|
||||
`/client/applications/${applicationId}/http-api/credentials/${credentialId}/revoke`,
|
||||
{ method: 'POST', body: JSON.stringify({}) },
|
||||
),
|
||||
listHttpWebhooks: (applicationId: string) =>
|
||||
request<HttpWebhookEndpoint[]>(`/client/applications/${applicationId}/http-api/webhooks`),
|
||||
saveHttpWebhook: (
|
||||
applicationId: string,
|
||||
eventType: 'receipt' | 'uplink',
|
||||
body: { url: string; rotateSecret?: boolean; status?: string },
|
||||
) =>
|
||||
request<HttpWebhookEndpoint>(`/client/applications/${applicationId}/http-api/webhooks/${eventType}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
listHttpApiRequests: (applicationId: string) =>
|
||||
request<HttpApiRequestLog[]>(`/client/applications/${applicationId}/http-api/requests`),
|
||||
listHttpWebhookDeliveries: (applicationId: string) =>
|
||||
request<HttpWebhookDelivery[]>(`/client/applications/${applicationId}/http-api/webhook-deliveries`),
|
||||
retryHttpWebhookDelivery: (applicationId: string, deliveryId: string) =>
|
||||
request<{ id: string; status: string }>(
|
||||
`/client/applications/${applicationId}/http-api/webhook-deliveries/${deliveryId}/retry`,
|
||||
{ method: 'POST', body: JSON.stringify({}) },
|
||||
),
|
||||
listApplicationReportFields: (applicationId: string, reportType: 'signature' | 'drainage' = 'drainage') =>
|
||||
request<ClientApplicationReportField[]>(
|
||||
withQuery(`/client/applications/${applicationId}/report-fields`, { reportType }),
|
||||
),
|
||||
listCommonApplicationReportFields: (reportType: 'signature' | 'drainage') =>
|
||||
request<ClientApplicationReportField[]>(withQuery('/client/report-fields/common', { reportType })),
|
||||
listSignatures: () => request<ClientSmsSignatureView[]>('/client/signatures'),
|
||||
listSignatureOptions: () => request<ClientSmsSignatureView[]>('/client/signature-options'),
|
||||
getSignatureWorkspace: (
|
||||
query: { keyword?: string; applicationId?: string; status?: string; page?: number; pageSize?: number } = {},
|
||||
) => request<ClientSignatureWorkspace>(withQuery('/client/signatures-workspace', query)),
|
||||
createSignature: (body: {
|
||||
applicationId?: string;
|
||||
name: string;
|
||||
purpose?: string;
|
||||
drainageInfo?: Record<string, unknown>;
|
||||
}) => request<ClientSmsSignatureView>('/client/signatures', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateSignature: (
|
||||
id: string,
|
||||
body: { applicationId?: string; name?: string; purpose?: string; drainageInfo?: Record<string, unknown> },
|
||||
) => request<ClientSmsSignatureView>(`/client/signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
submitSignature: (id: string) =>
|
||||
request<ClientSmsSignatureView>(`/client/signatures/${id}/submit`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
changeSignatureStatus: (id: string, status: string) =>
|
||||
request<ClientSmsSignatureView>(`/client/signatures/${id}/status`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
createSignatureMaterial: (
|
||||
id: string,
|
||||
body: { fileObjectId?: string; materialType: string; title: string; description?: string },
|
||||
) =>
|
||||
request<Record<string, unknown>>(`/client/signatures/${id}/materials`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
createDrainageInfo: (
|
||||
signatureId: string,
|
||||
body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> },
|
||||
) =>
|
||||
request<SmsDrainageInfo>(`/client/signatures/${signatureId}/drainage-infos`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
updateDrainageInfo: (
|
||||
id: string,
|
||||
body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> },
|
||||
) => request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
changeDrainageInfoStatus: (id: string, status: string) =>
|
||||
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
listTemplates: (query: { status?: string; keyword?: string; includeHistory?: boolean } = {}) =>
|
||||
request<ClientSmsTemplate[]>(
|
||||
withQuery('/client/templates', {
|
||||
status: query.status,
|
||||
keyword: query.keyword,
|
||||
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
|
||||
}),
|
||||
),
|
||||
listTemplatesPage: (query: { keyword?: string; includeHistory?: boolean; page: number; pageSize: number }) =>
|
||||
request<PagedResult<ClientSmsTemplate>>(
|
||||
withQuery('/client/templates', {
|
||||
keyword: query.keyword,
|
||||
includeHistory: query.includeHistory === undefined ? undefined : String(query.includeHistory),
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
}),
|
||||
),
|
||||
createTemplate: (body: {
|
||||
applicationId: string;
|
||||
signatureId?: string;
|
||||
name: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
variables?: Array<{ name: string; example?: string; required?: boolean }>;
|
||||
}) => request<ClientSmsTemplate>('/client/templates', { method: 'POST', body: JSON.stringify(body) }),
|
||||
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 }>;
|
||||
},
|
||||
) => request<ClientSmsTemplate>(`/client/templates/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
submitTemplate: (id: string) =>
|
||||
request<ClientSmsTemplate>(`/client/templates/${id}/submit`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
changeTemplateStatus: (id: string, status: string) =>
|
||||
request<ClientSmsTemplate>(`/client/templates/${id}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
|
||||
getDeletionPreflight: (type: Exclude<DeletionTargetType, 'channel'>, id: string) =>
|
||||
request<DeletionPreflight>(`/client/deletions/${type}/${id}/preflight`),
|
||||
deleteGovernedTarget: (type: Exclude<DeletionTargetType, 'channel'>, id: string, body: DeleteTargetRequest) =>
|
||||
request<DeletionResult>(`/client/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listBatchTasks: (query: { status?: string } = {}) =>
|
||||
request<SmsBatchTask[]>(withQuery('/client/send/batch-tasks', query)),
|
||||
listBatchTasksPage: (query: {
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
applicationKeyword?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}) => request<PagedResult<SmsBatchTask>>(withQuery('/client/send/batch-tasks', query)),
|
||||
cancelBatchTask: (id: string) =>
|
||||
request<SmsBatchTask>(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', body: JSON.stringify({}) }),
|
||||
createBatchTask: (body: {
|
||||
applicationId?: string;
|
||||
templateId?: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
phones: string[];
|
||||
sendMode?: 'immediate' | 'scheduled';
|
||||
scheduledAt?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
}) => request<SmsBatchTask>('/client/send/batch-tasks', { method: 'POST', body: JSON.stringify(body) }),
|
||||
previewImport: (body: {
|
||||
applicationId?: string;
|
||||
content: string;
|
||||
fileName?: string;
|
||||
delimiter?: ',' | '\t';
|
||||
requiredVariables?: string[];
|
||||
}) => request<ImportPreviewResponse>('/client/send/imports/preview', { method: 'POST', body: JSON.stringify(body) }),
|
||||
confirmImport: (body: {
|
||||
applicationId?: string;
|
||||
templateId?: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
importContent: string;
|
||||
sendMode?: 'immediate' | 'scheduled';
|
||||
scheduledAt?: string;
|
||||
requiredVariables?: string[];
|
||||
variables?: Record<string, unknown>;
|
||||
}) => request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listBatchTaskMessages: (id: string) => request<SmsMessageRecord[]>(`/client/send/batch-tasks/${id}/messages`),
|
||||
listMessages: (
|
||||
query: {
|
||||
applicationId?: string;
|
||||
taskId?: string;
|
||||
messageId?: string;
|
||||
phoneNumber?: string;
|
||||
contentKeyword?: string;
|
||||
status?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
) => request<PagedResult<SmsMessageRecord>>(withQuery('/client/operations/messages', query)),
|
||||
listUplinkMessages: (
|
||||
query: {
|
||||
channelId?: string;
|
||||
applicationId?: string;
|
||||
phoneNumber?: string;
|
||||
keyword?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
} = {},
|
||||
) => request<SmsUplinkMessage[]>(withQuery('/client/operations/uplink-messages', query)),
|
||||
listUplinkMessagesPage: (query: {
|
||||
channelId?: string;
|
||||
applicationId?: string;
|
||||
phoneNumber?: string;
|
||||
keyword?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}) => request<PagedResult<SmsUplinkMessage>>(withQuery('/client/operations/uplink-messages', query)),
|
||||
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => {
|
||||
assertUploadFileSize(file);
|
||||
const form = new FormData();
|
||||
@@ -143,7 +329,12 @@ export const clientApi = {
|
||||
const headers = new Headers();
|
||||
const session = readSession('client');
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const response = await fetch('/api/client/files/upload', { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
||||
const response = await fetch('/api/client/files/upload', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: form,
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (response.status === 401 && session) {
|
||||
const error = await readErrorBody(response.clone());
|
||||
if (error.code === 'SESSION_LOCKED') {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { fileDownloadUrl, readErrorBody, request, requestBlob, requestForm, withQuery } from './httpClient';
|
||||
import { setReauthenticationHandler, writeSession } from '../session';
|
||||
|
||||
let receivedTenantHeader: string | null = null;
|
||||
const server = setupServer(
|
||||
http.get('http://localhost/api/client/users', ({ request: incoming }) => {
|
||||
receivedTenantHeader = incoming.headers.get('x-tenant-id');
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
http.get('http://localhost/api/admin/users', ({ request: incoming }) => {
|
||||
receivedTenantHeader = incoming.headers.get('x-tenant-id');
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
http.get('http://localhost/api/client/failure', () =>
|
||||
HttpResponse.json({ message: '后端业务失败' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
|
||||
const nativeFetch = globalThis.fetch;
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: 'error' });
|
||||
const interceptedFetch = globalThis.fetch;
|
||||
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
interceptedFetch(new URL(String(input), 'http://localhost'), init)) as typeof fetch;
|
||||
});
|
||||
afterEach(() => {
|
||||
receivedTenantHeader = null;
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
globalThis.fetch = nativeFetch;
|
||||
setReauthenticationHandler();
|
||||
});
|
||||
|
||||
describe('request tenant and error boundaries', () => {
|
||||
it('never forwards an explicit tenant header to a client route', async () => {
|
||||
await expect(request('/client/users', { tenantId: 'tenant-attacker' })).resolves.toEqual([]);
|
||||
expect(receivedTenantHeader).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps explicit tenant selection for non-client administrative routes', async () => {
|
||||
await expect(request('/admin/users', { tenantId: 'tenant-admin-selected' })).resolves.toEqual([]);
|
||||
expect(receivedTenantHeader).toBe('tenant-admin-selected');
|
||||
});
|
||||
|
||||
it('surfaces a backend JSON error message instead of a generic status', async () => {
|
||||
await expect(request('/client/failure')).rejects.toThrow('后端业务失败');
|
||||
});
|
||||
|
||||
it.each([
|
||||
[HttpResponse.json({ message: ['字段一', '字段二'] }, { status: 422 }), '字段一;字段二'],
|
||||
[HttpResponse.json({ error: '网关错误' }, { status: 502 }), '网关错误'],
|
||||
[new HttpResponse(null, { status: 503 }), '请求失败(503)'],
|
||||
])('normalizes additional server error body shapes', async (response, expected) => {
|
||||
server.use(http.get('http://localhost/api/client/error-shape', () => response));
|
||||
await expect(request('/client/error-shape')).rejects.toThrow(expected);
|
||||
});
|
||||
|
||||
it('parses empty, JSON and plain-text error bodies safely', async () => {
|
||||
await expect(readErrorBody(new Response())).resolves.toEqual({});
|
||||
await expect(readErrorBody(HttpResponse.json({ code: 'BAD', message: ['字段一', '字段二'] }))).resolves.toEqual({
|
||||
code: 'BAD',
|
||||
message: ['字段一', '字段二'],
|
||||
});
|
||||
await expect(readErrorBody(new Response('代理错误'))).resolves.toEqual({ message: '代理错误' });
|
||||
});
|
||||
|
||||
it('does not redirect a rejected login attempt', async () => {
|
||||
server.use(
|
||||
http.post('http://localhost/api/client/auth/login', () =>
|
||||
HttpResponse.json({ message: 'Invalid login or password' }, { status: 401 }),
|
||||
),
|
||||
);
|
||||
await expect(request('/client/auth/login', { method: 'POST', body: '{}' })).rejects.toThrow(
|
||||
'Invalid login or password',
|
||||
);
|
||||
});
|
||||
|
||||
it('can suppress a session redirect for a background probe', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/client/probe', () => HttpResponse.json({ message: '未登录' }, { status: 401 })),
|
||||
);
|
||||
await expect(request('/client/probe', { suppressSessionRedirect: true })).rejects.toThrow('未登录');
|
||||
});
|
||||
|
||||
it('raises the server lock message for an authenticated session', async () => {
|
||||
writeSession({
|
||||
portal: 'client',
|
||||
user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
|
||||
idleTimeoutSeconds: 1,
|
||||
lockRecoverySeconds: 1,
|
||||
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
recentAuthenticationExpiresAt: new Date().toISOString(),
|
||||
});
|
||||
server.use(
|
||||
http.get('http://localhost/api/client/locked', () =>
|
||||
HttpResponse.json({ code: 'SESSION_LOCKED', message: '会话测试锁定' }, { status: 401 }),
|
||||
),
|
||||
);
|
||||
await expect(request('/client/locked')).rejects.toThrow('会话测试锁定');
|
||||
});
|
||||
|
||||
it('reauthenticates once and retries a protected request', async () => {
|
||||
writeSession({
|
||||
portal: 'client',
|
||||
user: { id: 'user-1', username: 'user', displayName: '用户', roles: ['enterprise_admin'] },
|
||||
idleTimeoutSeconds: 7200,
|
||||
lockRecoverySeconds: 14400,
|
||||
absoluteExpiresAt: new Date(Date.now() + 3600000).toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
recentAuthenticationExpiresAt: new Date().toISOString(),
|
||||
});
|
||||
let attempts = 0;
|
||||
server.use(
|
||||
http.post('http://localhost/api/client/protected', () => {
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
|
||||
: HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const reauthenticate = vi.fn().mockResolvedValue(undefined);
|
||||
setReauthenticationHandler(reauthenticate);
|
||||
await expect(request('/client/protected', { method: 'POST', body: '{}' })).resolves.toEqual({ success: true });
|
||||
expect(reauthenticate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('downloads blobs and preserves server text errors', async () => {
|
||||
server.use(
|
||||
http.get('http://localhost/api/client/file-ok', () => new HttpResponse('file-data', { status: 200 })),
|
||||
http.get('http://localhost/api/client/file-fail', () => new HttpResponse('文件不存在', { status: 404 })),
|
||||
);
|
||||
await expect((await requestBlob('/client/file-ok')).text()).resolves.toBe('file-data');
|
||||
await expect(requestBlob('/client/file-fail')).rejects.toThrow('文件不存在');
|
||||
});
|
||||
|
||||
it('retries blob downloads after recent authentication and supports admin tenant selection', async () => {
|
||||
writeSession({
|
||||
portal: 'admin',
|
||||
user: { id: 'a', username: 'a', displayName: 'A', roles: ['platform_admin'] },
|
||||
idleTimeoutSeconds: 1,
|
||||
lockRecoverySeconds: 1,
|
||||
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
recentAuthenticationExpiresAt: new Date().toISOString(),
|
||||
});
|
||||
let attempts = 0;
|
||||
server.use(
|
||||
http.get('http://localhost/api/admin/export', ({ request: incoming }) => {
|
||||
attempts += 1;
|
||||
receivedTenantHeader = incoming.headers.get('x-tenant-id');
|
||||
return attempts === 1
|
||||
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
|
||||
: new HttpResponse('csv');
|
||||
}),
|
||||
);
|
||||
setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
|
||||
await expect((await requestBlob('/admin/export', { tenantId: 'tenant-1' })).text()).resolves.toBe('csv');
|
||||
expect(receivedTenantHeader).toBe('tenant-1');
|
||||
});
|
||||
|
||||
it('submits multipart forms without forcing a JSON content type', async () => {
|
||||
let contentType = '';
|
||||
server.use(
|
||||
http.post('http://localhost/api/client/upload', ({ request: incoming }) => {
|
||||
contentType = incoming.headers.get('content-type') ?? '';
|
||||
return HttpResponse.json({ id: 'file-1' });
|
||||
}),
|
||||
);
|
||||
const form = new FormData();
|
||||
form.set('file', new Blob(['data']), 'data.txt');
|
||||
await expect(requestForm('/client/upload', form)).resolves.toEqual({ id: 'file-1' });
|
||||
expect(contentType).toContain('multipart/form-data; boundary=');
|
||||
});
|
||||
|
||||
it('retries multipart forms after recent authentication and reports failures', async () => {
|
||||
writeSession({
|
||||
portal: 'client',
|
||||
user: { id: 'u', username: 'u', displayName: 'U', roles: [] },
|
||||
idleTimeoutSeconds: 1,
|
||||
lockRecoverySeconds: 1,
|
||||
absoluteExpiresAt: new Date(Date.now() + 1000).toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
recentAuthenticationExpiresAt: new Date().toISOString(),
|
||||
});
|
||||
let attempts = 0;
|
||||
server.use(
|
||||
http.post('http://localhost/api/client/form-protected', () => {
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? HttpResponse.json({ code: 'RECENT_AUTHENTICATION_REQUIRED' }, { status: 403 })
|
||||
: HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
setReauthenticationHandler(vi.fn().mockResolvedValue(undefined));
|
||||
await expect(requestForm('/client/form-protected', new FormData())).resolves.toEqual({ ok: true });
|
||||
server.use(
|
||||
http.post('http://localhost/api/client/form-fail', () =>
|
||||
HttpResponse.json({ error: '上传失败' }, { status: 400 }),
|
||||
),
|
||||
);
|
||||
await expect(requestForm('/client/form-fail', new FormData())).rejects.toThrow('上传失败');
|
||||
});
|
||||
|
||||
it('builds bounded queries and encoded download URLs', () => {
|
||||
expect(withQuery('/client/messages', { page: 2, status: 'all', keyword: '', level: 'warn' })).toBe(
|
||||
'/client/messages?page=2&level=warn',
|
||||
);
|
||||
expect(fileDownloadUrl('folder/file 1', 'inline', 'client')).toBe(
|
||||
'/api/client/files/folder%2Ffile%201/download?disposition=inline',
|
||||
);
|
||||
expect(withQuery('/client/messages', { status: undefined })).toBe('/client/messages');
|
||||
expect(fileDownloadUrl('file-1')).toBe('/api/admin/files/file-1/download?disposition=attachment');
|
||||
});
|
||||
});
|
||||
+17
-13
@@ -2,7 +2,6 @@ import {
|
||||
clearSession,
|
||||
currentRouteForPortal,
|
||||
dispatchSessionEvent,
|
||||
getSessionTenantId,
|
||||
hasRecentUserActivity,
|
||||
portalFromPath,
|
||||
readSession,
|
||||
@@ -19,7 +18,6 @@ type RequestOptions = RequestInit & {
|
||||
suppressSessionRedirect?: boolean;
|
||||
};
|
||||
|
||||
|
||||
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
|
||||
|
||||
export async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
||||
@@ -32,8 +30,14 @@ export async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type SessionTiming = Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>;
|
||||
export type SessionTiming = Pick<
|
||||
LoginSession,
|
||||
| 'idleTimeoutSeconds'
|
||||
| 'lockRecoverySeconds'
|
||||
| 'absoluteExpiresAt'
|
||||
| 'lastActivityAt'
|
||||
| 'recentAuthenticationExpiresAt'
|
||||
>;
|
||||
|
||||
// Authentication failures are handled centrally so every domain API keeps the
|
||||
// same lock, recovery and redirect behavior as the original adminApi facade.
|
||||
@@ -87,9 +91,8 @@ export async function request<T>(path: string, options: RequestOptions = {}): Pr
|
||||
const portal = requestPortal(path);
|
||||
const session = portal ? readSession(portal) : null;
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
if (options.tenantId && !path.startsWith('/client')) {
|
||||
headers.set('x-tenant-id', options.tenantId);
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
|
||||
@@ -114,9 +117,8 @@ export async function requestBlob(path: string, options: RequestOptions = {}): P
|
||||
const portal = requestPortal(path);
|
||||
const session = portal ? readSession(portal) : null;
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const tenantId = options.tenantId ?? (path.startsWith('/client') ? getSessionTenantId() : undefined);
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
if (options.tenantId && !path.startsWith('/client')) {
|
||||
headers.set('x-tenant-id', options.tenantId);
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
if (response.status === 401) {
|
||||
@@ -156,7 +158,6 @@ export async function requestForm<T>(path: string, form: FormData, reauthenticat
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
|
||||
export function withQuery(path: string, query: Record<string, string | number | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
@@ -168,7 +169,10 @@ export function withQuery(path: string, query: Record<string, string | number |
|
||||
return `${path}${suffix}`;
|
||||
}
|
||||
|
||||
|
||||
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') {
|
||||
export function fileDownloadUrl(
|
||||
fileObjectId: string,
|
||||
disposition: 'attachment' | 'inline' = 'attachment',
|
||||
portal: Portal = 'admin',
|
||||
) {
|
||||
return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts.
|
||||
|
||||
|
||||
export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
|
||||
|
||||
|
||||
export type DeletionTargetType = 'channel' | 'signature' | 'template';
|
||||
|
||||
export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
|
||||
export type DeletionResolutionAction =
|
||||
'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks';
|
||||
|
||||
export type DeletionDependency = { kind: string; label: string; count: number; items: string[]; detailsVisible: boolean };
|
||||
export type DeletionDependency = {
|
||||
kind: string;
|
||||
label: string;
|
||||
count: number;
|
||||
items: string[];
|
||||
detailsVisible: boolean;
|
||||
};
|
||||
|
||||
export type DeletionRequiredSelection = {
|
||||
action: DeletionResolutionAction;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LoginPage, loginErrorMessage } from './LoginPage';
|
||||
|
||||
const { getCaptcha, login } = vi.hoisted(() => ({ getCaptcha: vi.fn(), login: vi.fn() }));
|
||||
|
||||
vi.mock('@/api/adminApi', () => ({
|
||||
adminApi: { getCaptcha, login },
|
||||
clientApi: { getCaptcha, login },
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui', () => ({
|
||||
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
ClientLoginCanvas: () => <div data-testid="client-canvas" />,
|
||||
Input: ({ label, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) => (
|
||||
<label>
|
||||
{label}
|
||||
<input {...props} />
|
||||
</label>
|
||||
),
|
||||
Modal: ({ children, open, title }: { children: React.ReactNode; open: boolean; title: string }) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
getCaptcha
|
||||
.mockReset()
|
||||
.mockResolvedValue({ captchaId: 'captcha-1', challenge: '12 + 3 = ?', expiresInSeconds: 300 });
|
||||
login.mockReset();
|
||||
});
|
||||
|
||||
it('renders client-only website return action and loads a captcha', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginPage portal="client" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByRole('link', { name: '返回官网' })).toHaveAttribute('href', 'https://www.lisglo.com');
|
||||
expect(await screen.findByRole('button', { name: '12 + 3 = ?' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('maps backend login errors to Chinese and refreshes the one-time captcha', async () => {
|
||||
login.mockRejectedValue(new Error('Invalid login or password'));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginPage portal="client" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await screen.findByRole('button', { name: '12 + 3 = ?' });
|
||||
fireEvent.change(screen.getByLabelText('用户名/登录账号'), { target: { value: 'bad-user' } });
|
||||
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'bad-password' } });
|
||||
fireEvent.change(screen.getByLabelText('图形验证码'), { target: { value: '15' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
await waitFor(() => expect(screen.getAllByText('用户名或密码错误')).toHaveLength(2));
|
||||
expect(getCaptcha).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not render client artwork or the website link for admin login', async () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginPage portal="admin" />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await screen.findByRole('button', { name: '12 + 3 = ?' });
|
||||
expect(screen.queryByTestId('client-canvas')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '返回官网' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('运营端登录')).toBeVisible();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['login and password are required', '请输入用户名和密码'],
|
||||
['Captcha expired', '验证码已过期,请刷新后重试'],
|
||||
['Captcha is incorrect', '验证码错误,请重新输入'],
|
||||
['User is locked', '账号已锁定,请 24 小时后再试'],
|
||||
['Too many login attempts from this source', '登录尝试过于频繁,请稍后再试'],
|
||||
['User is disabled or deleted', '账号已停用或已删除'],
|
||||
['Only platform admins can login to admin portal', '该账号不是运营端管理员'],
|
||||
['Only enterprise admins linked to a tenant can login to client portal', '该账号不是已绑定企业的客户端管理员'],
|
||||
['unknown backend error', 'unknown backend error'],
|
||||
])('maps backend error %s to a stable user message', (backend, expected) => {
|
||||
expect(loginErrorMessage(new Error(backend))).toBe(expected);
|
||||
});
|
||||
|
||||
it('uses a safe fallback for non-error failures', () => {
|
||||
expect(loginErrorMessage(null)).toBe('登录失败,请检查账号信息后重试');
|
||||
});
|
||||
});
|
||||
+38
-8
@@ -1,23 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
|
||||
import { consumeSessionRecovery, markUserActivity, readSessionRecovery, writeSession, type Portal } from '@/api/session';
|
||||
import {
|
||||
consumeSessionRecovery,
|
||||
markUserActivity,
|
||||
readSessionRecovery,
|
||||
writeSession,
|
||||
type Portal,
|
||||
} from '@/api/session';
|
||||
import { Button, ClientLoginCanvas, Input, Modal } from '@/components/ui';
|
||||
|
||||
type LoginPageProps = {
|
||||
portal: Portal;
|
||||
};
|
||||
|
||||
function loginErrorMessage(err: unknown) {
|
||||
export function loginErrorMessage(err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
if (message.includes('Invalid login or password')) return '用户名或密码错误';
|
||||
if (message.includes('login and password are required')) return '请输入用户名和密码';
|
||||
if (message.includes('Captcha expired')) return '验证码已过期,请刷新后重试';
|
||||
if (message.includes('Captcha is incorrect')) return '验证码错误,请重新输入';
|
||||
if (message.includes('User is locked')) return '账号已锁定,请 24 小时后再试';
|
||||
if (message.includes('Too many login attempts')) return '登录尝试过于频繁,请稍后再试';
|
||||
if (message.includes('User is disabled or deleted')) return '账号已停用或已删除';
|
||||
if (message.includes('Only platform admins can login to admin portal')) return '该账号不是运营端管理员';
|
||||
if (message.includes('Only enterprise admins linked to a tenant can login to client portal')) return '该账号不是已绑定企业的客户端管理员';
|
||||
if (message.includes('Only enterprise admins linked to a tenant can login to client portal'))
|
||||
return '该账号不是已绑定企业的客户端管理员';
|
||||
return message || '登录失败,请检查账号信息后重试';
|
||||
}
|
||||
|
||||
@@ -90,17 +98,39 @@ export function LoginPage({ portal }: LoginPageProps) {
|
||||
{recovery.message ?? '登录会话已失效,请重新登录。'} 登录成功后将返回之前访问的页面。
|
||||
</p>
|
||||
) : null}
|
||||
<Input label="用户名/登录账号" onChange={(event) => setLogin(event.target.value)} placeholder="请输入用户名、邮箱或手机号" value={login} />
|
||||
<Input label="密码" onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" type="password" value={password} />
|
||||
<Input
|
||||
label="用户名/登录账号"
|
||||
onChange={(event) => setLogin(event.target.value)}
|
||||
placeholder="请输入用户名、邮箱或手机号"
|
||||
value={login}
|
||||
/>
|
||||
<Input
|
||||
label="密码"
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
<div className="login-captcha-row">
|
||||
<Input label="图形验证码" onChange={(event) => setCaptchaText(event.target.value)} placeholder="请输入计算结果" value={captchaText} />
|
||||
<Input
|
||||
label="图形验证码"
|
||||
onChange={(event) => setCaptchaText(event.target.value)}
|
||||
placeholder="请输入计算结果"
|
||||
value={captchaText}
|
||||
/>
|
||||
<button className="login-captcha" onClick={() => void refreshCaptcha()} type="button">
|
||||
{captcha?.challenge ?? '刷新'}
|
||||
</button>
|
||||
</div>
|
||||
{error ? <p className="login-error">{error}</p> : null}
|
||||
<Button disabled={loading} onClick={submit}>{loading ? '登录中...' : '登录'}</Button>
|
||||
{!isAdmin ? <a className="login-return-link" href="https://www.lisglo.com">返回官网</a> : null}
|
||||
<Button disabled={loading} onClick={submit}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
{!isAdmin ? (
|
||||
<a className="login-return-link" href="https://www.lisglo.com">
|
||||
返回官网
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<Modal
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ClientUsersPage } from './ClientUsersPage';
|
||||
|
||||
const { clientApi } = vi.hoisted(() => ({
|
||||
clientApi: {
|
||||
listUsers: vi.fn(),
|
||||
deleteUser: vi.fn(),
|
||||
changeUserStatus: vi.fn(),
|
||||
createUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
changeUserPassword: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/api/adminApi', () => ({ clientApi }));
|
||||
vi.mock('@/components/ui', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
icon: _icon,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { icon?: React.ReactNode }) => (
|
||||
<button {...props}>{children}</button>
|
||||
),
|
||||
Input: ({ label, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) => (
|
||||
<label>
|
||||
{label}
|
||||
<input {...props} />
|
||||
</label>
|
||||
),
|
||||
Select: ({
|
||||
label,
|
||||
options,
|
||||
...props
|
||||
}: React.SelectHTMLAttributes<HTMLSelectElement> & {
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
}) => (
|
||||
<label>
|
||||
{label}
|
||||
<select {...props}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
),
|
||||
Tag: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
||||
Table: ({
|
||||
columns,
|
||||
data,
|
||||
emptyText,
|
||||
}: {
|
||||
columns: Array<{ key: string; render?: (record: never) => React.ReactNode }>;
|
||||
data: never[];
|
||||
emptyText: string;
|
||||
}) =>
|
||||
data.length ? (
|
||||
<div>
|
||||
{data.map((row: never, index: number) => (
|
||||
<div key={index}>
|
||||
{columns.map((column) => (
|
||||
<span key={column.key}>{column.render?.(row)}</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>{emptyText}</p>
|
||||
),
|
||||
Modal: ({
|
||||
children,
|
||||
footer,
|
||||
open,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
open: boolean;
|
||||
title: string;
|
||||
}) =>
|
||||
open ? (
|
||||
<div role="dialog" aria-label={title}>
|
||||
{children}
|
||||
{footer}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
displayName: '截图用户',
|
||||
username: 'screenshot',
|
||||
email: 'user@example.com',
|
||||
phone: '13800000000',
|
||||
status: 'active',
|
||||
roles: ['enterprise_admin'],
|
||||
};
|
||||
|
||||
describe('ClientUsersPage states', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(clientApi).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
it('renders the empty state from a successful real API-shaped response', async () => {
|
||||
clientApi.listUsers.mockResolvedValue([]);
|
||||
render(<ClientUsersPage />);
|
||||
expect(await screen.findByText('暂无用户')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders a backend loading error', async () => {
|
||||
clientApi.listUsers.mockRejectedValue(new Error('用户服务暂不可用'));
|
||||
render(<ClientUsersPage />);
|
||||
expect(await screen.findByText('用户服务暂不可用')).toBeVisible();
|
||||
});
|
||||
|
||||
it('shows query loading and requires confirmation before deletion', async () => {
|
||||
let resolveQuery: (value: (typeof user)[]) => void = () => undefined;
|
||||
clientApi.listUsers
|
||||
.mockResolvedValueOnce([user])
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveQuery = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce([]);
|
||||
clientApi.deleteUser.mockResolvedValue(user);
|
||||
render(<ClientUsersPage />);
|
||||
await screen.findByText('截图用户');
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
expect(screen.getByRole('button', { name: '查询中...' })).toBeDisabled();
|
||||
resolveQuery([user]);
|
||||
await screen.findByRole('button', { name: '查询' });
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除' }));
|
||||
expect(screen.getByRole('dialog', { name: '删除用户' })).toHaveTextContent('确认删除用户 截图用户');
|
||||
expect(clientApi.deleteUser).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
await waitFor(() => expect(clientApi.deleteUser).toHaveBeenCalledWith('user-1'));
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Edit3, KeyRound, Plus, Search, Trash2, Users } from 'lucide-react';
|
||||
import { clientApi, type ManagedUser, type UserPayload } from '@/api/adminApi';
|
||||
import { clientApi, type ManagedUser } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { readSession } from '@/api/session';
|
||||
import { Button, Input, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import './ClientUsersPage.css';
|
||||
|
||||
@@ -42,19 +41,19 @@ const emptyFilters: UserFilters = {
|
||||
};
|
||||
|
||||
function toForm(user?: ManagedUser): UserForm {
|
||||
return user ? {
|
||||
displayName: user.displayName,
|
||||
username: user.username,
|
||||
email: user.email ?? '',
|
||||
phone: user.phone ?? '',
|
||||
status: user.status,
|
||||
password: '',
|
||||
} : emptyForm;
|
||||
return user
|
||||
? {
|
||||
displayName: user.displayName,
|
||||
username: user.username,
|
||||
email: user.email ?? '',
|
||||
phone: user.phone ?? '',
|
||||
status: user.status,
|
||||
password: '',
|
||||
}
|
||||
: emptyForm;
|
||||
}
|
||||
|
||||
export function ClientUsersPage() {
|
||||
const session = readSession('client');
|
||||
const tenantId = session?.user.tenantId ?? undefined;
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [filters, setFilters] = useState<UserFilters>(emptyFilters);
|
||||
const [appliedFilters, setAppliedFilters] = useState<UserFilters>(emptyFilters);
|
||||
@@ -72,16 +71,15 @@ export function ClientUsersPage() {
|
||||
const [querying, setQuerying] = useState(false);
|
||||
|
||||
async function loadUsers(query: UserFilters = appliedFilters) {
|
||||
if (!tenantId) return;
|
||||
setUsers(await clientApi.listUsers(query, tenantId));
|
||||
setUsers(await clientApi.listUsers(query));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenantId) return;
|
||||
void clientApi.listUsers({}, tenantId)
|
||||
void clientApi
|
||||
.listUsers({})
|
||||
.then(setUsers)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : '加载用户失败'));
|
||||
}, [tenantId]);
|
||||
}, []);
|
||||
|
||||
function updateFilter<Key extends keyof UserFilters>(key: Key, value: UserFilters[Key]) {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
@@ -122,26 +120,28 @@ export function ClientUsersPage() {
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
if (!form.displayName.trim() || (!form.email.trim() && !form.phone.trim()) || (creating && form.password.length < 6)) {
|
||||
if (
|
||||
!form.displayName.trim() ||
|
||||
(!form.email.trim() && !form.phone.trim()) ||
|
||||
(creating && form.password.length < 6)
|
||||
) {
|
||||
setFormError('请填写姓名、邮箱或手机号;新增用户密码至少 6 位');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setFormError('');
|
||||
const body: UserPayload = {
|
||||
const body = {
|
||||
displayName: form.displayName,
|
||||
username: form.username || form.email || form.phone,
|
||||
email: form.email,
|
||||
phone: form.phone,
|
||||
status: form.status,
|
||||
roleCode: 'enterprise_admin',
|
||||
operatorId: session?.user.id,
|
||||
};
|
||||
try {
|
||||
if (creating) {
|
||||
await clientApi.createUser({ ...body, password: form.password }, tenantId);
|
||||
await clientApi.createUser({ ...body, password: form.password });
|
||||
} else if (editingUser) {
|
||||
await clientApi.updateUser(editingUser.id, body, tenantId);
|
||||
await clientApi.updateUser(editingUser.id, body);
|
||||
}
|
||||
setCreating(false);
|
||||
setEditingUser(null);
|
||||
@@ -159,9 +159,12 @@ export function ClientUsersPage() {
|
||||
setConfirmError('');
|
||||
try {
|
||||
if (confirmAction.type === 'delete') {
|
||||
await clientApi.deleteUser(confirmAction.user.id, session?.user.id, tenantId);
|
||||
await clientApi.deleteUser(confirmAction.user.id);
|
||||
} else {
|
||||
await clientApi.changeUserStatus(confirmAction.user.id, confirmAction.user.status === 'active' ? 'disabled' : 'active', session?.user.id, tenantId);
|
||||
await clientApi.changeUserStatus(
|
||||
confirmAction.user.id,
|
||||
confirmAction.user.status === 'active' ? 'disabled' : 'active',
|
||||
);
|
||||
}
|
||||
} catch (failure) {
|
||||
const detail = failure instanceof Error ? failure.message : '用户操作失败';
|
||||
@@ -181,55 +184,136 @@ export function ClientUsersPage() {
|
||||
|
||||
async function savePassword() {
|
||||
if (!passwordUser) return;
|
||||
await clientApi.changeUserPassword(passwordUser.id, newPassword, session?.user.id, tenantId);
|
||||
await clientApi.changeUserPassword(passwordUser.id, newPassword);
|
||||
setPasswordUser(null);
|
||||
setNewPassword('');
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<TableColumn<ManagedUser>>>(() => [
|
||||
{ key: 'name', title: '用户名', width: '140px', render: (record) => <strong className="text-strong">{record.displayName}</strong> },
|
||||
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email ?? '-'}</span> },
|
||||
{ key: 'phone', title: '手机号', width: '160px', render: (record) => <span className="muted">{record.phone ?? '-'}</span> },
|
||||
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info">企业管理员</Tag> },
|
||||
{ key: 'status', title: '状态', width: '120px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '正常' : '禁用'}</Tag> },
|
||||
{ key: 'lastLoginAt', title: '最后登录时间', width: '190px', render: (record) => <span className="muted">{formatDateTime(record.lastLoginAt)}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '290px',
|
||||
render: (record) => (
|
||||
<div className="inline-actions client-user-actions" aria-label={`${record.displayName}的用户操作`}>
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<KeyRound size={15} />} onClick={() => { setPasswordUser(record); setNewPassword(''); }} size="sm" variant="ghost">改密</Button>
|
||||
<Button onClick={() => openConfirm({ type: 'status', user: record })} size="sm" variant={record.status === 'active' ? 'warning' : 'success'}>{record.status === 'active' ? '禁用' : '启用'}</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => openConfirm({ type: 'delete', user: record })} size="sm" variant="danger">删除</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
const columns = useMemo<Array<TableColumn<ManagedUser>>>(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
title: '用户名',
|
||||
width: '140px',
|
||||
render: (record) => <strong className="text-strong">{record.displayName}</strong>,
|
||||
},
|
||||
{ key: 'email', title: '邮箱', render: (record) => <span className="muted">{record.email ?? '-'}</span> },
|
||||
{
|
||||
key: 'phone',
|
||||
title: '手机号',
|
||||
width: '160px',
|
||||
render: (record) => <span className="muted">{record.phone ?? '-'}</span>,
|
||||
},
|
||||
{ key: 'role', title: '角色', width: '130px', render: () => <Tag tone="info">企业管理员</Tag> },
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
width: '120px',
|
||||
render: (record) => (
|
||||
<Tag tone={record.status === 'active' ? 'success' : 'neutral'}>
|
||||
{record.status === 'active' ? '正常' : '禁用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastLoginAt',
|
||||
title: '最后登录时间',
|
||||
width: '190px',
|
||||
render: (record) => <span className="muted">{formatDateTime(record.lastLoginAt)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
title: '操作',
|
||||
width: '290px',
|
||||
render: (record) => (
|
||||
<div className="inline-actions client-user-actions" aria-label={`${record.displayName}的用户操作`}>
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => openEditor(record)} size="sm" variant="ghost">
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
icon={<KeyRound size={15} />}
|
||||
onClick={() => {
|
||||
setPasswordUser(record);
|
||||
setNewPassword('');
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
改密
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openConfirm({ type: 'status', user: record })}
|
||||
size="sm"
|
||||
variant={record.status === 'active' ? 'warning' : 'success'}
|
||||
>
|
||||
{record.status === 'active' ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Trash2 size={15} />}
|
||||
onClick={() => openConfirm({ type: 'delete', user: record })}
|
||||
size="sm"
|
||||
variant="danger"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="page-stack system-page">
|
||||
<div className="system-page-toolbar">
|
||||
<div className="sms-send-title">
|
||||
<span className="sms-send-title__icon"><Users size={22} /></span>
|
||||
<span className="sms-send-title__icon">
|
||||
<Users size={22} />
|
||||
</span>
|
||||
<h1>用户管理</h1>
|
||||
</div>
|
||||
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm">添加用户</Button>
|
||||
<Button icon={<Plus size={16} />} onClick={() => openEditor()} size="sm">
|
||||
添加用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="surface system-filter-row client-user-filter">
|
||||
<Input label="用户姓名" onChange={(event) => updateFilter('displayName', event.target.value)} placeholder="请输入用户姓名" value={filters.displayName} />
|
||||
<Input label="登录账号" onChange={(event) => updateFilter('login', event.target.value)} placeholder="用户名、邮箱或手机号" value={filters.login} />
|
||||
<Input
|
||||
label="用户姓名"
|
||||
onChange={(event) => updateFilter('displayName', event.target.value)}
|
||||
placeholder="请输入用户姓名"
|
||||
value={filters.displayName}
|
||||
/>
|
||||
<Input
|
||||
label="登录账号"
|
||||
onChange={(event) => updateFilter('login', event.target.value)}
|
||||
placeholder="用户名、邮箱或手机号"
|
||||
value={filters.login}
|
||||
/>
|
||||
<Select
|
||||
label="状态"
|
||||
onChange={(event) => updateFilter('status', event.target.value)}
|
||||
options={[{ label: '全部状态', value: '' }, { label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]}
|
||||
options={[
|
||||
{ label: '全部状态', value: '' },
|
||||
{ label: '正常', value: 'active' },
|
||||
{ label: '禁用', value: 'disabled' },
|
||||
]}
|
||||
value={filters.status}
|
||||
/>
|
||||
<div className="client-user-filter__actions">
|
||||
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>{querying ? '查询中...' : '查询'}</Button>
|
||||
<Button disabled={querying} onClick={() => { setFilters(emptyFilters); void queryUsers(emptyFilters); }} variant="secondary">重置</Button>
|
||||
<Button disabled={querying} icon={<Search size={16} />} onClick={() => void queryUsers()}>
|
||||
{querying ? '查询中...' : '查询'}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={querying}
|
||||
onClick={() => {
|
||||
setFilters(emptyFilters);
|
||||
void queryUsers(emptyFilters);
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <div className="surface empty-state">{error}</div> : null}
|
||||
@@ -237,38 +321,141 @@ export function ClientUsersPage() {
|
||||
<Table columns={columns} data={users} emptyText="暂无用户" rowKey="id" />
|
||||
</div>
|
||||
|
||||
{(creating || editingUser) ? (
|
||||
{creating || editingUser ? (
|
||||
<Modal
|
||||
footer={<><Button disabled={saving} onClick={() => { setCreating(false); setEditingUser(null); }} variant="secondary">取消</Button><Button disabled={saving} onClick={() => void saveUser()}>{saving ? '保存中...' : '保存'}</Button></>}
|
||||
onClose={() => { setCreating(false); setEditingUser(null); }}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setCreating(false);
|
||||
setEditingUser(null);
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={saving} onClick={() => void saveUser()}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => {
|
||||
setCreating(false);
|
||||
setEditingUser(null);
|
||||
}}
|
||||
open
|
||||
size="xl"
|
||||
title={creating ? '添加用户' : '编辑用户'}
|
||||
>
|
||||
<div className="system-user-form">
|
||||
<Input label="用户名 *" onChange={(event) => updateField('displayName', event.target.value)} placeholder="请输入用户名" value={form.displayName} />
|
||||
<Input label="邮箱 *" onChange={(event) => updateField('email', event.target.value)} placeholder="请输入邮箱" value={form.email} />
|
||||
<Input label="手机号 *" onChange={(event) => updateField('phone', event.target.value)} placeholder="请输入手机号" value={form.phone} />
|
||||
<Input hint="可用用户名、邮箱或手机号登录" label="用户名/登录账号" onChange={(event) => updateField('username', event.target.value)} value={form.username} />
|
||||
{creating ? <Input label="初始密码 *" onChange={(event) => updateField('password', event.target.value)} type="password" value={form.password} /> : null}
|
||||
<Select label="状态 *" onChange={(event) => updateField('status', event.target.value)} options={[{ label: '正常', value: 'active' }, { label: '禁用', value: 'disabled' }]} value={form.status} />
|
||||
{formError ? <p className="form-error" role="alert">{formError}</p> : null}
|
||||
<Input
|
||||
label="用户名 *"
|
||||
onChange={(event) => updateField('displayName', event.target.value)}
|
||||
placeholder="请输入用户名"
|
||||
value={form.displayName}
|
||||
/>
|
||||
<Input
|
||||
label="邮箱 *"
|
||||
onChange={(event) => updateField('email', event.target.value)}
|
||||
placeholder="请输入邮箱"
|
||||
value={form.email}
|
||||
/>
|
||||
<Input
|
||||
label="手机号 *"
|
||||
onChange={(event) => updateField('phone', event.target.value)}
|
||||
placeholder="请输入手机号"
|
||||
value={form.phone}
|
||||
/>
|
||||
<Input
|
||||
hint="可用用户名、邮箱或手机号登录"
|
||||
label="用户名/登录账号"
|
||||
onChange={(event) => updateField('username', event.target.value)}
|
||||
value={form.username}
|
||||
/>
|
||||
{creating ? (
|
||||
<Input
|
||||
label="初始密码 *"
|
||||
onChange={(event) => updateField('password', event.target.value)}
|
||||
type="password"
|
||||
value={form.password}
|
||||
/>
|
||||
) : null}
|
||||
<Select
|
||||
label="状态 *"
|
||||
onChange={(event) => updateField('status', event.target.value)}
|
||||
options={[
|
||||
{ label: '正常', value: 'active' },
|
||||
{ label: '禁用', value: 'disabled' },
|
||||
]}
|
||||
value={form.status}
|
||||
/>
|
||||
{formError ? (
|
||||
<p className="form-error" role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{passwordUser ? (
|
||||
<Modal footer={<><Button onClick={() => setPasswordUser(null)} variant="secondary">取消</Button><Button onClick={() => void savePassword()}>保存</Button></>} onClose={() => setPasswordUser(null)} open title="修改密码">
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setPasswordUser(null)} variant="secondary">
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => void savePassword()}>保存</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setPasswordUser(null)}
|
||||
open
|
||||
title="修改密码"
|
||||
>
|
||||
<div className="system-user-form">
|
||||
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} type="password" value={newPassword} />
|
||||
<Input
|
||||
label="新密码"
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
type="password"
|
||||
value={newPassword}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
|
||||
{confirmAction ? (
|
||||
<Modal footer={<><Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="secondary">取消</Button><Button disabled={confirming} onClick={() => void runConfirm()} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>{confirming ? '处理中...' : '确认'}</Button></>} onClose={() => { if (!confirming) setConfirmAction(null); }} open title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}>
|
||||
<p>{confirmAction.type === 'delete' ? `确认删除用户 ${confirmAction.user.displayName}?` : `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}</p>
|
||||
{confirmError ? <p className="form-error" role="alert">{confirmError}</p> : null}
|
||||
<Modal
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={confirming} onClick={() => setConfirmAction(null)} variant="secondary">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={confirming}
|
||||
onClick={() => void runConfirm()}
|
||||
variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}
|
||||
>
|
||||
{confirming ? '处理中...' : '确认'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => {
|
||||
if (!confirming) setConfirmAction(null);
|
||||
}}
|
||||
open
|
||||
title={confirmAction.type === 'delete' ? '删除用户' : '变更用户状态'}
|
||||
>
|
||||
<p>
|
||||
{confirmAction.type === 'delete'
|
||||
? `确认删除用户 ${confirmAction.user.displayName}?`
|
||||
: `确认${confirmAction.user.status === 'active' ? '禁用' : '启用'}用户 ${confirmAction.user.displayName}?`}
|
||||
</p>
|
||||
{confirmError ? (
|
||||
<p className="form-error" role="alert">
|
||||
{confirmError}
|
||||
</p>
|
||||
) : null}
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RouteLoadBoundary } from './RouteLoadBoundary';
|
||||
|
||||
function BrokenRoute(): never {
|
||||
throw new Error('chunk load failed');
|
||||
}
|
||||
|
||||
describe('RouteLoadBoundary', () => {
|
||||
it('shows a recoverable Chinese error state when a route chunk throws', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
render(
|
||||
<RouteLoadBoundary>
|
||||
<BrokenRoute />
|
||||
</RouteLoadBoundary>,
|
||||
);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('页面资源加载失败');
|
||||
expect(screen.getByRole('button', { name: '重新加载' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders healthy route content unchanged', () => {
|
||||
render(
|
||||
<RouteLoadBoundary>
|
||||
<p>正常页面</p>
|
||||
</RouteLoadBoundary>,
|
||||
);
|
||||
expect(screen.getByText('正常页面')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
Reference in New Issue
Block a user