feat: 新增报备任务提醒并调整预警分组
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { AdminLayout } from './AdminLayout';
|
||||
import { AppShell } from './AppShell';
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getPendingAudits: vi.fn(),
|
||||
getSignatureRetirementUnreadCount: vi.fn(),
|
||||
getSecurityNotificationSummary: vi.fn(),
|
||||
getInfrastructureMonitoringNotificationSummary: vi.fn(),
|
||||
current: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/adminApi', () => ({ adminApi: api, portalSessionApi: { current: api.current } }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, 'alert').mockImplementation(() => undefined);
|
||||
api.current.mockResolvedValue({
|
||||
portal: 'admin',
|
||||
user: { id: 'admin-1', username: 'admin', displayName: '验收管理员', roles: ['platform_admin'] },
|
||||
idleTimeoutSeconds: 3600,
|
||||
absoluteExpiresAt: new Date(Date.now() + 86400000).toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
});
|
||||
api.getPendingAudits.mockResolvedValue({
|
||||
enterpriseCertifications: 1,
|
||||
smsAudits: 0,
|
||||
templates: 0,
|
||||
signatures: 0,
|
||||
signatureImports: 3,
|
||||
drainageInfos: 0,
|
||||
total: 4,
|
||||
});
|
||||
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 7 });
|
||||
api.getSecurityNotificationSummary.mockResolvedValue({ count: 2, criticalCount: 1 });
|
||||
api.getInfrastructureMonitoringNotificationSummary.mockResolvedValue({ count: 5, criticalCount: 0 });
|
||||
});
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <p data-testid="route">{location.pathname + location.search}</p>;
|
||||
}
|
||||
|
||||
async function renderAdmin() {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/admin']}>
|
||||
<Routes>
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route path="*" element={<LocationProbe />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '报备任务提醒' })).toHaveTextContent('7'), {
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
it('separates retirement counts from security/monitoring and leaves audit counts intact', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderAdmin();
|
||||
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('7');
|
||||
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
|
||||
await user.click(screen.getByRole('button', { name: '预警通知' }));
|
||||
const alerts = screen.getByRole('menu', { name: '预警中心' });
|
||||
expect(within(alerts).queryByRole('menuitem', { name: /签名清退/ })).not.toBeInTheDocument();
|
||||
expect(within(alerts).getAllByRole('menuitem')).toHaveLength(2);
|
||||
expect(within(alerts).getByText('1 条严重告警待处置')).toBeVisible();
|
||||
await user.click(screen.getByRole('button', { name: '报备任务提醒' }));
|
||||
expect(screen.queryByRole('menu', { name: '预警中心' })).not.toBeInTheDocument();
|
||||
const reporting = screen.getByRole('menu', { name: '报备任务提醒' });
|
||||
expect(within(reporting).getByRole('menuitem', { name: /签名清退预警/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/signature-retirement',
|
||||
);
|
||||
expect(within(reporting).getByText('7 条')).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps the pending progress button disabled without a route or fabricated count', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderAdmin();
|
||||
await user.click(screen.getByRole('button', { name: '报备任务提醒' }));
|
||||
const pending = screen.getByRole('menuitem', { name: /报备进度提醒/ });
|
||||
expect(pending).toBeDisabled();
|
||||
expect(pending).not.toHaveAttribute('href');
|
||||
expect(within(pending).getByText('待开发')).toBeVisible();
|
||||
expect(pending.querySelector('strong')).toBeNull();
|
||||
await user.click(pending);
|
||||
expect(screen.getByTestId('route')).toHaveTextContent('/admin');
|
||||
expect(screen.getByRole('menu', { name: '报备任务提醒' })).toBeVisible();
|
||||
expect(api.getSignatureRetirementUnreadCount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles one menu at a time and closes the reporting menu when navigating', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderAdmin();
|
||||
const reporting = screen.getByRole('button', { name: '报备任务提醒' });
|
||||
await user.click(reporting);
|
||||
await user.click(reporting);
|
||||
expect(reporting).toHaveAttribute('aria-expanded', 'false');
|
||||
await user.click(reporting);
|
||||
await user.click(screen.getByRole('button', { name: '通知' }));
|
||||
expect(screen.queryByRole('menu', { name: '报备任务提醒' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /签名导入待审/ })).toBeVisible();
|
||||
await user.click(reporting);
|
||||
expect(screen.queryByRole('menuitem', { name: /签名导入待审/ })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('menuitem', { name: /签名清退预警/ }));
|
||||
expect(screen.getByTestId('route')).toHaveTextContent('/admin/signature-retirement');
|
||||
expect(reporting).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('refreshes retirement data independently and preserves other domains after its request fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderAdmin();
|
||||
api.getSignatureRetirementUnreadCount.mockResolvedValue({ count: 0 });
|
||||
fireEvent(window, new Event('cmpp-retirement-count-refresh'));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: '报备任务提醒' }).querySelector('.notice-count')).toBeNull(),
|
||||
);
|
||||
expect(screen.getByRole('button', { name: '报备任务提醒' }).querySelector('.notice-count')).toBeNull();
|
||||
api.getSignatureRetirementUnreadCount.mockRejectedValue(new Error('forbidden'));
|
||||
fireEvent(window, new Event('cmpp-retirement-count-refresh'));
|
||||
await waitFor(() => expect(api.getSignatureRetirementUnreadCount).toHaveBeenCalledTimes(3));
|
||||
await user.click(screen.getByRole('button', { name: '报备任务提醒' }));
|
||||
expect(within(screen.getByRole('menu', { name: '报备任务提醒' })).getByText('0 条')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '预警通知' })).toHaveTextContent('7');
|
||||
expect(screen.getByRole('button', { name: '通知' })).toHaveTextContent('4');
|
||||
});
|
||||
|
||||
it('does not add notification controls to a client shell without notification items', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<AppShell
|
||||
title="客户端"
|
||||
subtitle="客户端"
|
||||
workspaceName="企业"
|
||||
loginPath="/client/login"
|
||||
portal="client"
|
||||
userName="客户"
|
||||
userRole="企业管理员"
|
||||
navSections={[]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: '报备任务提醒' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '预警通知' })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -122,13 +122,20 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
|
||||
userName={session.user.displayName}
|
||||
userRole="平台管理员"
|
||||
onSessionLockedChange={setSessionLocked}
|
||||
alertNotifications={[
|
||||
reportingNotifications={[
|
||||
{
|
||||
label: '签名清退预警',
|
||||
count: retirementUnreadCount,
|
||||
description: '今日未读且未抑制',
|
||||
to: '/admin/signature-retirement',
|
||||
},
|
||||
{
|
||||
label: '报备进度提醒',
|
||||
description: '报备进度通知功能尚未开放',
|
||||
pending: true,
|
||||
},
|
||||
]}
|
||||
alertNotifications={[
|
||||
{
|
||||
label: '安全检测与封禁',
|
||||
count: securityAlertSummary.count,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
.alert-notification-menu .alert-notification-menu__pending {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: not-allowed;
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
justify-content: space-between;
|
||||
min-height: 58px;
|
||||
padding: 0 var(--space-3);
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.alert-notification-menu .ui-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.alert-notification-menu .notice-popover {
|
||||
left: var(--space-3);
|
||||
max-height: calc(100dvh - 76px);
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
right: var(--space-3);
|
||||
top: 64px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Tag } from '@/components/ui/Tag';
|
||||
import './AlertNotificationMenu.css';
|
||||
|
||||
export type AlertNotificationItem = {
|
||||
label: string;
|
||||
description: string;
|
||||
} & ({ to: string; count: number; pending?: false } | { pending: true; to?: never; count?: never });
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
title: string;
|
||||
icon: ComponentType<{ size?: number }>;
|
||||
items: AlertNotificationItem[];
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function AlertNotificationMenu({ label, title, icon: Icon, items, open, onToggle, onClose }: Props) {
|
||||
const total = items.reduce((sum, item) => sum + (item.pending ? 0 : item.count), 0);
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<div className="notice-menu-wrap alert-notification-menu">
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
aria-label={label}
|
||||
title={title}
|
||||
className={['icon-button', total > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
>
|
||||
<Icon size={18} />
|
||||
{total > 0 ? <span className="notice-count">{total}</span> : null}
|
||||
</button>
|
||||
{open ? (
|
||||
<div aria-label={title} className="notice-popover notice-popover--alerts" role="menu">
|
||||
<div className="notice-popover__header">
|
||||
<strong>{title}</strong>
|
||||
<span className={total === 0 ? 'is-zero' : ''}>{total} 条</span>
|
||||
</div>
|
||||
{items.map((item) => {
|
||||
const copy = (
|
||||
<span className="notice-popover__copy">
|
||||
<b>{item.label}</b>
|
||||
<small>{item.description}</small>
|
||||
</span>
|
||||
);
|
||||
return item.pending ? (
|
||||
<button
|
||||
key={item.label}
|
||||
className="alert-notification-menu__pending"
|
||||
disabled
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
{copy}
|
||||
<Tag>待开发</Tag>
|
||||
</button>
|
||||
) : (
|
||||
<NavLink key={item.to} onClick={onClose} role="menuitem" to={item.to}>
|
||||
{copy}
|
||||
<strong className={item.count === 0 ? 'is-zero' : ''}>{item.count}</strong>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+227
-111
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
ClipboardList,
|
||||
FileClock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
@@ -30,6 +31,8 @@ import {
|
||||
type Portal,
|
||||
} from '@/api/session';
|
||||
import { Button, Input, Modal } from '@/components/ui';
|
||||
import { AlertNotificationMenu, type AlertNotificationItem } from './AlertNotificationMenu';
|
||||
export type { AlertNotificationItem } from './AlertNotificationMenu';
|
||||
|
||||
export type ShellNavItem = {
|
||||
label: string;
|
||||
@@ -50,10 +53,6 @@ export type AuditNotificationItem = {
|
||||
to: string;
|
||||
};
|
||||
|
||||
export type AlertNotificationItem = AuditNotificationItem & {
|
||||
description: string;
|
||||
};
|
||||
|
||||
type AppShellProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -65,6 +64,7 @@ type AppShellProps = {
|
||||
navSections: ShellNavSection[];
|
||||
auditNotifications?: AuditNotificationItem[];
|
||||
alertNotifications?: AlertNotificationItem[];
|
||||
reportingNotifications?: AlertNotificationItem[];
|
||||
onSessionLockedChange?: (locked: boolean) => void;
|
||||
};
|
||||
|
||||
@@ -78,14 +78,14 @@ export function AppShell({
|
||||
navSections,
|
||||
auditNotifications = [],
|
||||
alertNotifications = [],
|
||||
reportingNotifications = [],
|
||||
onSessionLockedChange,
|
||||
}: AppShellProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const [noticeOpen, setNoticeOpen] = useState(false);
|
||||
const [alertNoticeOpen, setAlertNoticeOpen] = useState(false);
|
||||
const [openNotice, setOpenNotice] = useState<'alerts' | 'reporting' | 'audits' | null>(null);
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
@@ -108,14 +108,7 @@ export function AppShell({
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
|
||||
const auditTotal = useMemo(
|
||||
() => auditNotifications.reduce((sum, item) => sum + item.count, 0),
|
||||
[auditNotifications],
|
||||
);
|
||||
const alertTotal = useMemo(
|
||||
() => alertNotifications.reduce((sum, item) => sum + item.count, 0),
|
||||
[alertNotifications],
|
||||
);
|
||||
const auditTotal = useMemo(() => auditNotifications.reduce((sum, item) => sum + item.count, 0), [auditNotifications]);
|
||||
|
||||
async function changeOwnPassword() {
|
||||
if (!currentPassword || newPassword.length < 6) {
|
||||
@@ -246,7 +239,10 @@ export function AppShell({
|
||||
lockRequested.current = false;
|
||||
markUserActivity();
|
||||
};
|
||||
const onLogout = () => { clearSession(portal); navigate(loginPath, { replace: true }); };
|
||||
const onLogout = () => {
|
||||
clearSession(portal);
|
||||
navigate(loginPath, { replace: true });
|
||||
};
|
||||
const lockedEvent = sessionEvent(portal, 'locked');
|
||||
const unlockedEvent = sessionEvent(portal, 'unlocked');
|
||||
const logoutEvent = sessionEvent(portal, 'logout');
|
||||
@@ -266,13 +262,16 @@ export function AppShell({
|
||||
channel = undefined;
|
||||
}
|
||||
|
||||
setReauthenticationHandler(() => new Promise<void>((resolve, reject) => {
|
||||
reauthenticationResolve.current = resolve;
|
||||
reauthenticationReject.current = reject;
|
||||
setReauthenticationPassword('');
|
||||
setReauthenticationError('');
|
||||
setReauthenticationOpen(true);
|
||||
}));
|
||||
setReauthenticationHandler(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
reauthenticationResolve.current = resolve;
|
||||
reauthenticationReject.current = reject;
|
||||
setReauthenticationPassword('');
|
||||
setReauthenticationError('');
|
||||
setReauthenticationOpen(true);
|
||||
}),
|
||||
);
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
const session = readSession(portal);
|
||||
@@ -330,14 +329,30 @@ export function AppShell({
|
||||
}, [auditTotal, title]);
|
||||
|
||||
return (
|
||||
<div className={['app-shell', collapsed ? 'app-shell--collapsed' : '', mobileNavOpen ? 'app-shell--mobile-nav-open' : ''].filter(Boolean).join(' ')}>
|
||||
<aside aria-label="应用导航" className={['sidebar', mobileNavOpen ? 'sidebar--mobile-open' : ''].filter(Boolean).join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'app-shell',
|
||||
collapsed ? 'app-shell--collapsed' : '',
|
||||
mobileNavOpen ? 'app-shell--mobile-nav-open' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<aside
|
||||
aria-label="应用导航"
|
||||
className={['sidebar', mobileNavOpen ? 'sidebar--mobile-open' : ''].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className="sidebar-brand-row">
|
||||
<div className="brand-block">
|
||||
<img alt={`${title} logo`} className="brand-logo brand-logo--full" src="/logo/logo1.png" />
|
||||
<img alt={`${title} logo`} className="brand-logo brand-logo--compact" src="/logo/logo2.png" />
|
||||
</div>
|
||||
<button aria-label="关闭导航" className="icon-button mobile-nav-close" onClick={() => setMobileNavOpen(false)} type="button">
|
||||
<button
|
||||
aria-label="关闭导航"
|
||||
className="icon-button mobile-nav-close"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -349,7 +364,9 @@ export function AppShell({
|
||||
<button
|
||||
aria-expanded={!closedSections[section.title]}
|
||||
className="side-nav-group-toggle"
|
||||
onClick={() => setClosedSections((current) => ({ ...current, [section.title]: !current[section.title] }))}
|
||||
onClick={() =>
|
||||
setClosedSections((current) => ({ ...current, [section.title]: !current[section.title] }))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<section.icon size={18} strokeWidth={2.1} />
|
||||
@@ -363,7 +380,9 @@ export function AppShell({
|
||||
) : (
|
||||
<p>{section.title}</p>
|
||||
)}
|
||||
<div className={['side-nav-list', closedSections[section.title] ? 'side-nav-list--closed' : ''].join(' ')}>
|
||||
<div
|
||||
className={['side-nav-list', closedSections[section.title] ? 'side-nav-list--closed' : ''].join(' ')}
|
||||
>
|
||||
{section.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
@@ -395,93 +414,101 @@ export function AppShell({
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{mobileNavOpen ? <button aria-label="关闭导航遮罩" className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} type="button" /> : null}
|
||||
{mobileNavOpen ? (
|
||||
<button
|
||||
aria-label="关闭导航遮罩"
|
||||
className="mobile-nav-backdrop"
|
||||
onClick={() => setMobileNavOpen(false)}
|
||||
type="button"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<main className="main-area">
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
<button
|
||||
className="icon-button desktop-nav-toggle"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
type="button"
|
||||
aria-label={collapsed ? '展开导航' : '收起导航'}
|
||||
>
|
||||
<ToggleIcon size={18} />
|
||||
</button>
|
||||
<button
|
||||
aria-expanded={mobileNavOpen}
|
||||
aria-label={mobileNavOpen ? '关闭导航' : '打开导航'}
|
||||
className="icon-button mobile-nav-toggle"
|
||||
onClick={() => setMobileNavOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<span className="desktop-nav-toggle">
|
||||
<button
|
||||
className="icon-button"
|
||||
onClick={() => setCollapsed((value) => !value)}
|
||||
type="button"
|
||||
aria-label={collapsed ? '展开导航' : '收起导航'}
|
||||
>
|
||||
<ToggleIcon size={18} />
|
||||
</button>
|
||||
</span>
|
||||
<span className="mobile-nav-toggle">
|
||||
<button
|
||||
aria-expanded={mobileNavOpen}
|
||||
aria-label={mobileNavOpen ? '关闭导航' : '打开导航'}
|
||||
className="icon-button"
|
||||
onClick={() => setMobileNavOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
</span>
|
||||
<div className="mobile-topbar-brand">
|
||||
<img alt={`${title} logo`} src="/logo/logo1.png" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
<button className="icon-button topbar-help" type="button" aria-label="帮助中心">
|
||||
<CircleHelp size={18} />
|
||||
</button>
|
||||
{alertNotifications.length ? (
|
||||
<span className="topbar-help">
|
||||
<button className="icon-button" type="button" aria-label="帮助中心">
|
||||
<CircleHelp size={18} />
|
||||
</button>
|
||||
</span>
|
||||
<AlertNotificationMenu
|
||||
icon={Bell}
|
||||
items={alertNotifications}
|
||||
label="预警通知"
|
||||
title="预警中心"
|
||||
open={openNotice === 'alerts'}
|
||||
onToggle={() => setOpenNotice((open) => (open === 'alerts' ? null : 'alerts'))}
|
||||
onClose={() => setOpenNotice(null)}
|
||||
/>
|
||||
<AlertNotificationMenu
|
||||
icon={FileClock}
|
||||
items={reportingNotifications}
|
||||
label="报备任务提醒"
|
||||
title="报备任务提醒"
|
||||
open={openNotice === 'reporting'}
|
||||
onToggle={() => setOpenNotice((open) => (open === 'reporting' ? null : 'reporting'))}
|
||||
onClose={() => setOpenNotice(null)}
|
||||
/>
|
||||
{auditNotifications.length ? (
|
||||
<div className="notice-menu-wrap">
|
||||
<button
|
||||
aria-expanded={alertNoticeOpen}
|
||||
aria-expanded={openNotice === 'audits'}
|
||||
aria-haspopup="menu"
|
||||
aria-label="预警通知"
|
||||
className={['icon-button', alertTotal > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => { setAlertNoticeOpen((open) => !open); setNoticeOpen(false); }}
|
||||
className={['icon-button', auditTotal > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => setOpenNotice((open) => (open === 'audits' ? null : 'audits'))}
|
||||
type="button"
|
||||
aria-label="通知"
|
||||
>
|
||||
<Bell size={18} />
|
||||
{alertTotal > 0 ? <span className="notice-count">{alertTotal}</span> : null}
|
||||
<ClipboardList size={18} />
|
||||
{auditTotal > 0 ? <span className="notice-count">{auditTotal}</span> : null}
|
||||
</button>
|
||||
{alertNoticeOpen ? (
|
||||
<div className="notice-popover notice-popover--alerts" role="menu">
|
||||
{openNotice === 'audits' ? (
|
||||
<div className="notice-popover" role="menu">
|
||||
<div className="notice-popover__header">
|
||||
<strong>预警中心</strong>
|
||||
<span className={alertTotal === 0 ? 'is-zero' : ''}>{alertTotal} 条</span>
|
||||
<strong>待审核任务</strong>
|
||||
<span className={auditTotal === 0 ? 'is-zero' : ''}>{auditTotal} 条</span>
|
||||
</div>
|
||||
{alertNotifications.map((item) => (
|
||||
<NavLink key={item.to} onClick={() => setAlertNoticeOpen(false)} role="menuitem" to={item.to}>
|
||||
<span className="notice-popover__copy"><b>{item.label}</b><small>{item.description}</small></span>
|
||||
<strong className={item.count === 0 ? 'is-zero' : ''}>{item.count}</strong>
|
||||
</NavLink>
|
||||
))}
|
||||
{auditNotifications.length ? (
|
||||
auditNotifications.map((item) => (
|
||||
<NavLink key={item.to} onClick={() => setOpenNotice(null)} role="menuitem" to={item.to}>
|
||||
<span>{item.label}</span>
|
||||
<strong className={item.count === 0 ? 'is-zero' : ''}>{item.count}</strong>
|
||||
</NavLink>
|
||||
))
|
||||
) : (
|
||||
<p>暂无待审核任务</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{auditNotifications.length ? <div className="notice-menu-wrap">
|
||||
<button
|
||||
aria-expanded={noticeOpen}
|
||||
aria-haspopup="menu"
|
||||
className={['icon-button', auditTotal > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
|
||||
onClick={() => { setNoticeOpen((open) => !open); setAlertNoticeOpen(false); }}
|
||||
type="button"
|
||||
aria-label="通知"
|
||||
>
|
||||
<ClipboardList size={18} />
|
||||
{auditTotal > 0 ? <span className="notice-count">{auditTotal}</span> : null}
|
||||
</button>
|
||||
{noticeOpen ? (
|
||||
<div className="notice-popover" role="menu">
|
||||
<div className="notice-popover__header">
|
||||
<strong>待审核任务</strong>
|
||||
<span className={auditTotal === 0 ? 'is-zero' : ''}>{auditTotal} 条</span>
|
||||
</div>
|
||||
{auditNotifications.length ? auditNotifications.map((item) => (
|
||||
<NavLink key={item.to} onClick={() => setNoticeOpen(false)} role="menuitem" to={item.to}>
|
||||
<span>{item.label}</span>
|
||||
<strong className={item.count === 0 ? 'is-zero' : ''}>{item.count}</strong>
|
||||
</NavLink>
|
||||
)) : <p>暂无待审核任务</p>}
|
||||
</div>
|
||||
) : null}
|
||||
</div> : null}
|
||||
<div className="user-menu-wrap">
|
||||
<button
|
||||
aria-expanded={userMenuOpen}
|
||||
@@ -499,14 +526,29 @@ export function AppShell({
|
||||
</button>
|
||||
{userMenuOpen ? (
|
||||
<div className="user-menu-popover" role="menu">
|
||||
<button onClick={() => { setUserMenuOpen(false); setPasswordModalOpen(true); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); setPasswordError(''); }} role="menuitem" type="button">
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
setPasswordModalOpen(true);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setPasswordError('');
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<KeyRound size={16} />
|
||||
修改密码
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
void logout();
|
||||
}} role="menuitem" type="button">
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
void logout();
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
退出登录
|
||||
</button>
|
||||
@@ -516,35 +558,81 @@ export function AppShell({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="page-content">
|
||||
{routesSuspended ? null : <Outlet />}
|
||||
</div>
|
||||
<div className="page-content">{routesSuspended ? null : <Outlet />}</div>
|
||||
</main>
|
||||
<Modal
|
||||
footer={<><Button disabled={passwordSaving} onClick={() => setPasswordModalOpen(false)} variant="ghost">取消</Button><Button disabled={passwordSaving} icon={<KeyRound size={16} />} onClick={() => void changeOwnPassword()}>{passwordSaving ? '保存中...' : '确认修改'}</Button></>}
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={passwordSaving} onClick={() => setPasswordModalOpen(false)} variant="ghost">
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={passwordSaving} icon={<KeyRound size={16} />} onClick={() => void changeOwnPassword()}>
|
||||
{passwordSaving ? '保存中...' : '确认修改'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setPasswordModalOpen(false)}
|
||||
open={passwordModalOpen}
|
||||
title="修改密码"
|
||||
>
|
||||
<div className="admin-system-modal-form">
|
||||
<Input label="当前密码" onChange={(event) => setCurrentPassword(event.target.value)} required type="password" value={currentPassword} />
|
||||
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} required type="password" value={newPassword} />
|
||||
<Input label="确认新密码" onChange={(event) => setConfirmPassword(event.target.value)} required type="password" value={confirmPassword} />
|
||||
<Input
|
||||
label="当前密码"
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
required
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
/>
|
||||
<Input
|
||||
label="新密码"
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
required
|
||||
type="password"
|
||||
value={newPassword}
|
||||
/>
|
||||
<Input
|
||||
label="确认新密码"
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
required
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
/>
|
||||
{passwordError ? <p className="form-error">{passwordError}</p> : null}
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
footer={<><Button onClick={() => void logout()} variant="ghost">退出登录</Button><Button disabled={unlocking} onClick={() => void unlockSession()}>{unlocking ? '解锁中...' : '解锁'}</Button></>}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => void logout()} variant="ghost">
|
||||
退出登录
|
||||
</Button>
|
||||
<Button disabled={unlocking} onClick={() => void unlockSession()}>
|
||||
{unlocking ? '解锁中...' : '解锁'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => undefined}
|
||||
open={locked}
|
||||
title="会话已安全锁定"
|
||||
>
|
||||
<p>由于长时间未操作,请输入当前密码继续使用。解锁后将返回当前页面;锁定超过 4 小时后需要完整登录。</p>
|
||||
<Input label="当前密码" onChange={(event) => setUnlockPassword(event.target.value)} type="password" value={unlockPassword} />
|
||||
<Input
|
||||
label="当前密码"
|
||||
onChange={(event) => setUnlockPassword(event.target.value)}
|
||||
type="password"
|
||||
value={unlockPassword}
|
||||
/>
|
||||
{unlockError ? <p className="login-error">{unlockError}</p> : null}
|
||||
</Modal>
|
||||
<Modal
|
||||
footer={<><Button onClick={() => setIdleWarningSeconds(null)} variant="ghost">稍后处理</Button><Button onClick={() => void continueSession()}>继续使用</Button></>}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setIdleWarningSeconds(null)} variant="ghost">
|
||||
稍后处理
|
||||
</Button>
|
||||
<Button onClick={() => void continueSession()}>继续使用</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => setIdleWarningSeconds(null)}
|
||||
open={!locked && idleWarningSeconds !== null}
|
||||
title="会话即将锁定"
|
||||
@@ -552,13 +640,40 @@ export function AppShell({
|
||||
<p>长时间未操作,系统将在 {formatCountdown(idleWarningSeconds ?? 0)} 后锁定。点击“继续使用”可保持当前会话。</p>
|
||||
</Modal>
|
||||
<Modal
|
||||
footer={<><Button onClick={() => { reauthenticationReject.current?.(new Error('已取消敏感操作')); reauthenticationResolve.current = null; reauthenticationReject.current = null; setReauthenticationOpen(false); }} variant="ghost">取消</Button><Button disabled={reauthenticating} onClick={() => void confirmReauthentication()}>{reauthenticating ? '验证中...' : '确认身份'}</Button></>}
|
||||
onClose={() => { reauthenticationReject.current?.(new Error('已取消敏感操作')); reauthenticationResolve.current = null; reauthenticationReject.current = null; setReauthenticationOpen(false); }}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
reauthenticationReject.current?.(new Error('已取消敏感操作'));
|
||||
reauthenticationResolve.current = null;
|
||||
reauthenticationReject.current = null;
|
||||
setReauthenticationOpen(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={reauthenticating} onClick={() => void confirmReauthentication()}>
|
||||
{reauthenticating ? '验证中...' : '确认身份'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={() => {
|
||||
reauthenticationReject.current?.(new Error('已取消敏感操作'));
|
||||
reauthenticationResolve.current = null;
|
||||
reauthenticationReject.current = null;
|
||||
setReauthenticationOpen(false);
|
||||
}}
|
||||
open={reauthenticationOpen}
|
||||
title="敏感操作身份验证"
|
||||
>
|
||||
<p>该操作影响账号、通道或资金安全,请输入当前登录用户密码。验证通过后 30 分钟内无需重复输入。</p>
|
||||
<Input label="当前密码" onChange={(event) => setReauthenticationPassword(event.target.value)} type="password" value={reauthenticationPassword} />
|
||||
<Input
|
||||
label="当前密码"
|
||||
onChange={(event) => setReauthenticationPassword(event.target.value)}
|
||||
type="password"
|
||||
value={reauthenticationPassword}
|
||||
/>
|
||||
{reauthenticationError ? <p className="login-error">{reauthenticationError}</p> : null}
|
||||
</Modal>
|
||||
</div>
|
||||
@@ -569,7 +684,8 @@ function isShellNavItemActive(pathname: string, itemPath: string) {
|
||||
if (pathname === itemPath) return true;
|
||||
if (itemPath === '/admin' || itemPath === '/client') return false;
|
||||
if (pathname.startsWith(`${itemPath}/`)) return true;
|
||||
if (itemPath === '/admin/enterprise-applications' && /^\/admin\/customers\/[^/]+\/sms-apps\//.test(pathname)) return true;
|
||||
if (itemPath === '/admin/enterprise-applications' && /^\/admin\/customers\/[^/]+\/sms-apps\//.test(pathname))
|
||||
return true;
|
||||
if (itemPath === '/admin/customer-enterprises' && /^\/admin\/customers\/[^/]+\/edit$/.test(pathname)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user