feat: improve operations diagnostics and channel management
This commit is contained in:
@@ -34,7 +34,7 @@ import {
|
||||
UserX,
|
||||
} from 'lucide-react';
|
||||
import { adminApi } from '@/api/adminApi';
|
||||
import type { LoginSession } from '@/api/session';
|
||||
import { getLastUserActivityAt, readSession, type LoginSession } from '@/api/session';
|
||||
import { AppShell } from '@/layouts/AppShell';
|
||||
import { PortalSessionBoundary } from '@/layouts/PortalSessionBoundary';
|
||||
|
||||
@@ -46,7 +46,13 @@ export function AdminLayout() {
|
||||
|
||||
function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
const [pendingAudits, setPendingAudits] = useState(EMPTY_PENDING_AUDITS);
|
||||
const [sessionLocked, setSessionLocked] = useState(Boolean(session.locked));
|
||||
const loadPendingAuditCount = useCallback(() => {
|
||||
const currentSession = readSession('admin');
|
||||
if (!currentSession || currentSession.locked
|
||||
|| Date.now() - getLastUserActivityAt() >= currentSession.idleTimeoutSeconds * 1000) {
|
||||
return;
|
||||
}
|
||||
// This runs globally and on a timer, so it must not fan out through the full dashboard aggregation.
|
||||
adminApi.getPendingAudits()
|
||||
.then((counts) => {
|
||||
@@ -58,7 +64,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (session.portal !== 'admin' || session.locked) {
|
||||
if (session.portal !== 'admin' || sessionLocked) {
|
||||
return;
|
||||
}
|
||||
loadPendingAuditCount();
|
||||
@@ -72,7 +78,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('cmpp-audit-count-refresh', onAuditRefresh);
|
||||
};
|
||||
}, [loadPendingAuditCount, session.locked, session.portal]);
|
||||
}, [loadPendingAuditCount, session.portal, sessionLocked]);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
@@ -83,6 +89,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
portal="admin"
|
||||
userName={session.user.displayName}
|
||||
userRole="平台管理员"
|
||||
onSessionLockedChange={setSessionLocked}
|
||||
auditNotifications={[
|
||||
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
|
||||
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
|
||||
@@ -120,7 +127,6 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
{ label: '短信模板审核', to: '/admin/templates', icon: FileCheck2 },
|
||||
{ label: '短信签名审核', to: '/admin/signatures', icon: FilePenLine },
|
||||
{ label: '引流信息审核', to: '/admin/drainage-audits', icon: FilePenLine },
|
||||
{ label: '风控规则', to: '/admin/risk-rules', icon: Shield },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -174,6 +180,7 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
title: '安全控制',
|
||||
icon: Shield,
|
||||
items: [
|
||||
{ label: '风控规则', to: '/admin/risk-rules', icon: Shield },
|
||||
{ label: '企业黑名单', to: '/admin/enterprise-blacklist', icon: UserX },
|
||||
{ label: '全局黑名单', to: '/admin/global-blacklist', icon: ShieldOff },
|
||||
{ label: '敏感词管理', to: '/admin/sensitive-words', icon: Shield },
|
||||
|
||||
@@ -59,6 +59,7 @@ type AppShellProps = {
|
||||
userRole: string;
|
||||
navSections: ShellNavSection[];
|
||||
auditNotifications?: AuditNotificationItem[];
|
||||
onSessionLockedChange?: (locked: boolean) => void;
|
||||
};
|
||||
|
||||
export function AppShell({
|
||||
@@ -70,6 +71,7 @@ export function AppShell({
|
||||
userRole,
|
||||
navSections,
|
||||
auditNotifications = [],
|
||||
onSessionLockedChange,
|
||||
}: AppShellProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
@@ -199,6 +201,10 @@ export function AppShell({
|
||||
setMobileNavOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
onSessionLockedChange?.(locked);
|
||||
}, [locked, onSessionLockedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileNavOpen) return;
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
@@ -213,8 +219,21 @@ export function AppShell({
|
||||
const onActivity = () => markUserActivity();
|
||||
activityEvents.forEach((eventName) => window.addEventListener(eventName, onActivity, { passive: true }));
|
||||
|
||||
const onLocked = () => setLocked(true);
|
||||
const onUnlocked = () => { setLocked(false); markUserActivity(); };
|
||||
const onLocked = () => {
|
||||
updateSessionTiming(portal, { locked: true });
|
||||
setLocked(true);
|
||||
// Business routes must be unmounted while the server session is locked.
|
||||
// This prevents background list and badge requests from repeatedly
|
||||
// receiving 401 responses and guarantees a fresh read after unlocking.
|
||||
setRoutesSuspended(true);
|
||||
};
|
||||
const onUnlocked = () => {
|
||||
updateSessionTiming(portal, { locked: false });
|
||||
setLocked(false);
|
||||
setRoutesSuspended(false);
|
||||
lockRequested.current = false;
|
||||
markUserActivity();
|
||||
};
|
||||
const onLogout = () => { clearSession(portal); navigate(loginPath, { replace: true }); };
|
||||
const lockedEvent = sessionEvent(portal, 'locked');
|
||||
const unlockedEvent = sessionEvent(portal, 'unlocked');
|
||||
@@ -261,8 +280,8 @@ export function AppShell({
|
||||
const remaining = session.idleTimeoutSeconds * 1000 - (now - getLastUserActivityAt());
|
||||
if (remaining <= 0 && !lockRequested.current) {
|
||||
lockRequested.current = true;
|
||||
setLocked(true);
|
||||
setIdleWarningSeconds(null);
|
||||
dispatchSessionEvent(portal, 'locked', { reason: 'client_idle_timer' });
|
||||
void portalSessionApi.lock(portal).catch(() => undefined);
|
||||
} else if (remaining <= 5 * 60 * 1000) {
|
||||
setIdleWarningSeconds(Math.ceil(remaining / 1000));
|
||||
|
||||
Reference in New Issue
Block a user