feat: harden platform workflows and UI governance
This commit is contained in:
+222
-62
@@ -1,11 +1,41 @@
|
||||
import { clearSession, dispatchSessionEvent, getSessionTenantId, hasRecentUserActivity, readSession, requestReauthentication, type LoginSession } from './session';
|
||||
import {
|
||||
clearSession,
|
||||
currentRouteForPortal,
|
||||
dispatchSessionEvent,
|
||||
getSessionTenantId,
|
||||
hasRecentUserActivity,
|
||||
portalFromPath,
|
||||
readSession,
|
||||
redirectToPortalLogin,
|
||||
requestReauthentication,
|
||||
saveSessionRecovery,
|
||||
type LoginSession,
|
||||
type Portal,
|
||||
} from './session';
|
||||
import { assertUploadFileSize } from '@/utils/fileUpload';
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
tenantId?: string;
|
||||
reauthenticationAttempted?: boolean;
|
||||
suppressSessionRedirect?: boolean;
|
||||
};
|
||||
|
||||
export type DeletionTargetType = 'channel' | 'signature' | 'template';
|
||||
export type DeletionDependency = { kind: string; label: string; count: number; items: string[] };
|
||||
export type DeletionPreflight = {
|
||||
type: DeletionTargetType;
|
||||
id: string;
|
||||
expectedUpdatedAt: string;
|
||||
identity: Record<string, string>;
|
||||
dependencies: DeletionDependency[];
|
||||
impacts: string[];
|
||||
blockedReasons: string[];
|
||||
allowedActions: Array<'delete'>;
|
||||
recoverability: { mode: 'soft_delete'; description: string };
|
||||
};
|
||||
export type DeleteTargetRequest = { expectedUpdatedAt: string; idempotencyKey: string; reason: string };
|
||||
export type DeletionResult = { operationId: string; status: 'deleted'; replayed: boolean };
|
||||
|
||||
type ApiErrorBody = { message?: string | string[]; error?: string; code?: string };
|
||||
|
||||
async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
||||
@@ -20,6 +50,35 @@ async function readErrorBody(response: Response): Promise<ApiErrorBody> {
|
||||
|
||||
export const DEFAULT_CLIENT_TENANT_ID = 'tenant-a';
|
||||
|
||||
type SessionTiming = Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>;
|
||||
|
||||
function requestPortal(path: string): Portal | undefined {
|
||||
return portalFromPath(path);
|
||||
}
|
||||
|
||||
async function handleSessionFailure(response: Response, portal: Portal | undefined, suppressRedirect = false) {
|
||||
if (!portal) return false;
|
||||
const session = readSession(portal);
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED' && session) {
|
||||
dispatchSessionEvent(portal, 'locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
if (suppressRedirect) return false;
|
||||
|
||||
if (session) {
|
||||
saveSessionRecovery(portal, {
|
||||
returnUrl: currentRouteForPortal(portal),
|
||||
code: body.code,
|
||||
message: typeof body.message === 'string' ? body.message : '登录会话已失效,请重新登录',
|
||||
});
|
||||
}
|
||||
clearSession(portal);
|
||||
dispatchSessionEvent(portal, 'logout', { code: body.code, message: body.message });
|
||||
redirectToPortalLogin(portal);
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
const fallback = `请求失败(${response.status})`;
|
||||
const text = await response.text();
|
||||
@@ -40,7 +99,8 @@ async function readErrorMessage(response: Response) {
|
||||
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('Content-Type', 'application/json');
|
||||
const session = readSession();
|
||||
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) {
|
||||
@@ -48,16 +108,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
const isLoginAttempt = path === '/admin/auth/login' || path === '/client/auth/login';
|
||||
if (response.status === 401 && session && !isLoginAttempt) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
if (response.status === 401 && !isLoginAttempt) {
|
||||
await handleSessionFailure(response, portal, options.suppressSessionRedirect);
|
||||
}
|
||||
if (response.status === 403 && session && !options.reauthenticationAttempted) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
@@ -74,23 +126,16 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
||||
|
||||
async function requestBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||
const headers = new Headers(options.headers);
|
||||
const session = readSession();
|
||||
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);
|
||||
}
|
||||
const response = await fetch(`/api${path}`, { ...options, headers, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
if (response.status === 401) {
|
||||
await handleSessionFailure(response, portal, options.suppressSessionRedirect);
|
||||
}
|
||||
if (response.status === 403 && session && !options.reauthenticationAttempted) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
@@ -107,18 +152,12 @@ async function requestBlob(path: string, options: RequestOptions = {}): Promise<
|
||||
|
||||
async function requestForm<T>(path: string, form: FormData, reauthenticationAttempted = false): Promise<T> {
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
const portal = requestPortal(path);
|
||||
const session = portal ? readSession(portal) : null;
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
const response = await fetch(`/api${path}`, { method: 'POST', headers, body: form, credentials: 'same-origin' });
|
||||
if (response.status === 401 && session) {
|
||||
const body = await readErrorBody(response.clone());
|
||||
if (body.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: body.message });
|
||||
throw new Error(typeof body.message === 'string' ? body.message : '由于长时间未操作,会话已安全锁定');
|
||||
}
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: body.code, message: body.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
if (response.status === 401) {
|
||||
await handleSessionFailure(response, portal);
|
||||
throw new Error('登录会话已失效,请重新登录');
|
||||
}
|
||||
if (response.status === 403 && session && !reauthenticationAttempted) {
|
||||
@@ -208,6 +247,27 @@ export type SmsTemplateAudit = {
|
||||
tenant?: { name: string };
|
||||
};
|
||||
|
||||
export type ReviewPreflight = {
|
||||
type: 'signature' | 'template';
|
||||
id: string;
|
||||
tenantId: string;
|
||||
status: string;
|
||||
expectedUpdatedAt: string;
|
||||
identity: Record<string, string>;
|
||||
impacts: string[];
|
||||
materialSummary: Record<string, string | number>;
|
||||
blockedReasons: string[];
|
||||
allowedActions: Array<'approve' | 'reject'>;
|
||||
};
|
||||
|
||||
export type ReviewDecisionResult = {
|
||||
operationId: string;
|
||||
replayed: boolean;
|
||||
decision: 'approve' | 'reject';
|
||||
status: string;
|
||||
item: ClientSmsSignature | SmsTemplateAudit;
|
||||
};
|
||||
|
||||
export type TenantOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -305,6 +365,25 @@ export type RechargeOrder = {
|
||||
tenant?: TenantOption;
|
||||
};
|
||||
|
||||
export type ManualRechargePreflight = {
|
||||
tenant: Pick<TenantOption, 'id' | 'name' | 'code'>;
|
||||
accountId: string;
|
||||
expectedAccountUpdatedAt: string;
|
||||
balanceCents: number;
|
||||
creditCents: number;
|
||||
amountCents: number;
|
||||
balanceAfterCents: number;
|
||||
direction: 'topup' | 'correction';
|
||||
allowedActions: Array<'confirm'>;
|
||||
blockedReasons: string[];
|
||||
};
|
||||
|
||||
export type ManualRechargeResult = RechargeOrder & {
|
||||
balanceAfterCents: number;
|
||||
operationId: string;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
export type ClientSmsApplication = {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
@@ -683,6 +762,50 @@ export type ReportMaterialPendingItem = {
|
||||
application?: ClientSmsApplication | null;
|
||||
};
|
||||
|
||||
export type ReportMaterialPreflightTarget = {
|
||||
id: string;
|
||||
name: string;
|
||||
carrier: string;
|
||||
businessKey: string;
|
||||
eligible: boolean;
|
||||
blockedReasons: string[];
|
||||
duplicateBatchId?: string;
|
||||
};
|
||||
|
||||
export type ReportMaterialPreflightItem = {
|
||||
id: string;
|
||||
reportType: 'signature' | 'drainage';
|
||||
signatureId: string;
|
||||
drainageItemId?: string;
|
||||
materialVersion: number;
|
||||
name: string;
|
||||
tenantName: string;
|
||||
applicationId?: string;
|
||||
applicationName: string;
|
||||
eligible: boolean;
|
||||
blockedReasons: string[];
|
||||
targets: ReportMaterialPreflightTarget[];
|
||||
};
|
||||
|
||||
export type ReportMaterialBatchPreflight = {
|
||||
checkedAt: string;
|
||||
eligible: boolean;
|
||||
eligibleItemCount: number;
|
||||
blockedItemCount: number;
|
||||
eligibleTargetCount: number;
|
||||
skippedTargetCount: number;
|
||||
items: ReportMaterialPreflightItem[];
|
||||
};
|
||||
|
||||
export type ReportMaterialBatchResult = Record<string, unknown> & {
|
||||
id: string;
|
||||
batchNo: string;
|
||||
status: string;
|
||||
operationId: string;
|
||||
replayed: boolean;
|
||||
result: { successCount: number; skippedCount: number; failedCount: number; items: ReportMaterialPreflightItem[] };
|
||||
};
|
||||
|
||||
export type ReportImportMapping = {
|
||||
sourceHeader: string;
|
||||
sourceHeaderPath?: string;
|
||||
@@ -771,8 +894,8 @@ export type FileRef = {
|
||||
contentType?: string;
|
||||
};
|
||||
|
||||
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment') {
|
||||
return `/api/admin/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
|
||||
export function fileDownloadUrl(fileObjectId: string, disposition: 'attachment' | 'inline' = 'attachment', portal: Portal = 'admin') {
|
||||
return `/api/${portal}/files/${encodeURIComponent(fileObjectId)}/download?disposition=${disposition}`;
|
||||
}
|
||||
|
||||
export type RiskReviewTask = {
|
||||
@@ -810,6 +933,7 @@ export type TenantAccount = {
|
||||
balanceCents: number;
|
||||
creditCents: number;
|
||||
status: string;
|
||||
updatedAt?: string;
|
||||
tenant?: TenantOption;
|
||||
};
|
||||
|
||||
@@ -835,6 +959,16 @@ export type OperationLogResponse = {
|
||||
modules: string[];
|
||||
};
|
||||
|
||||
export type SystemLogExportResult = {
|
||||
operationId: string;
|
||||
status: 'completed';
|
||||
fileName: string;
|
||||
recordCount: number;
|
||||
truncated: boolean;
|
||||
content: string;
|
||||
filters: { keyword?: string; level?: string; module?: string; range?: string };
|
||||
};
|
||||
|
||||
export type PagedResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
@@ -1180,17 +1314,28 @@ function withQuery(path: string, query: Record<string, string | number | undefin
|
||||
return `${path}${suffix}`;
|
||||
}
|
||||
|
||||
export const portalSessionApi = {
|
||||
current: (portal: Portal) => request<LoginSession>(`/${portal}/auth/session`, { suppressSessionRedirect: true }),
|
||||
touch: (portal: Portal) => request<SessionTiming>(`/${portal}/auth/session/touch`, { method: 'POST', body: '{}' }),
|
||||
lock: (portal: Portal) => request<{ locked: boolean }>(`/${portal}/auth/session/lock`, { method: 'POST', body: '{}' }),
|
||||
unlock: (portal: Portal, password: string) => request<SessionTiming>(`/${portal}/auth/session/unlock`, { method: 'POST', body: JSON.stringify({ password }) }),
|
||||
reauthenticate: (portal: Portal, password: string) => request<Pick<LoginSession, 'recentAuthenticationExpiresAt'>>(`/${portal}/auth/reauthenticate`, { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }),
|
||||
logout: (portal: Portal) => request<{ success: boolean }>(`/${portal}/auth/logout`, { method: 'POST', body: '{}' }),
|
||||
changeOwnPassword: (portal: Portal, body: { currentPassword: string; password: string }) =>
|
||||
request<ManagedUser>(`/${portal}/auth/password`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
};
|
||||
|
||||
export const adminApi = {
|
||||
getCaptcha: () => request<CaptchaResponse>('/admin/auth/captcha'),
|
||||
login: (body: { login: string; password: string; captchaId: string; captchaText: string }) =>
|
||||
request<LoginSession>('/admin/auth/login', { method: 'POST', body: JSON.stringify(body) }),
|
||||
touchSession: () => request<Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>>('/auth/session/touch', { method: 'POST', body: '{}' }),
|
||||
lockSession: () => request<{ locked: boolean }>('/auth/session/lock', { method: 'POST', body: '{}' }),
|
||||
unlockSession: (password: string) => request<Pick<LoginSession, 'idleTimeoutSeconds' | 'lockRecoverySeconds' | 'absoluteExpiresAt' | 'lastActivityAt' | 'recentAuthenticationExpiresAt'>>('/auth/session/unlock', { method: 'POST', body: JSON.stringify({ password }) }),
|
||||
reauthenticate: (password: string) => request<Pick<LoginSession, 'recentAuthenticationExpiresAt'>>('/auth/reauthenticate', { method: 'POST', body: JSON.stringify({ password }), reauthenticationAttempted: true }),
|
||||
logout: () => request<{ success: boolean }>('/auth/logout', { method: 'POST', body: '{}' }),
|
||||
touchSession: () => portalSessionApi.touch('admin'),
|
||||
lockSession: () => portalSessionApi.lock('admin'),
|
||||
unlockSession: (password: string) => portalSessionApi.unlock('admin', password),
|
||||
reauthenticate: (password: string) => portalSessionApi.reauthenticate('admin', password),
|
||||
logout: () => portalSessionApi.logout('admin'),
|
||||
changeOwnPassword: (body: { currentPassword: string; password: string }) =>
|
||||
request<ManagedUser>('/auth/password', { method: 'POST', body: JSON.stringify(body) }),
|
||||
portalSessionApi.changeOwnPassword('admin', body),
|
||||
listTenants: () => request<TenantOption[]>('/admin/tenants'),
|
||||
listTenantManagementRows: () => request<TenantManagementRow[]>('/admin/tenants/management-list'),
|
||||
getTenant: (id: string) => request<TenantOption>(`/admin/tenants/${id}`),
|
||||
@@ -1212,12 +1357,16 @@ export const adminApi = {
|
||||
getDashboard: (tenantId?: string) => request<DashboardResponse>(withQuery('/admin/operations/dashboard/statistics', { tenantId })),
|
||||
listSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string; page?: number; pageSize?: number }) =>
|
||||
request<OperationLogResponse>(withQuery('/admin/system-logs', query)),
|
||||
exportSystemLogs: (query: { tenantId?: string; keyword?: string; level?: string; module?: string; range?: string }) =>
|
||||
request<SystemLogExportResult>('/admin/system-logs/exports', { method: 'POST', body: JSON.stringify(query) }),
|
||||
listAccounts: () => request<TenantAccount[]>('/admin/billing/accounts'),
|
||||
updateCreditLimit: (tenantId: string, body: { creditCents: number; operatorId?: string; remark?: string }) =>
|
||||
request<TenantAccount>(`/admin/billing/accounts/${tenantId}/credit-limit`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
|
||||
createManualRecharge: (body: { tenantId: string; amountCents: number; operatorId?: string; remark?: string }) =>
|
||||
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||
preflightManualRecharge: (body: { tenantId: string; amountCents: number }) =>
|
||||
request<ManualRechargePreflight>('/admin/billing/manual-recharges/preflight', { method: 'POST', body: JSON.stringify(body) }),
|
||||
createManualRecharge: (body: { tenantId: string; amountCents: number; expectedAccountUpdatedAt: string; idempotencyKey: string; remark?: string }) =>
|
||||
request<ManualRechargeResult>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) =>
|
||||
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
|
||||
getEnterpriseApplication: (id: string) =>
|
||||
@@ -1272,6 +1421,10 @@ export const adminApi = {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
getDeletionPreflight: (type: DeletionTargetType, id: string) =>
|
||||
request<DeletionPreflight>(`/admin/deletions/${type}/${id}/preflight`),
|
||||
deleteGovernedTarget: (type: DeletionTargetType, id: string, body: DeleteTargetRequest) =>
|
||||
request<DeletionResult>(`/admin/deletions/${type}/${id}`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listChannelConnectionLogs: (id: string) => request<ChannelConnectionLogResponse>(`/admin/channels/${id}/connection-logs`),
|
||||
listTemplateAudits: (query: { keyword?: string; status?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -1290,6 +1443,10 @@ export const adminApi = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason }),
|
||||
}),
|
||||
getReviewPreflight: (type: 'signature' | 'template', id: string) =>
|
||||
request<ReviewPreflight>(`/admin/reviews/${type}/${id}/preflight`),
|
||||
submitReviewDecision: (type: 'signature' | 'template', id: string, body: { decision: 'approve' | 'reject'; expectedUpdatedAt: string; idempotencyKey: string; reason?: string }) =>
|
||||
request<ReviewDecisionResult>(`/admin/reviews/${type}/${id}/decision`, { method: 'POST', body: JSON.stringify(body) }),
|
||||
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) =>
|
||||
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
|
||||
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
|
||||
@@ -1370,8 +1527,10 @@ export const adminApi = {
|
||||
commitReportMaterialImport: (id: string, body: { mappings: ReportImportMapping[]; profile?: Omit<ReportImportProfile, 'id'> & { id?: string } }) =>
|
||||
request<Record<string, unknown>>(`/admin/report-materials/imports/${id}/commit`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
listReportMaterialBatches: () => request<Array<Record<string, unknown>>>('/admin/report-materials/batches'),
|
||||
createReportMaterialBatch: (body: { createdById?: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string }> }) =>
|
||||
request<Record<string, unknown>>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
|
||||
preflightReportMaterialBatch: (body: { items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion?: number }> }) =>
|
||||
request<ReportMaterialBatchPreflight>('/admin/report-materials/batches/preflight', { method: 'POST', body: JSON.stringify(body) }),
|
||||
createReportMaterialBatch: (body: { idempotencyKey: string; items: Array<{ reportType: 'signature' | 'drainage'; signatureId: string; drainageItemId?: string; materialVersion: number }> }) =>
|
||||
request<ReportMaterialBatchResult>('/admin/report-materials/batches', { method: 'POST', body: JSON.stringify(body) }),
|
||||
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
|
||||
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
|
||||
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -1462,7 +1621,7 @@ export const adminApi = {
|
||||
form.set('prefix', body.prefix);
|
||||
}
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
const session = readSession('admin');
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
@@ -1471,11 +1630,11 @@ export const adminApi = {
|
||||
if (response.status === 401 && session) {
|
||||
const error = await readErrorBody(response.clone());
|
||||
if (error.code === 'SESSION_LOCKED') {
|
||||
dispatchSessionEvent('locked', { message: error.message });
|
||||
dispatchSessionEvent('admin', 'locked', { message: error.message });
|
||||
} else {
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: error.code, message: error.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
clearSession('admin');
|
||||
dispatchSessionEvent('admin', 'logout', { code: error.code, message: error.message });
|
||||
redirectToPortalLogin('admin');
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
@@ -1513,6 +1672,8 @@ export const clientApi = {
|
||||
}),
|
||||
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 }),
|
||||
exportSystemLogs: (query: { keyword?: string; level?: string; module?: string; range?: 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 }),
|
||||
listApplications: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
@@ -1566,6 +1727,10 @@ export const clientApi = {
|
||||
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 }),
|
||||
cancelBatchTask: (id: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
|
||||
@@ -1582,9 +1747,7 @@ export const clientApi = {
|
||||
request<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 }),
|
||||
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) => {
|
||||
uploadFileObject: async (file: File, body: { purpose: string; prefix?: string }) => {
|
||||
assertUploadFileSize(file);
|
||||
const form = new FormData();
|
||||
form.set('file', file);
|
||||
@@ -1593,20 +1756,17 @@ export const clientApi = {
|
||||
form.set('prefix', body.prefix);
|
||||
}
|
||||
const headers = new Headers();
|
||||
const session = readSession();
|
||||
const session = readSession('client');
|
||||
if (session && hasRecentUserActivity()) headers.set('x-session-activity', 'user');
|
||||
if (tenantId) {
|
||||
headers.set('x-tenant-id', tenantId);
|
||||
}
|
||||
const response = await fetch('/api/admin/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') {
|
||||
dispatchSessionEvent('locked', { message: error.message });
|
||||
dispatchSessionEvent('client', 'locked', { message: error.message });
|
||||
} else {
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: error.code, message: error.message });
|
||||
window.location.assign(session.portal === 'admin' ? '/admin/login' : '/client/login');
|
||||
clearSession('client');
|
||||
dispatchSessionEvent('client', 'logout', { code: error.code, message: error.message });
|
||||
redirectToPortalLogin('client');
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
|
||||
+106
-14
@@ -19,32 +19,116 @@ export type LoginSession = {
|
||||
absoluteExpiresAt: string;
|
||||
lastActivityAt: string;
|
||||
recentAuthenticationExpiresAt: string;
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
const sessionKey = 'cmpp-auth-session';
|
||||
export type SessionRecovery = {
|
||||
returnUrl: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function readSession(): LoginSession | null {
|
||||
const legacySessionKey = 'cmpp-auth-session';
|
||||
const sessionKey = (portal: Portal) => `cmpp-auth-session:${portal}`;
|
||||
const recoveryKey = (portal: Portal) => `cmpp-session-recovery:${portal}`;
|
||||
const sessionEventName = (portal: Portal, type: 'locked' | 'unlocked' | 'logout') => `cmpp-session-${portal}-${type}`;
|
||||
|
||||
export function portalFromPath(path: string): Portal | undefined {
|
||||
if (/^\/?(?:api\/)?admin(?:\/|$)/.test(path)) return 'admin';
|
||||
if (/^\/?(?:api\/)?client(?:\/|$)/.test(path)) return 'client';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function readSession(portal: Portal): LoginSession | null {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(sessionKey);
|
||||
return raw ? JSON.parse(raw) as LoginSession : null;
|
||||
const raw = window.localStorage.getItem(sessionKey(portal));
|
||||
if (raw) return JSON.parse(raw) as LoginSession;
|
||||
|
||||
// One-time migration for sessions created before portal storage was isolated.
|
||||
const legacyRaw = window.localStorage.getItem(legacySessionKey);
|
||||
if (!legacyRaw) return null;
|
||||
const legacy = JSON.parse(legacyRaw) as LoginSession;
|
||||
if (legacy.portal !== portal) return null;
|
||||
window.localStorage.setItem(sessionKey(portal), legacyRaw);
|
||||
window.localStorage.removeItem(legacySessionKey);
|
||||
return legacy;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSession(session: LoginSession) {
|
||||
window.localStorage.setItem(sessionKey, JSON.stringify(session));
|
||||
window.localStorage.setItem(sessionKey(session.portal), JSON.stringify(session));
|
||||
window.localStorage.removeItem(legacySessionKey);
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
window.localStorage.removeItem(sessionKey);
|
||||
export function clearSession(portal: Portal) {
|
||||
window.localStorage.removeItem(sessionKey(portal));
|
||||
try {
|
||||
const legacyRaw = window.localStorage.getItem(legacySessionKey);
|
||||
if (legacyRaw && (JSON.parse(legacyRaw) as LoginSession).portal === portal) {
|
||||
window.localStorage.removeItem(legacySessionKey);
|
||||
}
|
||||
} catch {
|
||||
window.localStorage.removeItem(legacySessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateSessionTiming(timing: Partial<Omit<LoginSession, 'portal' | 'user'>>) {
|
||||
const current = readSession();
|
||||
export function updateSessionTiming(portal: Portal, timing: Partial<Omit<LoginSession, 'portal' | 'user'>>) {
|
||||
const current = readSession(portal);
|
||||
if (current) writeSession({ ...current, ...timing });
|
||||
}
|
||||
|
||||
export function safeReturnUrl(portal: Portal, value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized.startsWith(`/${portal}`) || normalized.startsWith(`/${portal}/login`)) return undefined;
|
||||
if (normalized.startsWith('//') || normalized.includes('\\')) return undefined;
|
||||
try {
|
||||
const parsed = new URL(normalized, window.location.origin);
|
||||
if (parsed.origin !== window.location.origin || !parsed.pathname.startsWith(`/${portal}`)) return undefined;
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSessionRecovery(portal: Portal, recovery: SessionRecovery) {
|
||||
const returnUrl = safeReturnUrl(portal, recovery.returnUrl);
|
||||
if (!returnUrl) return;
|
||||
window.sessionStorage.setItem(recoveryKey(portal), JSON.stringify({ ...recovery, returnUrl }));
|
||||
}
|
||||
|
||||
export function readSessionRecovery(portal: Portal): SessionRecovery | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(recoveryKey(portal));
|
||||
if (!raw) return null;
|
||||
const recovery = JSON.parse(raw) as SessionRecovery;
|
||||
const returnUrl = safeReturnUrl(portal, recovery.returnUrl);
|
||||
return returnUrl ? { ...recovery, returnUrl } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeSessionRecovery(portal: Portal) {
|
||||
const recovery = readSessionRecovery(portal);
|
||||
window.sessionStorage.removeItem(recoveryKey(portal));
|
||||
return recovery;
|
||||
}
|
||||
|
||||
export function clearSessionRecovery(portal: Portal) {
|
||||
window.sessionStorage.removeItem(recoveryKey(portal));
|
||||
}
|
||||
|
||||
export function currentRouteForPortal(portal: Portal) {
|
||||
const hashRoute = window.location.hash.startsWith('#/') ? window.location.hash.slice(1) : '';
|
||||
return safeReturnUrl(portal, hashRoute) ?? safeReturnUrl(portal, `${window.location.pathname}${window.location.search}${window.location.hash}`) ?? `/${portal}`;
|
||||
}
|
||||
|
||||
export function redirectToPortalLogin(portal: Portal) {
|
||||
window.location.assign(`${window.location.origin}/#/${portal}/login`);
|
||||
}
|
||||
|
||||
let lastUserActivityAt = Date.now();
|
||||
let reauthenticationHandler: (() => Promise<void>) | undefined;
|
||||
|
||||
@@ -69,10 +153,10 @@ export function requestReauthentication() {
|
||||
return reauthenticationHandler();
|
||||
}
|
||||
|
||||
export function dispatchSessionEvent(type: 'locked' | 'unlocked' | 'logout', detail?: Record<string, unknown>) {
|
||||
window.dispatchEvent(new CustomEvent(`cmpp-session-${type}`, { detail }));
|
||||
export function dispatchSessionEvent(portal: Portal, type: 'locked' | 'unlocked' | 'logout', detail?: Record<string, unknown>) {
|
||||
window.dispatchEvent(new CustomEvent(sessionEventName(portal, type), { detail }));
|
||||
try {
|
||||
const channel = new BroadcastChannel('cmpp-session');
|
||||
const channel = new BroadcastChannel(`cmpp-session:${portal}`);
|
||||
channel.postMessage({ type, detail });
|
||||
channel.close();
|
||||
} catch {
|
||||
@@ -80,6 +164,14 @@ export function dispatchSessionEvent(type: 'locked' | 'unlocked' | 'logout', det
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionTenantId() {
|
||||
return readSession()?.user.tenantId ?? undefined;
|
||||
export function sessionEvent(portal: Portal, type: 'locked' | 'unlocked' | 'logout') {
|
||||
return sessionEventName(portal, type);
|
||||
}
|
||||
|
||||
export function sessionChannel(portal: Portal) {
|
||||
return `cmpp-session:${portal}`;
|
||||
}
|
||||
|
||||
export function getSessionTenantId() {
|
||||
return readSession('client')?.user.tenantId ?? undefined;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, clientApi, type CaptchaResponse } from '@/api/adminApi';
|
||||
import { writeSession, type Portal } from '@/api/session';
|
||||
import { consumeSessionRecovery, readSessionRecovery, writeSession, type Portal } from '@/api/session';
|
||||
import { Button, Input, Modal } from '@/components/ui';
|
||||
|
||||
type LoginPageProps = {
|
||||
@@ -31,6 +31,7 @@ export function LoginPage({ portal }: LoginPageProps) {
|
||||
const [alertMessage, setAlertMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isAdmin = portal === 'admin';
|
||||
const recovery = readSessionRecovery(portal);
|
||||
|
||||
async function refreshCaptcha(options: { clearError?: boolean } = {}) {
|
||||
if (options.clearError ?? true) {
|
||||
@@ -56,7 +57,8 @@ export function LoginPage({ portal }: LoginPageProps) {
|
||||
captchaText,
|
||||
});
|
||||
writeSession(session);
|
||||
navigate(isAdmin ? '/admin' : '/client', { replace: true });
|
||||
const target = consumeSessionRecovery(portal)?.returnUrl;
|
||||
navigate(target ?? (isAdmin ? '/admin' : '/client'), { replace: true });
|
||||
} catch (err) {
|
||||
const message = loginErrorMessage(err);
|
||||
setError(message);
|
||||
@@ -78,6 +80,11 @@ export function LoginPage({ portal }: LoginPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-form">
|
||||
{recovery ? (
|
||||
<p className="login-session-notice" role="status">
|
||||
{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} />
|
||||
<div className="login-captcha-row">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { CheckCircle2, Copy, Eye, ExternalLink, FileText, Info, Pencil, Plus, Power, Search, Send, Trash2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency';
|
||||
|
||||
@@ -42,7 +42,7 @@ type ChannelModalState = {
|
||||
};
|
||||
|
||||
type ChannelConfirmAction = {
|
||||
type: 'toggle' | 'delete' | 'copy';
|
||||
type: 'toggle' | 'copy';
|
||||
channel: SmsChannel;
|
||||
};
|
||||
|
||||
@@ -532,11 +532,6 @@ export function AdminChannelsPage() {
|
||||
loadChannels();
|
||||
}
|
||||
|
||||
async function deleteChannel(id: string) {
|
||||
await adminApi.deleteChannel(id, '运营端删除通道');
|
||||
setChannels((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
async function copyChannel(channel: SmsChannel) {
|
||||
await adminApi.copyChannel(channel.id);
|
||||
loadChannels();
|
||||
@@ -563,10 +558,6 @@ export function AdminChannelsPage() {
|
||||
void toggleChannel(confirmAction.channel);
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'delete') {
|
||||
void deleteChannel(confirmAction.channel.id);
|
||||
}
|
||||
|
||||
if (confirmAction.type === 'copy') {
|
||||
void copyChannel(confirmAction.channel);
|
||||
}
|
||||
@@ -574,17 +565,13 @@ export function AdminChannelsPage() {
|
||||
setConfirmAction(null);
|
||||
}
|
||||
|
||||
const confirmTitle = confirmAction?.type === 'delete'
|
||||
? '确认删除通道'
|
||||
: confirmAction?.type === 'copy'
|
||||
const confirmTitle = confirmAction?.type === 'copy'
|
||||
? '确认复制通道'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '确认启用通道'
|
||||
: '确认停用通道';
|
||||
|
||||
const confirmDescription = confirmAction?.type === 'delete'
|
||||
? '删除后该通道将从列表移除,副本通道的本地记录也会同步清理。'
|
||||
: confirmAction?.type === 'copy'
|
||||
const confirmDescription = confirmAction?.type === 'copy'
|
||||
? '系统将复制当前通道配置和报备详情,并新建一条名称带“副本”的通道。'
|
||||
: confirmAction?.channel.status === 'stopped'
|
||||
? '启用后通道会进入连接中状态,后续可继续观察网关连接。'
|
||||
@@ -651,7 +638,7 @@ export function AdminChannelsPage() {
|
||||
<button className={channel.status === 'stopped' ? 'is-success' : 'is-warning'} onClick={() => setConfirmAction({ type: 'toggle', channel })} type="button">
|
||||
<Power size={15} />{channel.status === 'stopped' ? '启用' : '停用'}
|
||||
</button>
|
||||
<button className="is-danger" onClick={() => setConfirmAction({ type: 'delete', channel })} type="button"><Trash2 size={15} />删除</button>
|
||||
<DeleteRiskAction onCompleted={() => void loadChannels()} portal="admin" targetId={channel.id} targetType="channel" />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -691,7 +678,7 @@ export function AdminChannelsPage() {
|
||||
footer={(
|
||||
<>
|
||||
<Button onClick={() => setConfirmAction(null)} variant="ghost">取消</Button>
|
||||
<Button onClick={submitConfirmAction} variant={confirmAction.type === 'delete' ? 'danger' : 'primary'}>确认</Button>
|
||||
<Button onClick={submitConfirmAction}>确认</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setConfirmAction(null)}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Building2, DollarSign, Plus, Trash2, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { adminApi, type TenantManagementRow } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { Breadcrumb, Button, Input, ManualRechargeDialog, Modal, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { formatCents } from '@/utils/currency';
|
||||
|
||||
type AdminCustomersPageProps = {
|
||||
basePath?: string;
|
||||
@@ -11,11 +11,6 @@ type AdminCustomersPageProps = {
|
||||
|
||||
type CustomerRow = TenantManagementRow;
|
||||
|
||||
type RechargeForm = {
|
||||
amount: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCancel: () => void; onConfirm: () => void }) {
|
||||
return (
|
||||
<Modal footer={<><Button onClick={onCancel} variant="ghost">取消</Button><Button onClick={onConfirm}>确认</Button></>} onClose={onCancel} open title="操作确认">
|
||||
@@ -24,13 +19,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
|
||||
);
|
||||
}
|
||||
|
||||
function emptyRechargeForm(): RechargeForm {
|
||||
return {
|
||||
amount: '',
|
||||
remark: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCustomersPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const [records, setRecords] = useState<CustomerRow[]>([]);
|
||||
@@ -39,9 +27,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
const [filters, setFilters] = useState({ name: '', status: 'all' });
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: 'toggle' | 'delete'; record: CustomerRow } | null>(null);
|
||||
const [rechargeTarget, setRechargeTarget] = useState<CustomerRow | null>(null);
|
||||
const [rechargeForm, setRechargeForm] = useState<RechargeForm>(emptyRechargeForm);
|
||||
const [rechargeError, setRechargeError] = useState('');
|
||||
const [recharging, setRecharging] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function loadData() {
|
||||
@@ -108,37 +93,6 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
|
||||
function openRechargeModal(record: CustomerRow) {
|
||||
setRechargeTarget(record);
|
||||
setRechargeForm(emptyRechargeForm());
|
||||
setRechargeError('');
|
||||
}
|
||||
|
||||
function updateRechargeForm<K extends keyof RechargeForm>(key: K, value: RechargeForm[K]) {
|
||||
setRechargeForm((current) => ({ ...current, [key]: value }));
|
||||
setRechargeError('');
|
||||
}
|
||||
|
||||
async function submitRecharge() {
|
||||
if (!rechargeTarget) return;
|
||||
const amount = Number(rechargeForm.amount);
|
||||
if (!Number.isFinite(amount) || !isValidMoneyInput(rechargeForm.amount, { allowNegative: true, allowZero: false })) {
|
||||
setRechargeError('请填写非 0 的充值金额,支持负数冲正');
|
||||
return;
|
||||
}
|
||||
setRecharging(true);
|
||||
try {
|
||||
await adminApi.createManualRecharge({
|
||||
tenantId: rechargeTarget.id,
|
||||
amountCents: yuanToMoneyUnits(rechargeForm.amount),
|
||||
remark: rechargeForm.remark,
|
||||
});
|
||||
setRechargeTarget(null);
|
||||
setRechargeForm(emptyRechargeForm());
|
||||
await loadData();
|
||||
} catch (failure) {
|
||||
setRechargeError(failure instanceof Error ? failure.message : '企业充值失败');
|
||||
} finally {
|
||||
setRecharging(false);
|
||||
}
|
||||
}
|
||||
|
||||
function submitConfirmAction() {
|
||||
@@ -191,28 +145,19 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
|
||||
onConfirm={submitConfirmAction}
|
||||
/>
|
||||
) : null}
|
||||
{rechargeTarget ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button disabled={recharging} onClick={() => setRechargeTarget(null)} variant="ghost">取消</Button>
|
||||
<Button disabled={recharging} onClick={() => { void submitRecharge(); }}>{recharging ? '充值中...' : '确认充值'}</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setRechargeTarget(null)}
|
||||
open
|
||||
size="md"
|
||||
title="企业人工充值"
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input disabled label="企业名称" value={rechargeTarget.name} />
|
||||
<Input disabled label="当前余额" prefix="¥" value={formatCents(rechargeTarget.account?.balanceCents ?? 0)} />
|
||||
<Input label="充值金额" onChange={(event) => updateRechargeForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={rechargeForm.amount} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateRechargeForm('remark', event.target.value)} rows={4} value={rechargeForm.remark} />
|
||||
</div>
|
||||
{rechargeError ? <p className="form-error">{rechargeError}</p> : null}
|
||||
</Modal>
|
||||
) : null}
|
||||
<ManualRechargeDialog
|
||||
initialTargetId={rechargeTarget?.id}
|
||||
lockTarget
|
||||
onClose={() => setRechargeTarget(null)}
|
||||
onCompleted={loadData}
|
||||
open={Boolean(rechargeTarget)}
|
||||
targets={rechargeTarget ? [{
|
||||
id: rechargeTarget.id,
|
||||
name: rechargeTarget.name,
|
||||
code: rechargeTarget.code,
|
||||
balanceCents: rechargeTarget.account?.balanceCents ?? 0,
|
||||
}] : []}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
|
||||
pending: 'warning',
|
||||
manual_requeueing: 'info',
|
||||
awaiting_ack: 'info',
|
||||
delivered: 'success',
|
||||
failed: 'danger',
|
||||
@@ -14,6 +15,7 @@ const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'd
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
manual_requeueing: '人工重投处理中',
|
||||
awaiting_ack: '等待客户端确认',
|
||||
delivered: '客户端已确认',
|
||||
failed: '投递失败',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileText, Info, Plus, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { adminApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { Breadcrumb, Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { displayFileName } from '@/utils/fileName';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
@@ -563,7 +563,7 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
|
||||
export function AdminEnterpriseSignaturesPage() {
|
||||
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 [deleteTarget, setDeleteTarget] = useState<{ kind: 'drainage'; signatureId: string; id: string; name: string } | null>(null);
|
||||
const [drainageModal, setDrainageModal] = useState<{ signatureId: string; item?: DrainageInfo } | null>(null);
|
||||
const [drainageReport, setDrainageReport] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
|
||||
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
|
||||
@@ -672,11 +672,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
if (deleteTarget.kind === 'signature') {
|
||||
await adminApi.changeEnterpriseSignatureStatus(deleteTarget.id, 'deleted', '运营端删除签名');
|
||||
} else {
|
||||
await adminApi.changeDrainageInfoStatus(deleteTarget.id, 'deleted', '运营端删除引流信息');
|
||||
}
|
||||
await adminApi.changeDrainageInfoStatus(deleteTarget.id, 'deleted', '运营端删除引流信息');
|
||||
setDeleteTarget(null);
|
||||
await loadData();
|
||||
}
|
||||
@@ -708,7 +704,7 @@ export function AdminEnterpriseSignaturesPage() {
|
||||
<Button icon={<FileText size={16} />} onClick={() => setSignatureReport(signature)} size="sm" variant="ghost">报备详情</Button>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setReportStatusTarget(signature)} size="sm" variant="ghost">报备状态</Button>
|
||||
<Button icon={<Edit3 size={16} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={16} />} onClick={() => setDeleteTarget({ kind: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger">删除</Button>
|
||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={signature.id} targetType="signature" />
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Edit3, Eye, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsApplication, type ClientSmsSignature, type ClientSmsTemplate, type TenantOption } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { Breadcrumb, Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tabs, Tag, Textarea } from '@/components/ui';
|
||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||
|
||||
type TemplateFormState = {
|
||||
@@ -99,6 +99,8 @@ function TemplateFormModal({
|
||||
category: item?.category ?? '行业通知',
|
||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||
});
|
||||
const initialForm = useRef(form).current;
|
||||
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
||||
const tenantApplications = applications.filter((application) => application.tenantId === form.tenantId && application.status !== 'deleted');
|
||||
const tenantSignatures = signatures.filter((signature) => (
|
||||
signature.tenantId === form.tenantId
|
||||
@@ -146,9 +148,10 @@ function TemplateFormModal({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
dirty={dirty}
|
||||
footer={({ requestClose }) => (
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={requestClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.tenantId || !form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables: currentVariables })}>保存</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -277,7 +280,6 @@ function ConfirmModal({ message, onCancel, onConfirm }: { message: string; onCan
|
||||
export function AdminEnterpriseTemplatesPage() {
|
||||
const [activeTab, setActiveTab] = useState<'sms' | 'mms'>('sms');
|
||||
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
|
||||
const [applicationKeyword, setApplicationKeyword] = useState('');
|
||||
@@ -363,15 +365,6 @@ export function AdminEnterpriseTemplatesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
await adminApi.changeEnterpriseTemplateStatus(deleteTarget.id, 'deleted', '运营端删除模板');
|
||||
setDeleteTarget(null);
|
||||
await loadData();
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-customer-split-page">
|
||||
<div className="page-heading">
|
||||
@@ -442,7 +435,7 @@ export function AdminEnterpriseTemplatesPage() {
|
||||
<div className="admin-enterprise-template-row__actions">
|
||||
<Button icon={<Eye size={15} />} onClick={() => setTemplatePreview(template)} size="sm" variant="ghost">预览</Button>
|
||||
<Button icon={<Edit3 size={15} />} onClick={() => setTemplateModal(template)} size="sm" variant="ghost">编辑</Button>
|
||||
<Button icon={<Trash2 size={15} />} onClick={() => setDeleteTarget(template)} size="sm" variant="danger">删除</Button>
|
||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="admin" targetId={template.id} targetType="template" />
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
@@ -475,13 +468,6 @@ export function AdminEnterpriseTemplatesPage() {
|
||||
/>
|
||||
) : null}
|
||||
{templatePreview ? <TemplatePreviewModal item={templatePreview} onClose={() => setTemplatePreview(null)} /> : null}
|
||||
{deleteTarget ? (
|
||||
<ConfirmModal
|
||||
message={`确认删除模板“${deleteTarget.name}”吗?删除后会写入真实后台。`}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={() => { void confirmDelete(); }}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Textarea, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { adminApi, type RechargeOrder, type TenantOption } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, DateRangeInput, Input, ManualRechargeDialog, Pagination, Tag, type DateRangeValue } from '@/components/ui';
|
||||
import { adminApi, type RechargeOrder, type TenantAccount, type TenantOption } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
||||
|
||||
type ManualRechargeForm = {
|
||||
tenantId: string;
|
||||
amount: string;
|
||||
remark: string;
|
||||
};
|
||||
import { formatCents } from '@/utils/currency';
|
||||
|
||||
function getDate(value: string) {
|
||||
return value.slice(0, 10);
|
||||
@@ -26,27 +20,26 @@ function RemarkCell({ value }: { value?: string }) {
|
||||
export function AdminRechargeRecordsPage() {
|
||||
const [records, setRecords] = useState<RechargeOrder[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [accounts, setAccounts] = useState<TenantAccount[]>([]);
|
||||
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({});
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [form, setForm] = useState<ManualRechargeForm>({ tenantId: '', amount: '', remark: '' });
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [manualError, setManualError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [nextTenants, nextRecords] = await Promise.all([
|
||||
const [nextTenants, nextAccounts, nextRecords] = await Promise.all([
|
||||
adminApi.listTenants(),
|
||||
adminApi.listAccounts(),
|
||||
adminApi.listManualRecharges(),
|
||||
]);
|
||||
setTenants(nextTenants);
|
||||
setAccounts(nextAccounts);
|
||||
setRecords(nextRecords);
|
||||
setForm((current) => ({ ...current, tenantId: current.tenantId || nextTenants[0]?.id || '' }));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '充值记录加载失败');
|
||||
setRecords([]);
|
||||
@@ -84,35 +77,6 @@ export function AdminRechargeRecordsPage() {
|
||||
setDateRange({});
|
||||
}
|
||||
|
||||
function updateForm<K extends keyof ManualRechargeForm>(key: K, value: ManualRechargeForm[K]) {
|
||||
setForm((current) => ({ ...current, [key]: value }));
|
||||
setManualError('');
|
||||
}
|
||||
|
||||
async function submitManualRecharge() {
|
||||
const amount = Number(form.amount);
|
||||
if (!form.tenantId || !Number.isFinite(amount) || !isValidMoneyInput(form.amount, { allowNegative: true, allowZero: false })) {
|
||||
setManualError('请填写非 0 的充值金额;金额支持负数冲正。');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setManualError('');
|
||||
try {
|
||||
await adminApi.createManualRecharge({
|
||||
tenantId: form.tenantId,
|
||||
amountCents: yuanToMoneyUnits(form.amount),
|
||||
remark: form.remark,
|
||||
});
|
||||
await loadData();
|
||||
setManualOpen(false);
|
||||
setForm({ tenantId: tenants[0]?.id ?? '', amount: '', remark: '' });
|
||||
} catch (failure) {
|
||||
setManualError(failure instanceof Error ? failure.message : '人工充值失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack admin-recharge-page">
|
||||
<div className="page-heading">
|
||||
@@ -181,33 +145,18 @@ export function AdminRechargeRecordsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{manualOpen ? (
|
||||
<Modal
|
||||
footer={(
|
||||
<>
|
||||
<Button disabled={submitting} onClick={() => setManualOpen(false)} variant="ghost">取消</Button>
|
||||
<Button disabled={submitting} onClick={() => { void submitManualRecharge(); }}>{submitting ? '充值中...' : '确认充值'}</Button>
|
||||
</>
|
||||
)}
|
||||
onClose={() => setManualOpen(false)}
|
||||
open
|
||||
size="md"
|
||||
title="企业人工充值"
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Select
|
||||
label="企业名称"
|
||||
onChange={(event) => updateForm('tenantId', event.target.value)}
|
||||
options={tenants.map((tenant) => ({ label: tenant.name, value: tenant.id }))}
|
||||
required
|
||||
value={form.tenantId}
|
||||
/>
|
||||
<Input label="充值金额" onChange={(event) => updateForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={form.amount} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => updateForm('remark', event.target.value)} rows={4} value={form.remark} />
|
||||
</div>
|
||||
{manualError ? <p className="form-error">{manualError}</p> : null}
|
||||
</Modal>
|
||||
) : null}
|
||||
<ManualRechargeDialog
|
||||
initialTargetId={tenants.find((tenant) => tenant.status !== 'deleted')?.id}
|
||||
onClose={() => setManualOpen(false)}
|
||||
onCompleted={loadData}
|
||||
open={manualOpen}
|
||||
targets={tenants.filter((tenant) => tenant.status !== 'deleted').map((tenant) => ({
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
code: tenant.code,
|
||||
balanceCents: accounts.find((account) => account.tenantId === tenant.id)?.balanceCents ?? 0,
|
||||
}))}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Download, FileSpreadsheet, Layers3, RefreshCw } from 'lucide-react';
|
||||
import { adminApi, fileDownloadUrl, type ReportMaterialPendingItem } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
|
||||
import { AlertTriangle, CheckCircle2, Download, FileSpreadsheet, Layers3, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||
import { adminApi, fileDownloadUrl, type ReportMaterialBatchPreflight, type ReportMaterialBatchResult, type ReportMaterialPendingItem } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { ReportMaterialImportModal } from './ReportMaterialImportModal';
|
||||
|
||||
@@ -17,38 +17,68 @@ export function AdminReportMaterialsPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preflightBusy, setPreflightBusy] = useState(false);
|
||||
const [preflight, setPreflight] = useState<ReportMaterialBatchPreflight | null>(null);
|
||||
const [poolEligibility, setPoolEligibility] = useState<Map<string, ReportMaterialBatchPreflight['items'][number]>>(new Map());
|
||||
const [operationKey, setOperationKey] = useState('');
|
||||
const [batchResult, setBatchResult] = useState<ReportMaterialBatchResult | null>(null);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
function loadData() {
|
||||
Promise.all([adminApi.listPendingReportMaterials(reportType === 'all' ? {} : { reportType: reportType as 'signature' | 'drainage' }), adminApi.listReportMaterialBatches()])
|
||||
.then(([pendingItems, batchItems]) => { setItems(pendingItems); setBatches(batchItems as Batch[]); setSelected((current) => new Set([...current].filter((id) => pendingItems.some((item) => item.id === id)))); setError(''); })
|
||||
.then(async ([pendingItems, batchItems]) => {
|
||||
setItems(pendingItems); setBatches(batchItems as Batch[]); setError('');
|
||||
const eligibility = pendingItems.length ? await adminApi.preflightReportMaterialBatch({ items: pendingItems.map(toBatchItem) }) : null;
|
||||
const eligibilityMap = new Map((eligibility?.items ?? []).map((item) => [item.id, item]));
|
||||
setPoolEligibility(eligibilityMap);
|
||||
setSelected((current) => new Set([...current].filter((id) => eligibilityMap.get(id)?.eligible)));
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '待报备资料加载失败'));
|
||||
}
|
||||
|
||||
useEffect(loadData, [reportType]);
|
||||
const visibleItems = useMemo(() => items.filter((item) => !keyword.trim() || [item.name, item.detail, item.signatureName, item.tenant?.name, item.application?.name].some((value) => String(value ?? '').includes(keyword.trim()))), [items, keyword]);
|
||||
const allSelected = visibleItems.length > 0 && visibleItems.every((item) => selected.has(item.id));
|
||||
const eligibleVisibleItems = visibleItems.filter((item) => poolEligibility.get(item.id)?.eligible);
|
||||
const allSelected = eligibleVisibleItems.length > 0 && eligibleVisibleItems.every((item) => selected.has(item.id));
|
||||
|
||||
function toggle(id: string) { setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
||||
function toggle(id: string) { if (!poolEligibility.get(id)?.eligible) return; setSelected((current) => { const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
||||
|
||||
async function beginCreateBatch() {
|
||||
const chosen = items.filter((item) => selected.has(item.id));
|
||||
if (!chosen.length) { setError('请先选择具备报备资格的资料'); return; }
|
||||
setConfirmOpen(true); setPreflightBusy(true); setPreflight(null); setBatchResult(null); setError(''); setMessage('');
|
||||
setOperationKey(`report-batch:${crypto.randomUUID()}`);
|
||||
try { setPreflight(await adminApi.preflightReportMaterialBatch({ items: chosen.map(toBatchItem) })); }
|
||||
catch (failure) { setError(failure instanceof Error ? failure.message : '报备资格预检失败'); }
|
||||
finally { setPreflightBusy(false); }
|
||||
}
|
||||
|
||||
async function createBatch() {
|
||||
const chosen = items.filter((item) => selected.has(item.id));
|
||||
if (!chosen.length) { setError('请先选择要统一报备的资料'); return; }
|
||||
setBusy(true); setError(''); setMessage('');
|
||||
try {
|
||||
const batch = await adminApi.createReportMaterialBatch({ items: chosen.map((item) => ({ reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined })) });
|
||||
setMessage(`批次 ${String(batch.batchNo ?? '')} 已按应用路由生成各通道报备文件`); setSelected(new Set()); loadData();
|
||||
const batch = await adminApi.createReportMaterialBatch({ idempotencyKey: operationKey, items: chosen.map((item) => ({ ...toBatchItem(item), materialVersion: item.materialVersion })) });
|
||||
setBatchResult(batch); setMessage(`批次 ${batch.batchNo} 已完成:成功 ${batch.result.successCount},跳过 ${batch.result.skippedCount},失败 ${batch.result.failedCount}`); setSelected(new Set()); loadData();
|
||||
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备批次生成失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <section className="page-stack report-material-page">
|
||||
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1>签名与引流资料报备工作台</h1><p>导入或业务修改的资料先进入待报备池;人工勾选后一次生成所有关联通道的任务和 XLSX 文件。</p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost">导入 WPS 表格</Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void createBatch()}>{busy ? '生成中...' : `统一生成通道报备(${selected.size})`}</Button></div></div>
|
||||
<div className="surface page-heading"><div><Breadcrumb items={['报备任务', '待报备资料']} /><h1>签名与引流资料报备工作台</h1><p>导入或业务修改的资料先进入待报备池;生成前会重新检查应用、路由、通道字段、资料版本和重复批次。</p></div><div className="page-heading-actions"><Button icon={<FileSpreadsheet size={16} />} onClick={() => setImportOpen(true)} variant="ghost">导入 WPS 表格</Button><Button disabled={busy || selected.size === 0} icon={<Layers3 size={16} />} onClick={() => void beginCreateBatch()}>{busy ? '生成中...' : `预检并生成(${selected.size})`}</Button></div></div>
|
||||
{error ? <p className="form-error">{error}</p> : null}{message ? <p className="form-success">{message}</p> : null}
|
||||
<div className="surface report-material-filter"><Select label="资料类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部资料', value: 'all' }, { label: '签名资料', value: 'signature' }, { label: '引流信息', value: 'drainage' }]} value={reportType} /><Input label="企业/应用/签名/站点" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索待报备资料" value={keyword} /><Button icon={<RefreshCw size={15} />} onClick={loadData} variant="ghost">刷新</Button></div>
|
||||
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span>资料</span><span>企业 / 应用</span><span>版本</span><span>变更时间</span></div>{visibleItems.map((item) => <label className="report-material-row" key={item.id}><input checked={selected.has(item.id)} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><Tag tone="info">V{item.materialVersion}</Tag><span>{formatDateTime(item.changedAt)}</span></label>)}{visibleItems.length === 0 ? <div className="channel-report-empty">暂无符合条件的待报备资料</div> : null}</div>
|
||||
<div className="surface report-material-pool"><div className="report-material-table-head"><input checked={allSelected} onChange={() => setSelected((current) => { const next = new Set(current); for (const item of visibleItems.filter((entry) => poolEligibility.get(entry.id)?.eligible)) allSelected ? next.delete(item.id) : next.add(item.id); return next; })} type="checkbox" /><span>资料</span><span>企业 / 应用</span><span>版本 / 资格</span><span>变更时间</span></div>{visibleItems.map((item) => { const eligibility = poolEligibility.get(item.id); const disabled = !eligibility?.eligible; return <label className={`report-material-row${disabled ? ' is-disabled' : ''}`} key={item.id}><input checked={selected.has(item.id)} disabled={disabled} onChange={() => toggle(item.id)} type="checkbox" /><span><strong>{item.name}</strong><small>{item.reportType === 'signature' ? '签名资料' : `引流信息 · 所属签名 ${item.signatureName ?? '-'}`}</small><em>{item.detail || '-'}</em></span><span><strong>{item.tenant?.name ?? '-'}</strong><small>{item.application?.name ?? '未指定应用'}</small></span><span><Tag tone={disabled ? 'warning' : 'success'}>V{item.materialVersion} · {disabled ? '待补充' : `${eligibility.targets.filter((target) => target.eligible).length} 通道可生成`}</Tag>{disabled ? <small title={eligibility?.blockedReasons.join(';')}>{eligibility?.blockedReasons[0] ?? '资格检查中'}</small> : null}</span><span>{formatDateTime(item.changedAt)}</span></label>; })}{visibleItems.length === 0 ? <div className="channel-report-empty">暂无符合条件的待报备资料</div> : null}</div>
|
||||
<div className="surface report-material-batches"><div className="channel-field-section-head"><div><h2>最近生成批次</h2><p>一个批次可按路由展开成多个通道文件,图片直接嵌入 XLSX。</p></div><Tag tone="neutral">{batches.length} 个批次</Tag></div>{batches.map((batch) => <article key={batch.id}><div><strong>{batch.batchNo ?? batch.id}</strong><span>{statusLabel[String(batch.status)] ?? batch.status}</span><small>{batch.createdAt ? formatDateTime(batch.createdAt) : '-'} · 选择 {batch.selectedCount ?? 0} 条 · {batch.channelCount ?? 0} 个通道</small></div><div>{(batch.exportFiles ?? []).map((file) => <a href={fileDownloadUrl(String(file.fileObjectId))} key={String(file.id)}><Download size={15} />{String(file.fileName ?? '下载报备文件')}({String(file.rowCount ?? 0)} 行)</a>)}</div></article>)}{batches.length === 0 ? <div className="channel-report-empty">尚未生成报备批次</div> : null}</div>
|
||||
{importOpen ? <ReportMaterialImportModal onClose={() => setImportOpen(false)} onCompleted={() => { setMessage('资料导入完成,已进入待报备池'); loadData(); }} /> : null}
|
||||
<Modal footer={batchResult ? <Button onClick={() => setConfirmOpen(false)}>关闭</Button> : <><Button disabled={busy} onClick={() => setConfirmOpen(false)} variant="ghost">取消</Button><Button disabled={preflightBusy || busy || !preflight?.eligible} icon={<ShieldCheck size={16} />} onClick={() => void createBatch()}>{busy ? '生成处理中…' : '确认生成'}</Button></>} onClose={() => { if (!busy) setConfirmOpen(false); }} open={confirmOpen} size="xl" title="报备生成资格预检">
|
||||
<div className="report-batch-preflight">{preflightBusy ? <p role="status">正在核对资料版本、应用路由、通道字段与历史批次…</p> : null}{preflight ? <><div className="report-batch-summary"><span><CheckCircle2 size={17} />可生成 {preflight.eligibleTargetCount} 个资料通道组合</span><span><AlertTriangle size={17} />跳过 {preflight.skippedTargetCount} 个组合</span></div>{preflight.items.map((item) => <article key={item.id}><div><strong>{item.name}</strong><small>{item.tenantName} · {item.applicationName} · V{item.materialVersion}</small></div>{item.targets.length ? <ul>{item.targets.map((target) => <li key={target.businessKey} className={target.eligible ? 'is-eligible' : 'is-blocked'}><span>{target.name} · {target.carrier}</span><small>{target.eligible ? '资格通过' : target.blockedReasons.join(';')}</small></li>)}</ul> : <p className="form-error">{item.blockedReasons.join(';')}</p>}</article>)}</> : null}{batchResult ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong>报备批次 {batchResult.batchNo} 已处理</strong><span>成功 {batchResult.result.successCount} · 跳过 {batchResult.result.skippedCount} · 失败 {batchResult.result.failedCount}</span><span>操作单号:{batchResult.operationId}{batchResult.replayed ? '(幂等重放)' : ''}</span></div></div> : null}{error ? <p className="form-error" role="alert">{error}</p> : null}</div>
|
||||
</Modal>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function toBatchItem(item: ReportMaterialPendingItem) {
|
||||
return { reportType: item.reportType, signatureId: item.signatureId, drainageItemId: item.drainageItemId ?? undefined, materialVersion: item.materialVersion };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Eye, Search, X } from 'lucide-react';
|
||||
import { Eye, Search, X } from 'lucide-react';
|
||||
import { adminApi, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
|
||||
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { Breadcrumb, Button, FileActions, Input, Modal, RiskAction, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
|
||||
@@ -84,10 +84,6 @@ export function AdminSignatureAuditPage() {
|
||||
|
||||
const visible = useMemo(() => items.filter((item) => status === 'all' || item.auditStatus === status), [items, status]);
|
||||
|
||||
async function approve(item: ClientSmsSignature) {
|
||||
try { await adminApi.approveSignature(item.id); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核通过失败'); }
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!rejectTarget || !reason.trim()) return;
|
||||
try { await adminApi.rejectSignature(rejectTarget.id, reason.trim()); setRejectTarget(undefined); setReason(''); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '签名审核驳回失败'); }
|
||||
@@ -99,7 +95,7 @@ export function AdminSignatureAuditPage() {
|
||||
{ key: 'application', title: '应用', render: (record) => record.application?.name ?? '-' },
|
||||
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.updatedAt) },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={(statusMeta[record.auditStatus] ?? statusMeta.draft).tone}>{(statusMeta[record.auditStatus] ?? statusMeta.draft).label}</Tag> },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button><Button disabled={!canReviewSignature(record)} icon={<Check size={15} />} onClick={() => void approve(record)} size="sm" variant="success">通过</Button><Button disabled={!canReviewSignature(record)} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button></div> },
|
||||
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost">详情</Button><RiskAction disabled={!canReviewSignature(record)} onCompleted={loadData} targetId={record.id} targetType="signature" /><Button disabled={!canReviewSignature(record)} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger">驳回</Button></div> },
|
||||
], []);
|
||||
|
||||
return <section className="page-stack admin-template-audit-page">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
||||
import { Button, Input, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { CalendarDays, FileText, Search } from 'lucide-react';
|
||||
import { Button, Input, Pagination, Select, SystemLogExport, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type OperationLogItem } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
@@ -99,7 +99,7 @@ export function AdminSystemLogsPage() {
|
||||
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
||||
<h1>系统日志</h1>
|
||||
</div>
|
||||
<Button icon={<Download size={17} />} variant="secondary">导出日志</Button>
|
||||
<SystemLogExport exportLogs={adminApi.exportSystemLogs} filters={filters} portal="admin" />
|
||||
</div>
|
||||
|
||||
<div className="system-log-filters">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Search, X } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, RiskAction, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type SmsTemplateAudit } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
|
||||
@@ -29,10 +29,8 @@ export function AdminTemplateAuditPage() {
|
||||
.catch(() => setAudits([]));
|
||||
}, [keyword, status]);
|
||||
|
||||
async function reviewTemplate(id: string, nextStatus: 'approved' | 'rejected') {
|
||||
const updated = nextStatus === 'approved'
|
||||
? await adminApi.approveTemplate(id)
|
||||
: await adminApi.rejectTemplate(id);
|
||||
async function rejectTemplate(id: string) {
|
||||
const updated = await adminApi.rejectTemplate(id);
|
||||
setAudits((items) => items.map((item) => (item.id === id ? updated : item)));
|
||||
}
|
||||
|
||||
@@ -58,19 +56,11 @@ export function AdminTemplateAuditPage() {
|
||||
align: 'right',
|
||||
render: (record) => (
|
||||
<div className="table-actions">
|
||||
<Button
|
||||
disabled={record.auditStatus !== 'pending'}
|
||||
icon={<Check size={15} />}
|
||||
onClick={() => void reviewTemplate(record.id, 'approved')}
|
||||
size="sm"
|
||||
variant="success"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<RiskAction disabled={record.auditStatus !== 'pending'} onCompleted={() => adminApi.listTemplateAudits({ keyword, status }).then(setAudits)} targetId={record.id} targetType="template" />
|
||||
<Button
|
||||
disabled={record.auditStatus !== 'pending'}
|
||||
icon={<X size={15} />}
|
||||
onClick={() => void reviewTemplate(record.id, 'rejected')}
|
||||
onClick={() => void rejectTemplate(record.id)}
|
||||
size="sm"
|
||||
variant="danger"
|
||||
>
|
||||
@@ -80,7 +70,7 @@ export function AdminTemplateAuditPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[keyword, status],
|
||||
);
|
||||
const templateAudits = audits;
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ function generateInitialPassword() {
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const session = readSession();
|
||||
const session = readSession('admin');
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
@@ -61,7 +61,7 @@ function UploadPanel({ file, uploading, onFile }: { file: FileObject | null; upl
|
||||
<label className="enterprise-upload">
|
||||
<Upload size={38} />
|
||||
<strong>{uploading ? '上传中...' : file ? displayFileName(file.fileName) : '点击上传'}</strong>
|
||||
<FileActions file={fileRef} />
|
||||
<FileActions file={fileRef} portal="client" />
|
||||
<input
|
||||
accept="image/png,image/jpeg,image/webp,application/pdf"
|
||||
disabled={uploading}
|
||||
|
||||
@@ -21,7 +21,6 @@ type RecentTaskRow = {
|
||||
taskNo: string;
|
||||
scene: string;
|
||||
count: number;
|
||||
channel: string;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
};
|
||||
@@ -30,7 +29,6 @@ const columns: Array<TableColumn<RecentTaskRow>> = [
|
||||
{ key: 'taskNo', title: '批次编号', render: (record) => record.taskNo },
|
||||
{ key: 'scene', title: '发送场景', render: (record) => record.scene },
|
||||
{ key: 'count', title: '发送量', render: (record) => `${record.count.toLocaleString('zh-CN')} 条` },
|
||||
{ key: 'channel', title: '通道', render: (record) => record.channel },
|
||||
{ key: 'createdAt', title: '创建时间', render: (record) => record.createdAt },
|
||||
{ key: 'status', title: '状态', render: (record) => <Tag tone={record.status === 'completed' ? 'success' : record.status === 'failed' ? 'danger' : 'info'}>{record.status}</Tag> },
|
||||
];
|
||||
@@ -61,7 +59,6 @@ export function ClientHome() {
|
||||
taskNo: String(task.taskNo ?? task.id),
|
||||
scene: String(task.category ?? task.content ?? '短信发送'),
|
||||
count: Number(task.phoneTotal ?? task.progressTotal ?? 0),
|
||||
channel: Array.isArray(task.messages) && task.messages[0]?.channel?.name ? String(task.messages[0].channel.name) : '待路由',
|
||||
createdAt: formatDateTime(task.createdAt ? String(task.createdAt) : null),
|
||||
status: String(task.status ?? 'unknown'),
|
||||
})), [dashboard]);
|
||||
|
||||
@@ -178,8 +178,8 @@ export function ClientSendDetailPage() {
|
||||
<tr><td className="ui-table__empty" colSpan={9}>暂无发送记录</td></tr>
|
||||
) : visibleRows.map((record) => {
|
||||
const receipt = getReceipt(record);
|
||||
const carrier = record.channel?.carrier ? carrierLabelMap[record.channel.carrier] ?? record.channel.carrier : '-';
|
||||
const region = record.channel?.sendRegion ?? '-';
|
||||
const carrier = record.carrier ? carrierLabelMap[record.carrier] ?? record.carrier : '-';
|
||||
const region = record.province ?? '-';
|
||||
return (
|
||||
<Fragment key={record.id}>
|
||||
<tr className="send-detail-main-row">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Edit3, FileCheck2, Globe2, Plus, RotateCcw, Search, Trash2, Upload } from 'lucide-react';
|
||||
import { Button, FileActions, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { Button, DeleteRiskAction, FileActions, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import {
|
||||
clientApi,
|
||||
type ClientApplicationReportField,
|
||||
@@ -67,7 +67,7 @@ function ReviewFields({
|
||||
<Upload size={26} />
|
||||
<strong>{reportFileRef(values[field.code])?.fileName ?? `${field.required ? '* ' : ''}上传${field.name}`}</strong>
|
||||
<small>{uploadingCode === field.code ? '上传中...' : field.fieldType === 'image' ? '请选择图片文件' : '请选择文件'}</small>
|
||||
<FileActions file={reportFileRef(values[field.code])} />
|
||||
<FileActions file={reportFileRef(values[field.code])} portal="client" />
|
||||
<input
|
||||
accept={field.fieldType === 'image' ? 'image/*' : undefined}
|
||||
onChange={(event) => onUpload(field, event.target.files?.[0])}
|
||||
@@ -282,8 +282,7 @@ export function ClientSignaturesPage() {
|
||||
async function confirmDelete() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
if (deleting.type === 'signature') await clientApi.changeSignatureStatus(deleting.id, 'disabled');
|
||||
else await clientApi.changeDrainageInfoStatus(deleting.id, 'deleted');
|
||||
await clientApi.changeDrainageInfoStatus(deleting.id, 'deleted');
|
||||
setDeleting(undefined);
|
||||
loadData();
|
||||
} catch (failure) {
|
||||
@@ -335,7 +334,7 @@ export function ClientSignaturesPage() {
|
||||
<span>{formatDate(signature.updatedAt)}</span>
|
||||
<div className="table-actions">
|
||||
<Button disabled={!editable} icon={<Edit3 size={14} />} onClick={() => setSignatureModal(signature)} size="sm" variant="ghost">修改</Button>
|
||||
<Button icon={<Trash2 size={14} />} onClick={() => setDeleting({ type: 'signature', id: signature.id, name: signature.name })} size="sm" variant="danger">删除</Button>
|
||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="client" targetId={signature.id} targetType="signature" />
|
||||
</div>
|
||||
</div>
|
||||
{signature.rejectReason ? <div className="client-signature-inline-reason"><strong>修改说明:</strong>{signature.rejectReason}</div> : null}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CalendarDays, Download, FileText, Search } from 'lucide-react';
|
||||
import { CalendarDays, FileText, Search } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Pagination,
|
||||
Select,
|
||||
SystemLogExport,
|
||||
Table,
|
||||
Tag,
|
||||
type TableColumn,
|
||||
@@ -78,7 +79,7 @@ export function ClientSystemLogsPage() {
|
||||
<span className="sms-send-title__icon"><FileText size={22} /></span>
|
||||
<h1>系统日志</h1>
|
||||
</div>
|
||||
<Button icon={<Download size={17} />} variant="secondary">导出日志</Button>
|
||||
<SystemLogExport exportLogs={clientApi.exportSystemLogs} filters={{ keyword, level, module, range }} portal="client" />
|
||||
</div>
|
||||
|
||||
<div className="system-log-filters">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Edit3, MessageSquare, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { Button, DeleteRiskAction, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
|
||||
import { clientApi, type ClientSmsApplication, type ClientSmsSignatureView, type ClientSmsTemplate } from '@/api/adminApi';
|
||||
import { formatDateTime } from '@/utils/dateTime';
|
||||
import { replaceLeadingSmsSignature } from '@/utils/smsSignature';
|
||||
@@ -86,6 +86,8 @@ function TemplateModal({
|
||||
content: initialContent,
|
||||
variables: item?.variables?.map((variable) => ({ name: variable.name, example: variable.example ?? undefined, required: variable.required ?? true })) ?? [],
|
||||
});
|
||||
const initialForm = useRef(form).current;
|
||||
const dirty = JSON.stringify(form) !== JSON.stringify(initialForm);
|
||||
const application = applications.find((candidate) => candidate.id === form.applicationId);
|
||||
const availableSignatures = signatures.filter((signature) => (
|
||||
signature.auditStatus === 'approved'
|
||||
@@ -130,9 +132,10 @@ function TemplateModal({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={(
|
||||
dirty={dirty}
|
||||
footer={({ requestClose }) => (
|
||||
<>
|
||||
<Button onClick={onClose} variant="ghost">取消</Button>
|
||||
<Button onClick={requestClose} variant="ghost">取消</Button>
|
||||
<Button disabled={!form.applicationId || !form.signatureId || !form.name || !form.content.trim()} onClick={() => onSubmit({ ...form, variables })}>提交审核</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -266,12 +269,6 @@ export function ClientTemplatesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function disableTemplate(id: string) {
|
||||
clientApi.changeTemplateStatus(id, 'disabled')
|
||||
.then(loadData)
|
||||
.catch((reason: Error) => setError(reason.message || '模板禁用失败'));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<div className="template-page-header">
|
||||
@@ -315,10 +312,7 @@ export function ClientTemplatesPage() {
|
||||
<Edit3 size={14} />
|
||||
编辑
|
||||
</button>
|
||||
<button onClick={() => disableTemplate(template.id)} type="button">
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</button>
|
||||
<DeleteRiskAction onCompleted={() => void loadData()} portal="client" targetId={template.id} targetType="template" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -41,7 +41,7 @@ function toForm(user?: ManagedUser): UserForm {
|
||||
}
|
||||
|
||||
export function ClientUsersPage() {
|
||||
const session = readSession();
|
||||
const session = readSession('client');
|
||||
const tenantId = session?.user.tenantId ?? undefined;
|
||||
const [users, setUsers] = useState<ManagedUser[]>([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { AlertTriangle, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { adminApi, clientApi, type DeletionPreflight, type DeletionResult, type DeletionTargetType } from '@/api/adminApi';
|
||||
import { Button } from './Button';
|
||||
import { Modal } from './Modal';
|
||||
import { Textarea } from './Textarea';
|
||||
|
||||
export function DeleteRiskAction({ portal, targetType, targetId, children = '删除', icon = <Trash2 size={15} />, disabled, onCompleted }: {
|
||||
portal: 'admin' | 'client';
|
||||
targetType: DeletionTargetType;
|
||||
targetId: string;
|
||||
children?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onCompleted?: (result: DeletionResult) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [preflight, setPreflight] = useState<DeletionPreflight | null>(null);
|
||||
const [result, setResult] = useState<DeletionResult | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [idempotencyKey, setIdempotencyKey] = useState('');
|
||||
|
||||
async function begin() {
|
||||
const key = `delete:${targetType}:${targetId}:${crypto.randomUUID()}`;
|
||||
setOpen(true); setLoading(true); setPreflight(null); setResult(null); setReason(''); setError(''); setIdempotencyKey(key);
|
||||
try {
|
||||
const data = portal === 'admin'
|
||||
? await adminApi.getDeletionPreflight(targetType, targetId)
|
||||
: await clientApi.getDeletionPreflight(targetType as Exclude<DeletionTargetType, 'channel'>, targetId);
|
||||
setPreflight(data);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '删除资格预检失败');
|
||||
} finally { setLoading(false); }
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!preflight?.allowedActions.includes('delete') || reason.trim().length < 4) return;
|
||||
setSubmitting(true); setError('');
|
||||
try {
|
||||
const body = { expectedUpdatedAt: preflight.expectedUpdatedAt, idempotencyKey, reason: reason.trim() };
|
||||
const completed = portal === 'admin'
|
||||
? await adminApi.deleteGovernedTarget(targetType, targetId, body)
|
||||
: await clientApi.deleteGovernedTarget(targetType as Exclude<DeletionTargetType, 'channel'>, targetId, body);
|
||||
setResult(completed); onCompleted?.(completed);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '删除失败,请重新检查依赖后重试');
|
||||
} finally { setSubmitting(false); }
|
||||
}
|
||||
|
||||
function close() { if (!submitting) setOpen(false); }
|
||||
const blocked = Boolean(preflight && !preflight.allowedActions.includes('delete'));
|
||||
const footer = (requestClose: () => void) => result ? <Button onClick={close}>关闭</Button> : <>
|
||||
<Button disabled={submitting} onClick={requestClose} variant="ghost">取消</Button>
|
||||
<Button disabled={loading || submitting || blocked || !preflight || reason.trim().length < 4} icon={<Trash2 size={15} />} onClick={() => void confirm()} variant="danger">
|
||||
{submitting ? '删除处理中…' : '确认删除'}
|
||||
</Button>
|
||||
</>;
|
||||
|
||||
return <>
|
||||
<Button disabled={disabled} icon={icon} onClick={() => void begin()} size="sm" variant="danger">{children}</Button>
|
||||
<Modal dirty={!result && reason.trim().length > 0} footer={({ requestClose }) => footer(requestClose)} onClose={close} open={open} title="删除资格与影响确认">
|
||||
<div className="risk-action-content delete-risk-action">
|
||||
{loading ? <p role="status">正在从后台检查引用关系与当前状态…</p> : null}
|
||||
{preflight ? <>
|
||||
<div className="risk-action-identity">
|
||||
{Object.entries(preflight.identity).map(([key, value]) => <div key={key}><span>{identityLabels[key] ?? key}</span><strong>{value}</strong></div>)}
|
||||
</div>
|
||||
<section><h3>依赖与资格检查</h3>{preflight.dependencies.length ? <ul className="delete-risk-dependencies">{preflight.dependencies.map((item) => <li key={item.kind}><strong>{item.label}</strong><span>{item.count} 项</span>{item.items.length ? <small>{item.items.join(';')}</small> : <small>无活动引用</small>}</li>)}</ul> : null}</section>
|
||||
{preflight.blockedReasons.length
|
||||
? <ul className="risk-action-blockers">{preflight.blockedReasons.map((item) => <li key={item}><AlertTriangle size={15} />{item}</li>)}</ul>
|
||||
: <p className="risk-action-passed"><ShieldCheck size={16} />资格检查通过,可以执行逻辑删除</p>}
|
||||
<section><h3>影响范围</h3><ul>{preflight.impacts.map((item) => <li key={item}>{item}</li>)}</ul><p className="muted">{preflight.recoverability.description}</p></section>
|
||||
{!blocked ? <Textarea label="删除原因" onChange={(event) => setReason(event.target.value)} placeholder="至少填写 4 个字符,原因将写入审计记录" required value={reason} /> : null}
|
||||
</> : null}
|
||||
{result ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong>删除已完成</strong><span>操作单号:{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span></div></div> : null}
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
const identityLabels: Record<string, string> = { name: '对象名称', id: '唯一标识', code: '通道编码', tenant: '所属企业', application: '短信应用', signature: '关联签名' };
|
||||
@@ -7,6 +7,7 @@ import { Modal } from './Modal';
|
||||
|
||||
type FileActionsProps = {
|
||||
file?: FileRef | null;
|
||||
portal?: 'admin' | 'client';
|
||||
};
|
||||
|
||||
function isImageFile(file: FileRef) {
|
||||
@@ -15,14 +16,14 @@ function isImageFile(file: FileRef) {
|
||||
return contentType.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|svg)$/.test(name);
|
||||
}
|
||||
|
||||
export function FileActions({ file }: FileActionsProps) {
|
||||
export function FileActions({ file, portal = 'admin' }: FileActionsProps) {
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
if (!file?.fileObjectId) {
|
||||
return null;
|
||||
}
|
||||
const previewUrl = fileDownloadUrl(file.fileObjectId, 'inline');
|
||||
const downloadUrl = fileDownloadUrl(file.fileObjectId, 'attachment');
|
||||
const previewUrl = fileDownloadUrl(file.fileObjectId, 'inline', portal);
|
||||
const downloadUrl = fileDownloadUrl(file.fileObjectId, 'attachment', portal);
|
||||
const fileName = displayFileName(file.fileName);
|
||||
return (
|
||||
<span className="file-actions" onClick={(event) => event.stopPropagation()}>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { adminApi, type ManualRechargePreflight, type ManualRechargeResult } from '@/api/adminApi';
|
||||
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
||||
import { Button } from './Button';
|
||||
import { Input } from './Input';
|
||||
import { Modal } from './Modal';
|
||||
import { Select } from './Select';
|
||||
import { Textarea } from './Textarea';
|
||||
|
||||
export type ManualRechargeTarget = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
balanceCents: number;
|
||||
};
|
||||
|
||||
type ManualRechargeDialogProps = {
|
||||
initialTargetId?: string;
|
||||
lockTarget?: boolean;
|
||||
onClose: () => void;
|
||||
onCompleted: () => void | Promise<void>;
|
||||
open: boolean;
|
||||
targets: ManualRechargeTarget[];
|
||||
};
|
||||
|
||||
export function ManualRechargeDialog({ initialTargetId, lockTarget = false, onClose, onCompleted, open, targets }: ManualRechargeDialogProps) {
|
||||
const [tenantId, setTenantId] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [remark, setRemark] = useState('');
|
||||
const [review, setReview] = useState<ManualRechargePreflight | null>(null);
|
||||
const [result, setResult] = useState<ManualRechargeResult | null>(null);
|
||||
const [idempotencyKey, setIdempotencyKey] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setTenantId(initialTargetId ?? targets[0]?.id ?? '');
|
||||
setAmount('');
|
||||
setRemark('');
|
||||
setReview(null);
|
||||
setResult(null);
|
||||
setIdempotencyKey('');
|
||||
setError('');
|
||||
setSubmitting(false);
|
||||
}, [initialTargetId, open]);
|
||||
|
||||
if (!open) return null;
|
||||
const selectedTarget = targets.find((target) => target.id === tenantId);
|
||||
|
||||
function resetReview() {
|
||||
setReview(null);
|
||||
setResult(null);
|
||||
setIdempotencyKey('');
|
||||
setError('');
|
||||
}
|
||||
|
||||
function closeAndDestroyDraft() {
|
||||
if (submitting) return;
|
||||
setAmount('');
|
||||
setRemark('');
|
||||
resetReview();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function preflight() {
|
||||
if (!tenantId || !Number.isFinite(Number(amount)) || !isValidMoneyInput(amount, { allowNegative: true, allowZero: false })) {
|
||||
setError('请填写非 0 的充值金额;金额支持负数冲正。');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const preview = await adminApi.preflightManualRecharge({ tenantId, amountCents: yuanToMoneyUnits(amount) });
|
||||
setReview(preview);
|
||||
setIdempotencyKey(`manual-recharge:${crypto.randomUUID()}`);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '人工充值资格核对失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!review || !idempotencyKey) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextResult = await adminApi.createManualRecharge({
|
||||
tenantId,
|
||||
amountCents: review.amountCents,
|
||||
expectedAccountUpdatedAt: review.expectedAccountUpdatedAt,
|
||||
idempotencyKey,
|
||||
remark,
|
||||
});
|
||||
setResult(nextResult);
|
||||
await onCompleted();
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '人工充值失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const footer = (requestClose: () => void) => result ? (
|
||||
<Button onClick={closeAndDestroyDraft}>完成</Button>
|
||||
) : review ? (
|
||||
<>
|
||||
<Button disabled={submitting} onClick={resetReview} variant="ghost">返回修改</Button>
|
||||
<Button disabled={submitting} onClick={() => { void submit(); }}>{submitting ? '入账中...' : review.direction === 'topup' ? '确认充值' : '确认冲正'}</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button disabled={submitting} onClick={requestClose} variant="ghost">取消</Button>
|
||||
<Button disabled={submitting} onClick={() => { void preflight(); }}>{submitting ? '核对中...' : '下一步:核对信息'}</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
dirty={!result && !submitting && Boolean(amount.trim() || remark.trim() || review)}
|
||||
footer={({ requestClose }) => footer(requestClose)}
|
||||
onClose={closeAndDestroyDraft}
|
||||
open
|
||||
size="md"
|
||||
title={result ? '人工充值结果' : review ? '确认人工充值' : '企业人工充值'}
|
||||
>
|
||||
{result ? (
|
||||
<div className="manual-recharge-result" role="status">
|
||||
<strong>{result.amountCents > 0 ? '充值已入账' : '余额冲正已入账'}</strong>
|
||||
<span>订单号:{result.orderNo}</span>
|
||||
<span>充值后余额:¥{formatCents(result.balanceAfterCents)}</span>
|
||||
<span>操作单号:{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span>
|
||||
</div>
|
||||
) : review ? (
|
||||
<div className="manual-recharge-review">
|
||||
<p>请核对以下资金变更,确认后将立即写入企业账户。</p>
|
||||
<dl>
|
||||
<div><dt>充值对象</dt><dd><strong>{review.tenant.name}</strong><span>{review.tenant.code} · {review.tenant.id}</span></dd></div>
|
||||
<div><dt>操作方向</dt><dd>{review.direction === 'topup' ? '余额充值' : '余额冲正'}</dd></div>
|
||||
<div><dt>当前现金余额</dt><dd>¥{formatCents(review.balanceCents)}</dd></div>
|
||||
<div><dt>本次变动</dt><dd className={review.amountCents > 0 ? 'is-positive' : 'is-negative'}>{review.amountCents > 0 ? '+' : '-'}¥{formatCents(Math.abs(review.amountCents))}</dd></div>
|
||||
<div><dt>预计现金余额</dt><dd><strong>¥{formatCents(review.balanceAfterCents)}</strong></dd></div>
|
||||
</dl>
|
||||
{remark.trim() ? <p className="manual-recharge-review__remark"><strong>备注:</strong>{remark.trim()}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-system-modal-form">
|
||||
{lockTarget ? (
|
||||
<Input disabled label="企业名称" value={selectedTarget?.name ?? ''} />
|
||||
) : (
|
||||
<Select label="企业名称" onChange={(event) => { setTenantId(event.target.value); resetReview(); }} options={targets.map((target) => ({ label: target.name, value: target.id }))} required value={tenantId} />
|
||||
)}
|
||||
<Input disabled label="当前现金余额" prefix="¥" value={formatCents(selectedTarget?.balanceCents ?? 0)} />
|
||||
<Input label="充值金额" onChange={(event) => { setAmount(event.target.value); resetReview(); }} prefix="¥" required step="0.0001" type="number" value={amount} />
|
||||
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => { setRemark(event.target.value); resetReview(); }} rows={4} value={remark} />
|
||||
</div>
|
||||
)}
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
+226
-21
@@ -1,51 +1,256 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import type { ReactNode, RefObject } from 'react';
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export type ModalCloseControls = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
footer?: ReactNode | ((controls: ModalCloseControls) => ReactNode);
|
||||
size?: 'md' | 'xl';
|
||||
onClose: () => void;
|
||||
dirty?: boolean;
|
||||
initialFocusRef?: RefObject<HTMLElement | null>;
|
||||
closeGuardTitle?: string;
|
||||
closeGuardDescription?: string;
|
||||
};
|
||||
|
||||
export function Modal({ open, title, children, footer, size = 'md', onClose }: ModalProps) {
|
||||
const focusableSelector = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[contenteditable="true"]',
|
||||
].join(',');
|
||||
|
||||
const modalStack: HTMLElement[] = [];
|
||||
let documentLockCount = 0;
|
||||
let bodyOverflow = '';
|
||||
let bodyPaddingRight = '';
|
||||
let backgroundState: Array<{ element: HTMLElement; inert: boolean; ariaHidden: string | null }> = [];
|
||||
|
||||
function modalLayer() {
|
||||
let layer = document.getElementById('ui-modal-layer');
|
||||
if (!layer) {
|
||||
layer = document.createElement('div');
|
||||
layer.id = 'ui-modal-layer';
|
||||
document.body.appendChild(layer);
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
|
||||
function lockDocument(layer: HTMLElement) {
|
||||
documentLockCount += 1;
|
||||
if (documentLockCount !== 1) return;
|
||||
|
||||
bodyOverflow = document.body.style.overflow;
|
||||
bodyPaddingRight = document.body.style.paddingRight;
|
||||
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
|
||||
backgroundState = Array.from(document.body.children)
|
||||
.filter((child): child is HTMLElement => child instanceof HTMLElement && child !== layer)
|
||||
.map((element) => ({ element, inert: element.inert, ariaHidden: element.getAttribute('aria-hidden') }));
|
||||
backgroundState.forEach(({ element }) => {
|
||||
element.inert = true;
|
||||
element.setAttribute('aria-hidden', 'true');
|
||||
});
|
||||
}
|
||||
|
||||
function unlockDocument() {
|
||||
documentLockCount = Math.max(0, documentLockCount - 1);
|
||||
if (documentLockCount !== 0) return;
|
||||
|
||||
document.body.style.overflow = bodyOverflow;
|
||||
document.body.style.paddingRight = bodyPaddingRight;
|
||||
backgroundState.forEach(({ element, inert, ariaHidden }) => {
|
||||
element.inert = inert;
|
||||
if (ariaHidden === null) element.removeAttribute('aria-hidden');
|
||||
else element.setAttribute('aria-hidden', ariaHidden);
|
||||
});
|
||||
backgroundState = [];
|
||||
}
|
||||
|
||||
function focusableElements(root: HTMLElement) {
|
||||
return Array.from(root.querySelectorAll<HTMLElement>(focusableSelector))
|
||||
.filter((element) => !element.hidden && element.getClientRects().length > 0);
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
onClose,
|
||||
dirty = false,
|
||||
initialFocusRef,
|
||||
closeGuardTitle = '放弃未保存的修改?',
|
||||
closeGuardDescription = '当前内容尚未保存。放弃后无法恢复,请确认是否关闭。',
|
||||
}: ModalProps) {
|
||||
const titleId = useId();
|
||||
const guardTitleId = useId();
|
||||
const guardDescriptionId = useId();
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const guardRef = useRef<HTMLElement>(null);
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const guardRestoreFocusRef = useRef<HTMLElement | null>(null);
|
||||
const [showCloseGuard, setShowCloseGuard] = useState(false);
|
||||
const [layer] = useState(() => modalLayer());
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
if (dirty) {
|
||||
guardRestoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
setShowCloseGuard(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}, [dirty, onClose]);
|
||||
|
||||
const discardAndClose = useCallback(() => {
|
||||
setShowCloseGuard(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setShowCloseGuard(false);
|
||||
return undefined;
|
||||
}
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return undefined;
|
||||
|
||||
restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
modalStack.push(panel);
|
||||
lockDocument(layer);
|
||||
|
||||
const focusTarget = initialFocusRef?.current ?? focusableElements(panel)[0] ?? panel;
|
||||
requestAnimationFrame(() => focusTarget.focus());
|
||||
|
||||
return () => {
|
||||
const stackIndex = modalStack.lastIndexOf(panel);
|
||||
if (stackIndex >= 0) modalStack.splice(stackIndex, 1);
|
||||
unlockDocument();
|
||||
const restoreTarget = restoreFocusRef.current;
|
||||
queueMicrotask(() => {
|
||||
if (restoreTarget?.isConnected && !restoreTarget.inert) restoreTarget.focus();
|
||||
else modalStack[modalStack.length - 1]?.focus();
|
||||
});
|
||||
};
|
||||
}, [initialFocusRef, layer, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const panel = panelRef.current;
|
||||
if (!panel || modalStack[modalStack.length - 1] !== panel) return;
|
||||
const trapRoot = showCloseGuard ? guardRef.current : panel;
|
||||
if (!trapRoot) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (showCloseGuard) setShowCloseGuard(false);
|
||||
else requestClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const items = focusableElements(trapRoot);
|
||||
if (!items.length) {
|
||||
event.preventDefault();
|
||||
trapRoot.focus();
|
||||
return;
|
||||
}
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && (active === first || !trapRoot.contains(active))) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (active === last || !trapRoot.contains(active))) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [open, requestClose, showCloseGuard]);
|
||||
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
useEffect(() => {
|
||||
if (!showCloseGuard) return;
|
||||
const panel = panelRef.current;
|
||||
if (panel) panel.inert = true;
|
||||
requestAnimationFrame(() => focusableElements(guardRef.current ?? panelRef.current!)[0]?.focus());
|
||||
return () => {
|
||||
if (panel) panel.inert = false;
|
||||
const restoreTarget = guardRestoreFocusRef.current;
|
||||
queueMicrotask(() => {
|
||||
if (restoreTarget?.isConnected && panel?.isConnected) restoreTarget.focus();
|
||||
});
|
||||
};
|
||||
}, [showCloseGuard]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
if (!open) return null;
|
||||
const renderedFooter = typeof footer === 'function' ? footer({ requestClose }) : footer;
|
||||
|
||||
return createPortal(
|
||||
<div className="ui-modal" role="presentation">
|
||||
<button className="ui-modal__mask" type="button" aria-label="关闭弹窗" onClick={onClose} />
|
||||
<section aria-modal="true" className={['ui-modal__panel', `ui-modal__panel--${size}`].join(' ')} role="dialog">
|
||||
<div className="ui-modal" data-ui-modal-root>
|
||||
<div aria-hidden="true" className="ui-modal__mask" onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) requestClose();
|
||||
}} />
|
||||
<section
|
||||
aria-labelledby={titleId}
|
||||
aria-modal="true"
|
||||
className={['ui-modal__panel', `ui-modal__panel--${size}`].join(' ')}
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header className="ui-modal__header">
|
||||
<div className="ui-modal__title">{title}</div>
|
||||
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={onClose}>
|
||||
<div className="ui-modal__title" id={titleId}>{title}</div>
|
||||
<Button icon={<X size={17} />} iconOnly variant="ghost" onClick={requestClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</header>
|
||||
<div className="ui-modal__body">{children}</div>
|
||||
{footer ? <footer className="ui-modal__footer">{footer}</footer> : null}
|
||||
{renderedFooter ? <footer className="ui-modal__footer">{renderedFooter}</footer> : null}
|
||||
</section>
|
||||
{showCloseGuard ? (
|
||||
<div className="ui-modal__guard-layer">
|
||||
<section
|
||||
aria-describedby={guardDescriptionId}
|
||||
aria-labelledby={guardTitleId}
|
||||
aria-modal="true"
|
||||
className="ui-modal__guard"
|
||||
ref={guardRef}
|
||||
role="alertdialog"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<AlertTriangle aria-hidden="true" size={22} />
|
||||
<div>
|
||||
<h2 id={guardTitleId}>{closeGuardTitle}</h2>
|
||||
<p id={guardDescriptionId}>{closeGuardDescription}</p>
|
||||
</div>
|
||||
<footer>
|
||||
<Button autoFocus onClick={() => setShowCloseGuard(false)} variant="ghost">继续编辑</Button>
|
||||
<Button onClick={discardAndClose} variant="danger">放弃并关闭</Button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
layer,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { AlertTriangle, Check, ShieldCheck } from 'lucide-react';
|
||||
import { adminApi, type ReviewDecisionResult, type ReviewPreflight } from '@/api/adminApi';
|
||||
import { Button } from './Button';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
export function RiskAction({
|
||||
targetType,
|
||||
targetId,
|
||||
disabled,
|
||||
children = '通过',
|
||||
icon = <Check size={15} />,
|
||||
onCompleted,
|
||||
}: {
|
||||
targetType: 'signature' | 'template';
|
||||
targetId: string;
|
||||
disabled?: boolean;
|
||||
children?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
onCompleted?: (result: ReviewDecisionResult) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [preflight, setPreflight] = useState<ReviewPreflight | null>(null);
|
||||
const [result, setResult] = useState<ReviewDecisionResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [idempotencyKey, setIdempotencyKey] = useState('');
|
||||
|
||||
async function begin() {
|
||||
const key = `review:${targetType}:${targetId}:${crypto.randomUUID()}`;
|
||||
setOpen(true);
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
setError('');
|
||||
setPreflight(null);
|
||||
setIdempotencyKey(key);
|
||||
try {
|
||||
setPreflight(await adminApi.getReviewPreflight(targetType, targetId));
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '审核资格预检失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!preflight || !preflight.allowedActions.includes('approve')) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const completed = await adminApi.submitReviewDecision(targetType, targetId, {
|
||||
decision: 'approve',
|
||||
expectedUpdatedAt: preflight.expectedUpdatedAt,
|
||||
idempotencyKey,
|
||||
});
|
||||
setResult(completed);
|
||||
onCompleted?.(completed);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '审核提交失败,请刷新后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!submitting) setOpen(false);
|
||||
}
|
||||
|
||||
const blocked = Boolean(preflight && !preflight.allowedActions.includes('approve'));
|
||||
const footer = result ? <Button onClick={close}>关闭</Button> : <>
|
||||
<Button disabled={submitting} onClick={close} variant="ghost">取消</Button>
|
||||
<Button disabled={loading || submitting || blocked || !preflight} icon={<ShieldCheck size={16} />} onClick={() => void confirm()} variant="success">
|
||||
{submitting ? '提交审核中…' : '确认通过'}
|
||||
</Button>
|
||||
</>;
|
||||
|
||||
return <>
|
||||
<Button disabled={disabled} icon={icon} onClick={() => void begin()} size="sm" variant="success">{children}</Button>
|
||||
<Modal footer={footer} onClose={close} open={open} title="审核通过确认">
|
||||
<div className="risk-action-content">
|
||||
{loading ? <p role="status">正在检查审核资格…</p> : null}
|
||||
{preflight ? <>
|
||||
<div className="risk-action-identity">
|
||||
{Object.entries(preflight.identity).map(([key, value]) => <div key={key}><span>{identityLabels[key] ?? key}</span><strong>{value}</strong></div>)}
|
||||
</div>
|
||||
<section><h3>资格检查</h3>{preflight.blockedReasons.length
|
||||
? <ul className="risk-action-blockers">{preflight.blockedReasons.map((item) => <li key={item}><AlertTriangle size={15} />{item}</li>)}</ul>
|
||||
: <p className="risk-action-passed"><ShieldCheck size={16} />必需资料与当前状态检查通过</p>}</section>
|
||||
<section><h3>影响范围</h3><ul>{preflight.impacts.map((item) => <li key={item}>{item}</li>)}</ul></section>
|
||||
</> : null}
|
||||
{result ? <div className="risk-action-result" role="status"><ShieldCheck size={20} /><div><strong>审核已完成</strong><span>操作单号:{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span></div></div> : null}
|
||||
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
</>;
|
||||
}
|
||||
|
||||
const identityLabels: Record<string, string> = { name: '对象名称', id: '唯一标识', tenant: '所属企业', application: '短信应用' };
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Download, RotateCcw } from 'lucide-react';
|
||||
import type { SystemLogExportResult } from '@/api/adminApi';
|
||||
import { Button } from './Button';
|
||||
|
||||
type Filters = { keyword?: string; level?: string; module?: string; range?: string };
|
||||
|
||||
export function SystemLogExport({
|
||||
portal,
|
||||
filters,
|
||||
exportLogs,
|
||||
}: {
|
||||
portal: 'admin' | 'client';
|
||||
filters: Filters;
|
||||
exportLogs: (filters: Filters) => Promise<SystemLogExportResult>;
|
||||
}) {
|
||||
const storageKey = `cmpp:${portal}:system-log-export-recovery`;
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [result, setResult] = useState<SystemLogExportResult | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [retryFilters, setRetryFilters] = useState<Filters | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
setRetryFilters(JSON.parse(saved) as Filters);
|
||||
setError('上次导出未完成,筛选条件已保留,可直接重试。');
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(storageKey);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
async function run(nextFilters: Filters) {
|
||||
setExporting(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
setRetryFilters(nextFilters);
|
||||
sessionStorage.setItem(storageKey, JSON.stringify(nextFilters));
|
||||
try {
|
||||
const exported = await exportLogs(nextFilters);
|
||||
setResult(exported);
|
||||
setRetryFilters(null);
|
||||
sessionStorage.removeItem(storageKey);
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : '日志导出失败,请重试');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!result) return;
|
||||
const url = URL.createObjectURL(new Blob([`\uFEFF${result.content}`], { type: 'text/csv;charset=utf-8' }));
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = result.fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="system-log-export">
|
||||
<Button disabled={exporting} icon={<Download size={17} />} onClick={() => run(filters)} variant="secondary">
|
||||
{exporting ? '导出中…' : '导出日志'}
|
||||
</Button>
|
||||
{result ? (
|
||||
<div className="system-log-export__result" role="status">
|
||||
<span>导出完成,共 {result.recordCount} 条{result.truncated ? '(已截取前 10000 条)' : ''},操作单号 {result.operationId}</span>
|
||||
<Button icon={<Download size={15} />} onClick={download} size="sm" variant="ghost">下载文件</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="system-log-export__error" role="alert">
|
||||
<span>{error}</span>
|
||||
<Button disabled={exporting} icon={<RotateCcw size={15} />} onClick={() => run(retryFilters ?? filters)} size="sm" variant="ghost">重试</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,11 @@ export { DateRangeInput } from './DateRangeInput';
|
||||
export { DateTimeInput } from './DateTimeInput';
|
||||
export { DetailInfoGrid, DetailProgressStats, DetailSection, DetailTitle, getRateTone, ProgressBar, RateCard, RateOverview } from './Detail';
|
||||
export { FileActions } from './FileActions';
|
||||
export { SystemLogExport } from './SystemLogExport';
|
||||
export { RiskAction } from './RiskAction';
|
||||
export { DeleteRiskAction } from './DeleteRiskAction';
|
||||
export { ManualRechargeDialog } from './ManualRechargeDialog';
|
||||
export type { ManualRechargeTarget } from './ManualRechargeDialog';
|
||||
export { Input } from './Input';
|
||||
export { Modal } from './Modal';
|
||||
export { InlineTextPreview, Pagination, QueryPanel } from './PagePrimitives';
|
||||
|
||||
@@ -32,13 +32,16 @@ import {
|
||||
Users,
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { adminApi } from '@/api/adminApi';
|
||||
import { readSession } from '@/api/session';
|
||||
import type { LoginSession } from '@/api/session';
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||
|
||||
export function AdminLayout() {
|
||||
const session = readSession();
|
||||
return <PortalSessionBoundary portal="admin">{(session) => <AdminAuthenticatedLayout session={session} />}</PortalSessionBoundary>;
|
||||
}
|
||||
|
||||
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
const [pendingAudits, setPendingAudits] = useState({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
|
||||
const loadPendingAuditCount = useCallback(() => {
|
||||
adminApi.getDashboard()
|
||||
@@ -51,7 +54,7 @@ export function AdminLayout() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.portal !== 'admin') {
|
||||
if (session.portal !== 'admin' || session.locked) {
|
||||
return;
|
||||
}
|
||||
loadPendingAuditCount();
|
||||
@@ -65,11 +68,7 @@ export function AdminLayout() {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||
};
|
||||
}, [loadPendingAuditCount, session?.portal]);
|
||||
|
||||
if (session?.portal !== 'admin') {
|
||||
return <Navigate to="/admin/login" replace />;
|
||||
}
|
||||
}, [loadPendingAuditCount, session.locked, session.portal]);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -77,6 +76,7 @@ export function AdminLayout() {
|
||||
subtitle="平台运营管理中心"
|
||||
workspaceName="平台运营工作区"
|
||||
loginPath="/admin/login"
|
||||
portal="admin"
|
||||
userName={session.user.displayName}
|
||||
userRole="平台管理员"
|
||||
auditNotifications={[
|
||||
|
||||
+49
-30
@@ -13,15 +13,20 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { adminApi } from '@/api/adminApi';
|
||||
import { portalSessionApi } from '@/api/adminApi';
|
||||
import {
|
||||
clearSession,
|
||||
clearSessionRecovery,
|
||||
dispatchSessionEvent,
|
||||
getLastUserActivityAt,
|
||||
markUserActivity,
|
||||
readSession,
|
||||
saveSessionRecovery,
|
||||
sessionChannel,
|
||||
sessionEvent,
|
||||
setReauthenticationHandler,
|
||||
updateSessionTiming,
|
||||
type Portal,
|
||||
} from '@/api/session';
|
||||
import { Button, Input, Modal } from '@/components/ui';
|
||||
|
||||
@@ -49,6 +54,7 @@ type AppShellProps = {
|
||||
subtitle: string;
|
||||
workspaceName: string;
|
||||
loginPath: string;
|
||||
portal: Portal;
|
||||
userName: string;
|
||||
userRole: string;
|
||||
navSections: ShellNavSection[];
|
||||
@@ -59,6 +65,7 @@ export function AppShell({
|
||||
title,
|
||||
workspaceName,
|
||||
loginPath,
|
||||
portal,
|
||||
userName,
|
||||
userRole,
|
||||
navSections,
|
||||
@@ -76,7 +83,8 @@ export function AppShell({
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [idleWarningSeconds, setIdleWarningSeconds] = useState<number | null>(null);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [locked, setLocked] = useState(() => Boolean(readSession(portal)?.locked));
|
||||
const [routesSuspended, setRoutesSuspended] = useState(() => Boolean(readSession(portal)?.locked));
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [unlockError, setUnlockError] = useState('');
|
||||
const [unlocking, setUnlocking] = useState(false);
|
||||
@@ -107,9 +115,10 @@ export function AppShell({
|
||||
setPasswordSaving(true);
|
||||
setPasswordError('');
|
||||
try {
|
||||
await adminApi.changeOwnPassword({ currentPassword, password: newPassword });
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { message: '密码修改成功,请重新登录' });
|
||||
await portalSessionApi.changeOwnPassword(portal, { currentPassword, password: newPassword });
|
||||
clearSession(portal);
|
||||
clearSessionRecovery(portal);
|
||||
dispatchSessionEvent(portal, 'logout', { message: '密码修改成功,请重新登录' });
|
||||
navigate(loginPath, { replace: true });
|
||||
} catch (error) {
|
||||
setPasswordError(error instanceof Error ? error.message : '修改密码失败');
|
||||
@@ -120,18 +129,19 @@ export function AppShell({
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await adminApi.logout();
|
||||
await portalSessionApi.logout(portal);
|
||||
} finally {
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout');
|
||||
clearSession(portal);
|
||||
clearSessionRecovery(portal);
|
||||
dispatchSessionEvent(portal, 'logout');
|
||||
navigate(loginPath, { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function continueSession() {
|
||||
try {
|
||||
const timing = await adminApi.touchSession();
|
||||
updateSessionTiming(timing);
|
||||
const timing = await portalSessionApi.touch(portal);
|
||||
updateSessionTiming(portal, { ...timing, locked: false });
|
||||
markUserActivity();
|
||||
setIdleWarningSeconds(null);
|
||||
} catch {
|
||||
@@ -147,14 +157,15 @@ export function AppShell({
|
||||
setUnlocking(true);
|
||||
setUnlockError('');
|
||||
try {
|
||||
const timing = await adminApi.unlockSession(unlockPassword);
|
||||
updateSessionTiming(timing);
|
||||
const timing = await portalSessionApi.unlock(portal, unlockPassword);
|
||||
updateSessionTiming(portal, { ...timing, locked: false });
|
||||
markUserActivity();
|
||||
setLocked(false);
|
||||
setRoutesSuspended(false);
|
||||
lockRequested.current = false;
|
||||
setUnlockPassword('');
|
||||
setIdleWarningSeconds(null);
|
||||
dispatchSessionEvent('unlocked');
|
||||
dispatchSessionEvent(portal, 'unlocked');
|
||||
} catch (error) {
|
||||
setUnlockError(error instanceof Error ? error.message : '解锁失败');
|
||||
} finally {
|
||||
@@ -170,8 +181,8 @@ export function AppShell({
|
||||
setReauthenticating(true);
|
||||
setReauthenticationError('');
|
||||
try {
|
||||
const timing = await adminApi.reauthenticate(reauthenticationPassword);
|
||||
updateSessionTiming(timing);
|
||||
const timing = await portalSessionApi.reauthenticate(portal, reauthenticationPassword);
|
||||
updateSessionTiming(portal, timing);
|
||||
reauthenticationResolve.current?.();
|
||||
reauthenticationResolve.current = null;
|
||||
reauthenticationReject.current = null;
|
||||
@@ -204,14 +215,17 @@ export function AppShell({
|
||||
|
||||
const onLocked = () => setLocked(true);
|
||||
const onUnlocked = () => { setLocked(false); markUserActivity(); };
|
||||
const onLogout = () => { clearSession(); navigate(loginPath, { replace: true }); };
|
||||
window.addEventListener('cmpp-session-locked', onLocked);
|
||||
window.addEventListener('cmpp-session-unlocked', onUnlocked);
|
||||
window.addEventListener('cmpp-session-logout', onLogout);
|
||||
const onLogout = () => { clearSession(portal); navigate(loginPath, { replace: true }); };
|
||||
const lockedEvent = sessionEvent(portal, 'locked');
|
||||
const unlockedEvent = sessionEvent(portal, 'unlocked');
|
||||
const logoutEvent = sessionEvent(portal, 'logout');
|
||||
window.addEventListener(lockedEvent, onLocked);
|
||||
window.addEventListener(unlockedEvent, onUnlocked);
|
||||
window.addEventListener(logoutEvent, onLogout);
|
||||
|
||||
let channel: BroadcastChannel | undefined;
|
||||
try {
|
||||
channel = new BroadcastChannel('cmpp-session');
|
||||
channel = new BroadcastChannel(sessionChannel(portal));
|
||||
channel.onmessage = (event: MessageEvent<{ type?: string }>) => {
|
||||
if (event.data?.type === 'locked') onLocked();
|
||||
if (event.data?.type === 'unlocked') onUnlocked();
|
||||
@@ -230,12 +244,17 @@ export function AppShell({
|
||||
}));
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const session = readSession();
|
||||
const session = readSession(portal);
|
||||
if (!session) return;
|
||||
const now = Date.now();
|
||||
if (now >= Date.parse(session.absoluteExpiresAt)) {
|
||||
clearSession();
|
||||
dispatchSessionEvent('logout', { code: 'SESSION_ABSOLUTE_TIMEOUT' });
|
||||
saveSessionRecovery(portal, {
|
||||
returnUrl: `${location.pathname}${location.search}${location.hash}`,
|
||||
code: 'SESSION_ABSOLUTE_TIMEOUT',
|
||||
message: '登录已达到最长有效期,请重新登录后继续。',
|
||||
});
|
||||
clearSession(portal);
|
||||
dispatchSessionEvent(portal, 'logout', { code: 'SESSION_ABSOLUTE_TIMEOUT' });
|
||||
navigate(loginPath, { replace: true });
|
||||
return;
|
||||
}
|
||||
@@ -244,7 +263,7 @@ export function AppShell({
|
||||
lockRequested.current = true;
|
||||
setLocked(true);
|
||||
setIdleWarningSeconds(null);
|
||||
void adminApi.lockSession().catch(() => undefined);
|
||||
void portalSessionApi.lock(portal).catch(() => undefined);
|
||||
} else if (remaining <= 5 * 60 * 1000) {
|
||||
setIdleWarningSeconds(Math.ceil(remaining / 1000));
|
||||
} else {
|
||||
@@ -254,15 +273,15 @@ export function AppShell({
|
||||
|
||||
return () => {
|
||||
activityEvents.forEach((eventName) => window.removeEventListener(eventName, onActivity));
|
||||
window.removeEventListener('cmpp-session-locked', onLocked);
|
||||
window.removeEventListener('cmpp-session-unlocked', onUnlocked);
|
||||
window.removeEventListener('cmpp-session-logout', onLogout);
|
||||
window.removeEventListener(lockedEvent, onLocked);
|
||||
window.removeEventListener(unlockedEvent, onUnlocked);
|
||||
window.removeEventListener(logoutEvent, onLogout);
|
||||
channel?.close();
|
||||
window.clearInterval(timer);
|
||||
setReauthenticationHandler(undefined);
|
||||
reauthenticationReject.current?.(new Error('身份验证已取消'));
|
||||
};
|
||||
}, [loginPath, navigate]);
|
||||
}, [location.hash, location.pathname, location.search, loginPath, navigate, portal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auditTotal <= 0 || typeof window === 'undefined') {
|
||||
@@ -431,7 +450,7 @@ export function AppShell({
|
||||
</header>
|
||||
|
||||
<div className="page-content">
|
||||
<Outlet />
|
||||
{routesSuspended ? null : <Outlet />}
|
||||
</div>
|
||||
</main>
|
||||
<Modal
|
||||
@@ -453,7 +472,7 @@ export function AppShell({
|
||||
open={locked}
|
||||
title="会话已安全锁定"
|
||||
>
|
||||
<p>由于长时间未操作,请输入当前密码继续使用。锁定超过 4 小时后需要完整登录。</p>
|
||||
<p>由于长时间未操作,请输入当前密码继续使用。解锁后将返回当前页面;锁定超过 4 小时后需要完整登录。</p>
|
||||
<Input label="当前密码" onChange={(event) => setUnlockPassword(event.target.value)} type="password" value={unlockPassword} />
|
||||
{unlockError ? <p className="login-error">{unlockError}</p> : null}
|
||||
</Modal>
|
||||
|
||||
@@ -10,22 +10,21 @@ import {
|
||||
ShieldCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { readSession } from '@/api/session';
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||
|
||||
export function ClientLayout() {
|
||||
const session = readSession();
|
||||
if (session?.portal !== 'client') {
|
||||
return <Navigate to="/client/login" replace />;
|
||||
}
|
||||
return <PortalSessionBoundary portal="client">{(session) => <ClientAuthenticatedLayout session={session} />}</PortalSessionBoundary>;
|
||||
}
|
||||
|
||||
function ClientAuthenticatedLayout({ session }: { session: import('@/api/session').LoginSession }) {
|
||||
return (
|
||||
<AppShell
|
||||
title="短信服务平台"
|
||||
subtitle="短信服务控制台"
|
||||
workspaceName={session.user.tenantName ?? '企业客户空间'}
|
||||
loginPath="/client/login"
|
||||
portal="client"
|
||||
userName={session.user.displayName}
|
||||
userRole="企业管理员"
|
||||
navSections={[
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { portalSessionApi } from '@/api/adminApi';
|
||||
import {
|
||||
clearSession,
|
||||
readSession,
|
||||
saveSessionRecovery,
|
||||
writeSession,
|
||||
type LoginSession,
|
||||
type Portal,
|
||||
} from '@/api/session';
|
||||
|
||||
type PortalSessionBoundaryProps = {
|
||||
portal: Portal;
|
||||
children(session: LoginSession): ReactNode;
|
||||
};
|
||||
|
||||
export function PortalSessionBoundary({ portal, children }: PortalSessionBoundaryProps) {
|
||||
const location = useLocation();
|
||||
const targetRoute = useRef(`${location.pathname}${location.search}${location.hash}`);
|
||||
const [state, setState] = useState<{ checking: boolean; session: LoginSession | null }>({
|
||||
checking: true,
|
||||
session: readSession(portal),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setState((current) => ({ ...current, checking: true }));
|
||||
portalSessionApi.current(portal)
|
||||
.then((session) => {
|
||||
if (!active) return;
|
||||
writeSession(session);
|
||||
setState({ checking: false, session });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!active) return;
|
||||
clearSession(portal);
|
||||
saveSessionRecovery(portal, {
|
||||
returnUrl: targetRoute.current,
|
||||
message: error instanceof Error ? error.message : '登录会话已失效,请重新登录',
|
||||
});
|
||||
setState({ checking: false, session: null });
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [portal]);
|
||||
|
||||
if (state.checking) {
|
||||
return (
|
||||
<main aria-busy="true" aria-live="polite" className="page-loading-state">
|
||||
<p>正在恢复登录会话…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (!state.session) return <Navigate to={`/${portal}/login`} replace />;
|
||||
return children(state.session);
|
||||
}
|
||||
@@ -841,13 +841,41 @@
|
||||
|
||||
.ui-modal__mask {
|
||||
background: rgba(18, 18, 26, 0.48);
|
||||
border: 0;
|
||||
cursor: default;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-modal__guard-layer {
|
||||
align-items: center;
|
||||
background: rgba(18, 18, 26, 0.32);
|
||||
display: flex;
|
||||
inset: 0;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ui-modal__guard {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
max-width: 100%;
|
||||
padding: var(--space-6);
|
||||
width: 460px;
|
||||
}
|
||||
|
||||
.ui-modal__guard > svg { color: var(--color-warning); margin-top: 2px; }
|
||||
.ui-modal__guard h2 { font-size: var(--font-size-lg); margin: 0 0 var(--space-2); }
|
||||
.ui-modal__guard p { color: var(--color-text-muted); line-height: var(--line-height-relaxed); margin: 0; }
|
||||
.ui-modal__guard footer { display: flex; gap: var(--space-3); grid-column: 1 / -1; justify-content: flex-end; }
|
||||
|
||||
.ui-modal__panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid rgba(18, 18, 26, 0.08);
|
||||
@@ -1247,6 +1275,9 @@
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.ui-modal__guard { padding: var(--space-5); }
|
||||
.ui-modal__guard footer { display: grid; }
|
||||
|
||||
.ui-query-panel__grid,
|
||||
.ui-detail-info-grid,
|
||||
.ui-detail-progress-stats,
|
||||
|
||||
@@ -4568,6 +4568,24 @@ h3 {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.login-session-notice {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(37, 99, 235, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-strong);
|
||||
background: rgba(37, 99, 235, 0.08);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.page-loading-state {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.login-alert-message {
|
||||
margin: 0;
|
||||
color: var(--color-text-strong);
|
||||
@@ -4584,6 +4602,57 @@ h3 {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.system-log-export {
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(100%, 680px);
|
||||
}
|
||||
|
||||
.system-log-export__result,
|
||||
.system-log-export__error {
|
||||
align-items: center;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.system-log-export__result { background: var(--color-success-soft); color: var(--color-success-strong); }
|
||||
.system-log-export__error { background: var(--color-danger-soft); color: var(--color-danger-strong); }
|
||||
|
||||
.risk-action-content { display: grid; gap: 18px; }
|
||||
.risk-action-content section { display: grid; gap: 8px; }
|
||||
.risk-action-content h3 { font-size: 14px; margin: 0; }
|
||||
.risk-action-content ul { margin: 0; padding-left: 20px; }
|
||||
.risk-action-identity { background: var(--color-surface-muted); border-radius: var(--radius-lg); display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 14px; }
|
||||
.risk-action-identity div { display: grid; gap: 3px; min-width: 0; }
|
||||
.risk-action-identity span { color: var(--color-text-muted); font-size: 12px; }
|
||||
.risk-action-identity strong { overflow-wrap: anywhere; }
|
||||
.risk-action-blockers { color: var(--color-danger-strong); list-style: none; padding: 0 !important; }
|
||||
.risk-action-blockers li, .risk-action-passed { align-items: center; display: flex; gap: 7px; }
|
||||
.risk-action-passed { color: var(--color-success-strong); margin: 0; }
|
||||
.risk-action-result { align-items: center; background: var(--color-success-soft); border-radius: var(--radius-lg); color: var(--color-success-strong); display: flex; gap: 10px; padding: 14px; }
|
||||
.risk-action-result div { display: grid; gap: 4px; }
|
||||
.risk-action-result span { font-size: 12px; overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 640px) { .risk-action-identity { grid-template-columns: 1fr; } }
|
||||
.delete-risk-dependencies { display: grid; gap: 8px; list-style: none; padding: 0 !important; }
|
||||
.delete-risk-dependencies li { align-items: center; background: var(--color-surface-muted); border-radius: var(--radius-md); display: grid; gap: 3px; grid-template-columns: 1fr auto; padding: 10px 12px; }
|
||||
.delete-risk-dependencies small { color: var(--color-text-muted); grid-column: 1 / -1; overflow-wrap: anywhere; }
|
||||
.delete-risk-action .ui-textarea { min-height: 88px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.system-page-toolbar { align-items: stretch; flex-direction: column; }
|
||||
.system-log-export { align-items: stretch; max-width: none; }
|
||||
.system-log-export > .ui-button { min-height: 44px; width: 100%; }
|
||||
.system-log-export__result,
|
||||
.system-log-export__error { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
|
||||
.system-filter-row {
|
||||
max-width: 520px;
|
||||
}
|
||||
@@ -7298,6 +7367,8 @@ h3 {
|
||||
.report-material-table-head { background: var(--surface-muted); color: var(--text-muted); font-size: 12px; font-weight: 700; }
|
||||
.report-material-row { border-top: 1px solid var(--border); cursor: pointer; }
|
||||
.report-material-row:hover { background: color-mix(in srgb, var(--primary) 3%, var(--surface)); }
|
||||
.report-material-row.is-disabled { cursor: not-allowed; opacity: 0.72; }
|
||||
.report-material-row.is-disabled:hover { background: var(--surface); }
|
||||
.report-material-row > span { display: grid; gap: 3px; }
|
||||
.report-material-row small, .report-material-row em { color: var(--text-muted); font-size: 12px; font-style: normal; }
|
||||
.report-material-batches { display: grid; gap: 0; }
|
||||
@@ -7305,6 +7376,16 @@ h3 {
|
||||
.report-material-batches article > div { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; }
|
||||
.report-material-batches article small { width: 100%; color: var(--text-muted); }
|
||||
.report-material-batches article a { display: inline-flex; align-items: center; gap: 5px; color: var(--primary); }
|
||||
.report-batch-preflight { display: grid; gap: 16px; }
|
||||
.report-batch-summary { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.report-batch-summary span { display: inline-flex; align-items: center; gap: 7px; padding: 9px 12px; border-radius: 8px; background: var(--surface-muted); font-weight: 700; }
|
||||
.report-batch-preflight article { display: grid; gap: 10px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; }
|
||||
.report-batch-preflight article > div { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; }
|
||||
.report-batch-preflight article small { color: var(--text-muted); }
|
||||
.report-batch-preflight ul { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
|
||||
.report-batch-preflight li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; padding: 9px 10px; border-radius: 8px; background: var(--surface-muted); }
|
||||
.report-batch-preflight li.is-eligible { border-left: 3px solid var(--success); }
|
||||
.report-batch-preflight li.is-blocked { border-left: 3px solid var(--warning); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.channel-field-mapping-grid, .report-import-basic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
@@ -9490,6 +9571,99 @@ h3 {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.manual-recharge-review,
|
||||
.manual-recharge-result {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.manual-recharge-review > p {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.manual-recharge-review dl {
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.manual-recharge-review dl > div {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
grid-template-columns: minmax(110px, 0.7fr) minmax(0, 1.5fr);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
}
|
||||
|
||||
.manual-recharge-review dl > div:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.manual-recharge-review dt {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.manual-recharge-review dd {
|
||||
display: grid;
|
||||
font-variant-numeric: tabular-nums;
|
||||
gap: var(--space-1);
|
||||
justify-items: end;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.manual-recharge-review dd span {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.manual-recharge-review .is-positive {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.manual-recharge-review .is-negative {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.manual-recharge-review__remark {
|
||||
background: var(--color-warning-soft);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-strong) !important;
|
||||
padding: var(--space-4);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.manual-recharge-result {
|
||||
background: var(--color-success-soft);
|
||||
border: 1px solid var(--color-success);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-strong);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.manual-recharge-result > strong {
|
||||
color: var(--color-success);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.manual-recharge-review dl > div {
|
||||
align-items: start;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.manual-recharge-review dd {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-recharge-pagination {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--color-border);
|
||||
@@ -10166,6 +10340,63 @@ h3 {
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.enterprise-flow-card {
|
||||
min-height: 0;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.enterprise-stepper {
|
||||
justify-content: flex-start;
|
||||
margin-bottom: var(--space-8);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.enterprise-stepper__item {
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.enterprise-stepper__dot {
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.enterprise-stepper__arrow {
|
||||
flex: 0 0 auto;
|
||||
margin: 0 var(--space-2);
|
||||
}
|
||||
|
||||
.enterprise-form-panel,
|
||||
.enterprise-upload {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.enterprise-upload {
|
||||
padding: var(--space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.enterprise-upload strong {
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.enterprise-upload .file-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.enterprise-address-selects > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.enterprise-address-selects .ui-field {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.client-signature-heading,
|
||||
.client-drainage-panel__head {
|
||||
align-items: stretch;
|
||||
|
||||
Reference in New Issue
Block a user