feat: harden platform workflows and UI governance

This commit is contained in:
hectorzhao
2026-07-22 14:14:55 +08:00
parent ef957f7daa
commit 0f223f7f91
80 changed files with 4958 additions and 764 deletions
+222 -62
View File
@@ -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
View File
@@ -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;
}
+9 -2
View File
@@ -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">
+6 -19
View File
@@ -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)}
+15 -70
View File
@@ -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>
);
}
+19 -70
View File
@@ -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>
);
}
+40 -10
View File
@@ -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 };
}
+3 -7
View File
@@ -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">
+3 -3
View File
@@ -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">
+7 -17
View File
@@ -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;
+1 -1
View File
@@ -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('');
+1 -1
View File
@@ -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}
-3
View File
@@ -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]);
+2 -2
View File
@@ -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">
+4 -5
View File
@@ -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}
+3 -2
View File
@@ -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">
+7 -13
View File
@@ -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>
+1 -1
View File
@@ -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('');
+85
View File
@@ -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: '关联签名' };
+4 -3
View File
@@ -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()}>
+162
View File
@@ -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
View File
@@ -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,
);
}
+99
View File
@@ -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: '短信应用' };
+82
View File
@@ -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
View File
@@ -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';
+9 -9
View File
@@ -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
View File
@@ -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>
+5 -6
View File
@@ -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={[
+56
View File
@@ -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);
}
+32 -1
View File
@@ -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,
+231
View File
@@ -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;