322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
import { useCallback, useState } from 'react';
|
||
import {
|
||
Activity,
|
||
AlertTriangle,
|
||
BarChart3,
|
||
Building2,
|
||
FileCheck2,
|
||
FileText,
|
||
FileSpreadsheet,
|
||
FilePenLine,
|
||
Gauge,
|
||
Hash,
|
||
ImageIcon,
|
||
ListChecks,
|
||
Layers3,
|
||
MessageSquare,
|
||
Phone,
|
||
RefreshCw,
|
||
TrendingUp,
|
||
RadioTower,
|
||
ReceiptText,
|
||
ClipboardList,
|
||
Send,
|
||
ServerCog,
|
||
ScanSearch,
|
||
Settings,
|
||
Shield,
|
||
ShieldOff,
|
||
ShieldCheck,
|
||
Users,
|
||
UserX,
|
||
} from 'lucide-react';
|
||
import { adminApi, type PendingAuditCounts } from '@/api/adminApi';
|
||
import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/session';
|
||
import { AppShell } from '@/layouts/AppShell';
|
||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||
import { request } from '@/api/core/httpClient';
|
||
import { settleNotificationRequest, useNotificationPolling } from './useNotificationPolling';
|
||
|
||
const EMPTY_PENDING_AUDITS: Omit<PendingAuditCounts, 'total'> = {
|
||
enterpriseCertifications: 0,
|
||
smsAudits: 0,
|
||
templates: 0,
|
||
signatures: 0,
|
||
drainageInfos: 0,
|
||
};
|
||
|
||
type NotificationValue<T> = { value: T; status: 'loading' | 'ready' | 'unavailable'; hasValue: boolean };
|
||
|
||
function initialNotification<T>(value: T): NotificationValue<T> {
|
||
return { value, status: 'loading', hasValue: false };
|
||
}
|
||
|
||
function updateNotification<T>(
|
||
previous: NotificationValue<T>,
|
||
result: PromiseSettledResult<T>,
|
||
available = true,
|
||
): NotificationValue<T> {
|
||
return result.status === 'fulfilled' && available
|
||
? { value: result.value, status: 'ready', hasValue: true }
|
||
: { ...previous, status: 'unavailable' };
|
||
}
|
||
|
||
function notificationDescription<T>(notification: NotificationValue<T>, description: string) {
|
||
if (notification.status === 'loading') return '计数加载中';
|
||
if (notification.status === 'unavailable') {
|
||
return notification.hasValue ? '计数暂不可用,显示上次结果' : '计数暂不可用,尚未取得结果';
|
||
}
|
||
return description;
|
||
}
|
||
|
||
function canPollNotifications(userId: string) {
|
||
const session = readSession('admin');
|
||
return Boolean(
|
||
session &&
|
||
session.user.id === userId &&
|
||
!session.locked &&
|
||
Date.now() - getLastUserActivityAt() < session.idleTimeoutSeconds * 1000,
|
||
);
|
||
}
|
||
|
||
export function AdminLayout() {
|
||
return (
|
||
<PortalSessionBoundary portal="admin">
|
||
{(session) => <AdminAuthenticatedLayout key={session.user.id} session={session} />}
|
||
</PortalSessionBoundary>
|
||
);
|
||
}
|
||
|
||
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||
const [pendingAudits, setPendingAudits] = useState(() => initialNotification(EMPTY_PENDING_AUDITS));
|
||
const [retirementSummary, setRetirementSummary] = useState(() => initialNotification({ count: 0 }));
|
||
const [reportSummary, setReportSummary] = useState(() => initialNotification({ count: 0 }));
|
||
const [monitorSummary, setMonitorSummary] = useState(() => initialNotification({ count: 0 }));
|
||
const [securityAlertSummary, setSecurityAlertSummary] = useState(() =>
|
||
initialNotification({ count: 0, criticalCount: 0 }),
|
||
);
|
||
const [infrastructureAlertSummary, setInfrastructureAlertSummary] = useState(() =>
|
||
initialNotification({ count: 0, criticalCount: 0 }),
|
||
);
|
||
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
|
||
const canPoll = useCallback(() => canPollNotifications(session.user.id), [session.user.id]);
|
||
const loadPendingAuditCount = useCallback(async (signal: AbortSignal, isCurrent: () => boolean) => {
|
||
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
|
||
const results = await Promise.allSettled([
|
||
settleNotificationRequest(adminApi.getPendingAudits(undefined, signal), signal),
|
||
settleNotificationRequest(adminApi.getSignatureRetirementUnreadCount(signal), signal),
|
||
settleNotificationRequest(adminApi.getSecurityNotificationSummary(signal), signal),
|
||
settleNotificationRequest(adminApi.getInfrastructureMonitoringNotificationSummary(signal), signal),
|
||
settleNotificationRequest(request<{ count: number }>('/admin/report-notifications/summary', { signal }), signal),
|
||
settleNotificationRequest(
|
||
request<{ count: number; unavailable?: boolean }>('/admin/sending-monitor/notification-summary', { signal }),
|
||
signal,
|
||
),
|
||
]);
|
||
if (!isCurrent()) return false;
|
||
const [audits, retirement, security, infrastructure, reporting, monitor] = results;
|
||
const monitorAvailable = monitor.status === 'fulfilled' && !monitor.value.unavailable;
|
||
setPendingAudits((previous) => updateNotification(previous, audits));
|
||
setRetirementSummary((previous) => updateNotification(previous, retirement));
|
||
setSecurityAlertSummary((previous) => updateNotification(previous, security));
|
||
setInfrastructureAlertSummary((previous) => updateNotification(previous, infrastructure));
|
||
setReportSummary((previous) => updateNotification(previous, reporting));
|
||
setMonitorSummary((previous) => updateNotification(previous, monitor, monitorAvailable));
|
||
return results.every((result) => result.status === 'fulfilled') && monitorAvailable;
|
||
}, []);
|
||
|
||
useNotificationPolling({
|
||
enabled: session.portal === 'admin' && !sessionLocked,
|
||
canPoll,
|
||
poll: loadPendingAuditCount,
|
||
});
|
||
const auditLabel = (label: string) =>
|
||
pendingAudits.status === 'ready'
|
||
? label
|
||
: `${label}(${pendingAudits.status === 'loading' ? '计数加载中' : '计数暂不可用'})`;
|
||
|
||
return (
|
||
<AppShell
|
||
title="聆界短信平台运营端"
|
||
subtitle="平台运营管理中心"
|
||
workspaceName="平台运营工作区"
|
||
loginPath="/admin/login"
|
||
portal="admin"
|
||
userName={session.user.displayName}
|
||
userRole="平台管理员"
|
||
onSessionLockedChange={setSessionLocked}
|
||
reportingNotifications={[
|
||
{
|
||
label: '报备状态变化通知',
|
||
count: reportSummary.value.count,
|
||
description: notificationDescription(reportSummary, '按企业与小时汇总的未读消息'),
|
||
to: '/admin/report-records?tab=readiness',
|
||
},
|
||
{
|
||
label: '签名清退预警',
|
||
count: retirementSummary.value.count,
|
||
description: notificationDescription(retirementSummary, '今日未读且未抑制'),
|
||
to: '/admin/signature-retirement',
|
||
},
|
||
{
|
||
label: '报备进度提醒',
|
||
description: '报备进度通知功能尚未开放',
|
||
pending: true,
|
||
},
|
||
]}
|
||
alertNotifications={[
|
||
{
|
||
label: '发送质量告警',
|
||
count: monitorSummary.value.count,
|
||
description: notificationDescription(monitorSummary, '未读活动发送质量告警'),
|
||
to: '/admin/monitor?tab=alerts',
|
||
},
|
||
{
|
||
label: '安全检测与封禁',
|
||
count: securityAlertSummary.value.count,
|
||
description: notificationDescription(
|
||
securityAlertSummary,
|
||
securityAlertSummary.value.criticalCount > 0
|
||
? `${securityAlertSummary.value.criticalCount} 条严重告警待处置`
|
||
: '待处置安全告警',
|
||
),
|
||
to: '/admin/security-detection',
|
||
},
|
||
{
|
||
label: '系统监控告警',
|
||
count: infrastructureAlertSummary.value.count,
|
||
description: notificationDescription(
|
||
infrastructureAlertSummary,
|
||
infrastructureAlertSummary.value.criticalCount > 0
|
||
? `${infrastructureAlertSummary.value.criticalCount} 条 Prometheus 严重告警`
|
||
: 'Prometheus 活动告警',
|
||
),
|
||
to: '/admin/system-monitoring#active-alerts',
|
||
},
|
||
]}
|
||
auditNotifications={[
|
||
{
|
||
label: auditLabel('企业认证待审'),
|
||
count: pendingAudits.value.enterpriseCertifications,
|
||
to: '/admin/enterprise-audit',
|
||
},
|
||
{ label: auditLabel('短信审核待审'), count: pendingAudits.value.smsAudits, to: '/admin/sms-audit' },
|
||
{ label: auditLabel('模板待审'), count: pendingAudits.value.templates, to: '/admin/templates' },
|
||
{ label: auditLabel('签名待审'), count: pendingAudits.value.signatures, to: '/admin/signatures' },
|
||
{
|
||
label: auditLabel('签名导入待审'),
|
||
count: pendingAudits.value.signatureImports ?? 0,
|
||
to: '/admin/signatures?tab=import',
|
||
},
|
||
{ label: auditLabel('引流信息待审'), count: pendingAudits.value.drainageInfos, to: '/admin/drainage-audits' },
|
||
]}
|
||
navSections={[
|
||
{
|
||
title: '运营概览',
|
||
icon: Gauge,
|
||
items: [
|
||
{ label: '运营看板', to: '/admin', icon: Gauge },
|
||
{ label: '发送监控', to: '/admin/monitor', icon: Activity },
|
||
{ label: '网关异常', to: '/admin/gateway-submit-exceptions', icon: AlertTriangle },
|
||
{ label: '签名质量检测', to: '/admin/analytics', icon: BarChart3 },
|
||
],
|
||
},
|
||
{
|
||
title: '客户管理',
|
||
icon: Building2,
|
||
items: [
|
||
{ label: '企业管理', to: '/admin/customer-enterprises', icon: Building2 },
|
||
{ label: '企业应用管理', to: '/admin/enterprise-applications', icon: Layers3 },
|
||
{ label: '企业签名管理', to: '/admin/enterprise-signatures', icon: FilePenLine },
|
||
{ label: '企业模板管理', to: '/admin/enterprise-templates', icon: FileCheck2 },
|
||
{ label: 'HTTP签名计算', to: '/admin/http-signature', icon: Hash },
|
||
],
|
||
},
|
||
{
|
||
title: '审核中心',
|
||
icon: FileCheck2,
|
||
items: [
|
||
{ label: '企业认证审核', to: '/admin/enterprise-audit', icon: ShieldCheck },
|
||
{ label: '短信审核', to: '/admin/sms-audit', icon: MessageSquare },
|
||
{ label: '短信模板审核', to: '/admin/templates', icon: FileCheck2 },
|
||
{ label: '短信签名审核', to: '/admin/signatures', icon: FilePenLine },
|
||
{ label: '引流信息审核', to: '/admin/drainage-audits', icon: FilePenLine },
|
||
],
|
||
},
|
||
{
|
||
title: '通道管理',
|
||
icon: RadioTower,
|
||
items: [
|
||
{ label: '短信通道管理', to: '/admin/channels', icon: RadioTower },
|
||
{ label: '彩信通道管理', to: '/admin/mms-channels', icon: ImageIcon, pending: true },
|
||
{ label: '短信通道组管理', to: '/admin/channel-groups', icon: Layers3 },
|
||
],
|
||
},
|
||
{
|
||
title: '报备工作台',
|
||
icon: ClipboardList,
|
||
items: [
|
||
{ label: '报备资料池', to: '/admin/report-materials', icon: FileSpreadsheet },
|
||
{ label: '报备批次', to: '/admin/report-batches', icon: Layers3 },
|
||
{ label: '通道报备明细', to: '/admin/report-tasks', icon: ClipboardList },
|
||
{ label: '状态记录', to: '/admin/report-records', icon: ListChecks },
|
||
],
|
||
},
|
||
{
|
||
title: '发送任务',
|
||
icon: Send,
|
||
items: [
|
||
{ label: '短信任务进度', to: '/admin/sms-task-progress', icon: TrendingUp },
|
||
{ label: '彩信任务进度', to: '/admin/mms-task-progress', icon: BarChart3, pending: true },
|
||
],
|
||
},
|
||
{
|
||
title: '数据详单',
|
||
icon: ReceiptText,
|
||
items: [
|
||
{ label: '短信记录', to: '/admin/sms-records', icon: MessageSquare },
|
||
{ label: '彩信记录', to: '/admin/mms-records', icon: ImageIcon, pending: true },
|
||
{ label: '短信上行记录', to: '/admin/sms-uplink-records', icon: MessageSquare },
|
||
{ label: '下游投递记录', to: '/admin/downstream-deliveries', icon: Send },
|
||
{ label: '恢复状态管理', to: '/admin/downstream-recovery-statuses', icon: RefreshCw },
|
||
{ label: '充值记录', to: '/admin/recharge-records', icon: ReceiptText },
|
||
],
|
||
},
|
||
{
|
||
title: '报表对账',
|
||
icon: BarChart3,
|
||
items: [
|
||
{ label: '对账单', to: '/admin/reconciliation-reports', icon: ReceiptText },
|
||
{ label: '利润报表', to: '/admin/profit-reports', icon: TrendingUp },
|
||
{ label: '发送质量报表', to: '/admin/quality-reports', icon: BarChart3 },
|
||
],
|
||
},
|
||
{
|
||
title: '安全控制',
|
||
icon: Shield,
|
||
items: [
|
||
{ label: '风控规则', to: '/admin/risk-rules', icon: Shield },
|
||
{ label: '安全检测与封禁', to: '/admin/security-detection', icon: ShieldCheck },
|
||
{ label: '签名清退预警', to: '/admin/signature-retirement', icon: AlertTriangle },
|
||
{ label: '企业黑名单', to: '/admin/enterprise-blacklist', icon: UserX },
|
||
{ label: '全局黑名单', to: '/admin/global-blacklist', icon: ShieldOff },
|
||
{ label: '敏感词管理', to: '/admin/sensitive-words', icon: Shield },
|
||
],
|
||
},
|
||
{
|
||
title: '系统管理',
|
||
icon: Settings,
|
||
items: [
|
||
{ label: '用户管理', to: '/admin/users', icon: Users },
|
||
{ label: '手机号段库', to: '/admin/phone-segments', icon: Phone },
|
||
{ label: '报备字段库', to: '/admin/drainage-fields', icon: Hash },
|
||
{ label: '引流识别规则', to: '/admin/drainage-detection-rules', icon: ScanSearch },
|
||
{ label: '系统日志', to: '/admin/system-logs', icon: FileText },
|
||
{ label: '系统监控', to: '/admin/system-monitoring', icon: ServerCog },
|
||
],
|
||
},
|
||
]}
|
||
/>
|
||
);
|
||
}
|