feat: 新增报备任务提醒并调整预警分组

This commit is contained in:
hectorzhao
2026-09-06 15:36:14 +08:00
parent 442dda711d
commit bd920f76b0
11 changed files with 546 additions and 112 deletions
@@ -2199,3 +2199,12 @@
- 重复通道、归一化后重复省份及全国重复优先级必须明确阻止,不得静默覆盖已有成员。保存时保留已有组的状态、描述以及成员权重和主备属性。 - 重复通道、归一化后重复省份及全国重复优先级必须明确阻止,不得静默覆盖已有成员。保存时保留已有组的状态、描述以及成员权重和主备属性。
- 通道选择支持按名称、编号和地区搜索,直接展示成本价格、适用地区、运营商和真实连接回写。已删除、运营商不匹配、地区不匹配或已在本组配置的通道不可选择并说明原因;活动但暂时断连的通道允许配置。 - 通道选择支持按名称、编号和地区搜索,直接展示成本价格、适用地区、运营商和真实连接回写。已删除、运营商不匹配、地区不匹配或已在本组配置的通道不可选择并说明原因;活动但暂时断连的通道允许配置。
- 页面加载失败时禁止编辑和保存;存在未保存修改时,返回、路由切换、浏览器后退或刷新须提示。桌面、常用笔记本和 390px 窄屏均应可完成上述交互,且不得产生页面级横向溢出。 - 页面加载失败时禁止编辑和保存;存在未保存修改时,返回、路由切换、浏览器后退或刷新须提示。桌面、常用笔记本和 390px 窄屏均应可完成上述交互,且不得产生页面级横向溢出。
## 顶栏报备任务提醒(2026-09-06,本地实现)
本节替代此前旧章节中“签名清退归入预警中心”的入口归属,其余统计和业务规则继续有效;详细设计见[签名清退预警设计](signature-retirement-alert-design.md)的同日调整节。
- 运营端顶栏提供三个平级入口:预警中心、报备任务提醒、待审核任务。报备任务提醒复用预警中心样式及展开/收起、互斥和路由跳转交互。
- 签名清退预警移至报备任务提醒,数量使用现有真实今日未读且未抑制汇总;不再计入预警中心。原侧栏入口保留,审核待办统计不变。
- 报备任务提醒新增“报备进度提醒”禁用按钮和“待开发”标签,不提供跳转、虚构数量或假成功。后续真实进度能力需另行设计后端,本轮不实现。
- 预警中心继续汇总安全检测与封禁、系统监控告警。三个一级按钮和弹层在1600×1000、1366×768、390×844均应完整可用。
+6
View File
@@ -258,3 +258,9 @@ bash tools/deploy/production-deploy.sh
- 单企业微批阶段要求显式设置`API_INBOUND_WORKFLOW_BATCH_WAIT_MS=40``API_INBOUND_WORKFLOW_TARGET_BATCH_SIZE=32`,目标不得超过批次上限;发布脚本拒绝负等待、超过250ms或目标越界。API还必须设置`API_HTTP_KEEP_ALIVE_TIMEOUT_MS=120000`和更大的`API_HTTP_HEADERS_TIMEOUT_MS=125000`,确保服务端keep-alive长于Gateway 90秒空闲池;发布后用响应头回读实际timeout并检查Gateway日志无loopback reset。 - 单企业微批阶段要求显式设置`API_INBOUND_WORKFLOW_BATCH_WAIT_MS=40``API_INBOUND_WORKFLOW_TARGET_BATCH_SIZE=32`,目标不得超过批次上限;发布脚本拒绝负等待、超过250ms或目标越界。API还必须设置`API_HTTP_KEEP_ALIVE_TIMEOUT_MS=120000`和更大的`API_HTTP_HEADERS_TIMEOUT_MS=125000`,确保服务端keep-alive长于Gateway 90秒空闲池;发布后用响应头回读实际timeout并检查Gateway日志无loopback reset。
- `GATEWAY_CALLBACK_BATCH_ENABLED`默认关闭,只有独立Gateway callback进程已启用、`GATEWAY_CALLBACK_API_BASE_URL`指向其回环端口且批量路由健康检查通过时才允许显式设为`true`。主API回调模式必须保持`false`;发布后同时检查`gateway.submit.results``pending/lag`,任何持续增长均视为发布失败,禁止手工ACK掩盖回调未持久化。 - `GATEWAY_CALLBACK_BATCH_ENABLED`默认关闭,只有独立Gateway callback进程已启用、`GATEWAY_CALLBACK_API_BASE_URL`指向其回环端口且批量路由健康检查通过时才允许显式设为`true`。主API回调模式必须保持`false`;发布后同时检查`gateway.submit.results``pending/lag`,任何持续增长均视为发布失败,禁止手工ACK掩盖回调未持久化。
- 服务重启顺序必须为“独立callback(如启用)→ Gateway → API → Send Worker”。API必须在Gateway已健康后重启,利用启动同步重新下发活动供应商通道;发布后`cmpp_gateway_upstream_connections{state="desired"}``connected`必须恢复到发布前活动连接数,不能只凭Gateway `/health` 判定发布成功。 - 服务重启顺序必须为“独立callback(如启用)→ Gateway → API → Send Worker”。API必须在Gateway已健康后重启,利用启动同步重新下发活动供应商通道;发布后`cmpp_gateway_upstream_connections{state="desired"}``connected`必须恢复到发布前活动连接数,不能只凭Gateway `/health` 判定发布成功。
## 测试环境持久验收管理员(2026-09-06)
用户明确授权创建并保留后续复用的专用测试管理员。2026-09-06在测试环境100.93.204.60创建`codex_qa_admin`,显示名“自动化验收管理员”,用户ID`cmtphgp340000yrle7v11jemc`,角色`platform_admin`、状态`active`;正常验证码登录和退出已验证。该账号仅在测试环境创建,预生产不适用。
安全认证入口:工作站`C:\Users\hectorzhao\.config\cmpp-qa\test-admin.json`,仅当前Windows用户及SYSTEM可访问;测试机备份入口`/home/hector/.config/cmpp-qa/test-admin.json`,目录0700、文件0600、所有者hector。文件保存随机生成密码和登录地址,禁止打印内容、提交Git或将密码写进命令、截图和报告。后续通过既有SSH安全入口读取到内存或使用本机受限文件完成正常登录;账号和凭据按用户要求保留,测试结束退出会话即可。使用前重新核验账号有效性,不覆盖已有账号、不因认证失败重置其他管理员;账号存在不构成发送短信、改余额或通道/客户配置的授权。
+10
View File
@@ -10,6 +10,16 @@
## 1. 目标与范围 ## 1. 目标与范围
### 2026-09-06 顶栏报备任务提醒调整
本节替代下文旧“顶部预警铃铛”对签名清退入口的归属描述,不改变检测、消息、抑制、已读或Webhook规则。运营端顶栏增加与预警中心、待审核任务平级的“报备任务提醒”,复用预警中心的图标按钮、红色数量角标、标题总数和带摘要的菜单行;三组提醒互斥展开,再次点击关闭,点击有效菜单项进入原路由并收起。
- 预警中心仅汇总安全检测与封禁、系统监控告警;签名清退计数不再重复计入。
- 报备任务提醒包含“签名清退预警”(真实今日未读且未抑制数,原`/admin/signature-retirement`路由)和“报备进度提醒”(禁用按钮、待开发标签,不设路由、不请求API、不显示虚构计数)。
- 报备总数当前等于签名清退真实计数,零数仍可展开;审核待办维持原统计和跳转。客户端不新增报备入口,侧边栏原签名清退入口保留。
- 沿用现有四项轻量API、30秒轮询、焦点和既有刷新事件;失败域按现有兼容口径回退0,不清空其他域。本轮只调整入口归属,不新增服务端统计、表、迁移或消息生产者。
- 新局部菜单样式由布局组件直接import;桌面沿用既有预警样式,窄屏约束弹层在视口内。顶栏导航/帮助的响应式显隐放到独立包装节点,避免同节点的公共icon-button布局规则覆盖显隐;不提高CSS优先级或改变导入顺序。验收覆盖三尺寸、首次进入/刷新/路由切换、分域计数、菜单互斥、清退跳转和待开发禁用状态。
本项目解决运营商因签名长期无真实发送而清退的问题,并将通道能力和签名报备状态细化到真实运营商。最终形成以下闭环: 本项目解决运营商因签名长期无真实发送而清退的问题,并将通道能力和签名报备状态细化到真实运营商。最终形成以下闭环:
1. 通道支持移动、联通、电信多选,三个全选等价于现行“三网”。 1. 通道支持移动、联通、电信多选,三个全选等价于现行“三网”。
+15
View File
@@ -5115,3 +5115,18 @@ npm run verify:phase8
| TC-CHANNEL-GROUP-EDITOR-005 | 新建通道组时创建成功但成员保存失败,随后重试 | 页面保留草稿和已创建组ID,只重试后续保存,不重复创建通道组 | | TC-CHANNEL-GROUP-EDITOR-005 | 新建通道组时创建成功但成员保存失败,随后重试 | 页面保留草稿和已创建组ID,只重试后续保存,不重复创建通道组 |
| TC-CHANNEL-GROUP-EDITOR-006 | 加载接口失败,或有未保存修改时返回、跨路由、后退和刷新 | 加载失败时编辑与保存不可用并显示真实错误;未保存离开均提示,取消后保留草稿 | | TC-CHANNEL-GROUP-EDITOR-006 | 加载接口失败,或有未保存修改时返回、跨路由、后退和刷新 | 加载失败时编辑与保存不可用并显示真实错误;未保存离开均提示,取消后保留草稿 |
| TC-CHANNEL-GROUP-EDITOR-007 | 在1600×1000、1366×768和390×844执行排序、撤销、搜索、冲突校验和保存预览 | 三尺寸均可操作且无页面级横向溢出;控制台无新增错误;验收不保存真实通道组、不修改通道配置、不发送短信 | | TC-CHANNEL-GROUP-EDITOR-007 | 在1600×1000、1366×768和390×844执行排序、撤销、搜索、冲突校验和保存预览 | 三尺寸均可操作且无页面级横向溢出;控制台无新增错误;验收不保存真实通道组、不修改通道配置、不发送短信 |
## 顶栏报备任务提醒增量用例(2026-09-06)
本节替代TC-GLOBAL-ALERT-001/002/004中签名清退的旧入口归属和三域总计口径;TC-GLOBAL-ALERT-003的独立轻量请求与故障隔离继续有效。需求和设计见对应同日调整节,真实环境与代码测试证据分开记录于testing-progress.md。
| 编号 | 场景与步骤 | 预期结果 |
|---|---|---|
| TC-REPORT-NOTICE-001 | 运营端登录后分别打开预警中心、报备任务提醒和待审核任务;核对三个真实汇总与审核响应 | 三个入口平级;预警中心仅含安全、系统监控;清退项仅出现在报备任务提醒,报备总数等于清退未读数,审核数独立 |
| TC-REPORT-NOTICE-002 | 打开报备菜单,点击签名清退项;再次打开及重复点击一级按钮;三组之间切换 | 跳转原/admin/signature-retirement并收起;重复点击收起;最多一组通知菜单展开;侧栏入口保留 |
| TC-REPORT-NOTICE-003 | 检查报备进度提醒行和网络请求 | 显示待开发标签,按钮disabled,无路由、无计数、无新增进度API,不能触发业务写入 |
| TC-REPORT-NOTICE-004 | 隔离测试中分别提供清退正数、0、拒绝响应,触发既有刷新事件 | 正数显示角标,0隐藏角标且可展开;失败按既有口径回退0,其他域与审核不被清空;单元测试不能代替真实异常验收 |
| TC-REPORT-NOTICE-005 | 1600×1000、1366×768、390×844下首次进入、刷新及路由切换,检查截图和按钮/弹层边界 | 三个一级按钮和用户入口完整可见,导航/帮助按既有断点显隐;菜单样式与预警一致,无视口裁切;控制台和失败请求无异常 |
| TC-REPORT-NOTICE-006 | 检查无通知配置的客户端壳层 | 不新增运营端报备提醒;不得为占位功能读取或制造客户端数据 |
真实正数清退、失败/权限和客户端登录需有授权夹具/账号才可记为真实验收;本轮现有测试环境清退数为0,正数与失败分支使用单元测试隔离覆盖。
+11
View File
@@ -4582,3 +4582,14 @@ git diff --check
- 预生产API、Gateway、新Worker、Nginx、PostgreSQL、Redis和MinIO均active,三项应用进程`NRestarts=0/Result=success`;短信记录发布前后均86692,通道连接状态仍为connected 6、disconnected 7、failed 5,报备批次状态计数不变,三条短信Stream逐字一致,发布窗口warning及以上journal为空。数据盘和备份盘保护在发布后再次通过。 - 预生产API、Gateway、新Worker、Nginx、PostgreSQL、Redis和MinIO均active,三项应用进程`NRestarts=0/Result=success`;短信记录发布前后均86692,通道连接状态仍为connected 6、disconnected 7、failed 5,报备批次状态计数不变,三条短信Stream逐字一致,发布窗口warning及以上journal为空。数据盘和备份盘保护在发布后再次通过。
- 公网`https://sms.lisglo.com/api/health`返回ok;实际下载的主JS`index-Cf0GV6WF.js`和主CSS`index-CkSy6WDi.css`与本地候选SHA-256一致。三尺寸登录页首次进入及刷新均无整页溢出、控制台错误或失败响应。服务器两份历史管理员凭据对应账号当前均为deleted,正常登录返回401;未重置账号或新增预生产管理员,因此登录后通道组交互由同提交的测试环境真实验收和预生产API/数据库/资源证据覆盖,不宣称预生产登录后页面已验收。 - 公网`https://sms.lisglo.com/api/health`返回ok;实际下载的主JS`index-Cf0GV6WF.js`和主CSS`index-CkSy6WDi.css`与本地候选SHA-256一致。三尺寸登录页首次进入及刷新均无整页溢出、控制台错误或失败响应。服务器两份历史管理员凭据对应账号当前均为deleted,正常登录返回401;未重置账号或新增预生产管理员,因此登录后通道组交互由同提交的测试环境真实验收和预生产API/数据库/资源证据覆盖,不宣称预生产登录后页面已验收。
- 两套环境均保留原运行目录及独立恢复点;未执行全量初始化脚本,未修改余额、通道、客户配置、fstab、UUID、数据盘挂载、存储保护脚本或systemd存储drop-in。未发送、补发、重投或重新入队短信。 - 两套环境均保留原运行目录及独立恢复点;未执行全量初始化脚本,未修改余额、通道、客户配置、fstab、UUID、数据盘挂载、存储保护脚本或systemd存储drop-in。未发送、补发、重投或重新入队短信。
## 2026-09-06 顶栏报备任务提醒(本地实现与真实测试环境API验收)
- 开始核验:main、HEAD与远端main均为`442dda711d5c9f778f3f76fd6d8fd69f14414ce6`,差异0/0、暂存为空。原有8份修改文档和3份未跟踪文档继续保护;其中testing-progress、功能用例及部署文档仅提交本节相关新增段落。测试机部署标记只读回读仍为442dda7,本轮未访问预生产。
- 根因与范围:AdminLayout原将签名清退、安全、系统监控放入同一alertNotificationsAppShell只有预警与审核两组。现新增平级报备任务提醒,将清退原路由/真实计数移入,增加禁用的报备进度提醒和待开发标签。抽取共用AlertNotificationMenu统一样式、计数和交互,三类通知互斥展开,审核与侧栏清退入口保留。继续使用既有四个轻量API及刷新事件,无后端代码、数据库结构、迁移、队列或消息生产者修改。
- 视觉复查发现390px顶栏裁切:导航/帮助的显隐类与公共icon-button处于同一节点,后加载的公共display覆盖响应式规则。将显隐类放在包装节点,按钮继续使用公共样式,修复重复导航/窄屏帮助占位;不增加优先级或important,不改变CSS导入顺序。新增CSS仅在布局菜单同目录直接导入并登记所有权,未改14个历史模块或shell.css。AppShell按现有Prettier门禁格式化,其他会话/锁屏逻辑未变。
- 自动验证最终结果:前端18套95项全通过(新增5项涵盖分组计数、待开发禁用、互斥/跳转、刷新/失败隔离与客户端无配置)。新增测试初次全量出现默认1秒异步超时及不适用的exact选项类型错误,已改用5秒有界等待、正确角色匹配及精确零角标断言,最终全量复验通过。TypeScript、生产构建、quality:verify、变更ESLint、format:check、Stylelint、CSS治理及其15项测试、bundle:verify、diff检查通过;Chart保留既有500kB提示,入口gzip 108.27KiB低于250KiB预算。当前shell无npm入口,均用既有Node执行package.json的等价脚本,未安装依赖。
- 真实验收:使用本地生产预览`http://127.0.0.1:4173`代理测试机真实API,正常识别页面算术验证码并登录持久QA管理员。候选前端未部署到服务器。真实响应清退0、安全6(严重1)、系统监控2(严重1)、审核21(含签名导入19);预警合计8,报备合计0。1600×1000、1366×768、390×844完成首次进入、刷新、三菜单互斥/开关、禁用占位和原清退路由跳转;按钮/弹层边界均在视口内,计算背景/边框/圆角/字号与预警中心一致。截图逐一复查,console warning/error、pageerror及HTTP失败响应均0。Browser插件/技能入口不可用,按前端测试技能使用已有Playwright/Edge。
- 数据与账户:用户授权新建并保留`codex_qa_admin`ID `cmtphgp340000yrle7v11jemc`platform_admin、active),安全入口见[部署文档持久验收管理员节](production-deployment.md#测试环境持久验收管理员2026-09-06)。2026-09-06 15:32左右PostgreSQL短信记录仍119509,三条短信Stream的consumer/pending/last-delivered/entries-read/lag与验收前一致(pending/lag均0)。账号与受限凭据按要求保留,浏览器测试正常logout;未发送、补发、重投、重新入队短信或更改余额、通道、客户配置。
- 证据:本机`%TEMP%/cmpp-report-notice-20260906`的before.json/png、after.json、reporting-1600/1366/390.png、browser.mjs、build.log。浏览器脚本只在内存读取安全凭据,不保存会话凭证或密码到报告。
- 边界:真实清退正数、真实故障/权限注入和客户端账号登录未执行,正数/失败/无配置由隔离单元测试覆盖,不冒充真实验收;未为造数据改变历史消息状态。纯前端入口调整未重跑后端/Gateway全量测试与构建。仅授权本地提交,不推送、不部署测试或预生产;既有文档改动保持未提交。
+150
View File
@@ -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();
});
+8 -1
View File
@@ -122,13 +122,20 @@ function AdminAuthenticatedLayout({ session }: { session: LoginSession }) {
userName={session.user.displayName} userName={session.user.displayName}
userRole="平台管理员" userRole="平台管理员"
onSessionLockedChange={setSessionLocked} onSessionLockedChange={setSessionLocked}
alertNotifications={[ reportingNotifications={[
{ {
label: '签名清退预警', label: '签名清退预警',
count: retirementUnreadCount, count: retirementUnreadCount,
description: '今日未读且未抑制', description: '今日未读且未抑制',
to: '/admin/signature-retirement', to: '/admin/signature-retirement',
}, },
{
label: '报备进度提醒',
description: '报备进度通知功能尚未开放',
pending: true,
},
]}
alertNotifications={[
{ {
label: '安全检测与封禁', label: '安全检测与封禁',
count: securityAlertSummary.count, count: securityAlertSummary.count,
+30
View File
@@ -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;
}
}
+74
View File
@@ -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>
);
}
+195 -79
View File
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Bell, Bell,
ClipboardList, ClipboardList,
FileClock,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
CircleHelp, CircleHelp,
@@ -30,6 +31,8 @@ import {
type Portal, type Portal,
} from '@/api/session'; } from '@/api/session';
import { Button, Input, Modal } from '@/components/ui'; import { Button, Input, Modal } from '@/components/ui';
import { AlertNotificationMenu, type AlertNotificationItem } from './AlertNotificationMenu';
export type { AlertNotificationItem } from './AlertNotificationMenu';
export type ShellNavItem = { export type ShellNavItem = {
label: string; label: string;
@@ -50,10 +53,6 @@ export type AuditNotificationItem = {
to: string; to: string;
}; };
export type AlertNotificationItem = AuditNotificationItem & {
description: string;
};
type AppShellProps = { type AppShellProps = {
title: string; title: string;
subtitle: string; subtitle: string;
@@ -65,6 +64,7 @@ type AppShellProps = {
navSections: ShellNavSection[]; navSections: ShellNavSection[];
auditNotifications?: AuditNotificationItem[]; auditNotifications?: AuditNotificationItem[];
alertNotifications?: AlertNotificationItem[]; alertNotifications?: AlertNotificationItem[];
reportingNotifications?: AlertNotificationItem[];
onSessionLockedChange?: (locked: boolean) => void; onSessionLockedChange?: (locked: boolean) => void;
}; };
@@ -78,14 +78,14 @@ export function AppShell({
navSections, navSections,
auditNotifications = [], auditNotifications = [],
alertNotifications = [], alertNotifications = [],
reportingNotifications = [],
onSessionLockedChange, onSessionLockedChange,
}: AppShellProps) { }: AppShellProps) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [mobileNavOpen, setMobileNavOpen] = useState(false); const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [closedSections, setClosedSections] = useState<Record<string, boolean>>({}); const [closedSections, setClosedSections] = useState<Record<string, boolean>>({});
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
const [noticeOpen, setNoticeOpen] = useState(false); const [openNotice, setOpenNotice] = useState<'alerts' | 'reporting' | 'audits' | null>(null);
const [alertNoticeOpen, setAlertNoticeOpen] = useState(false);
const [passwordModalOpen, setPasswordModalOpen] = useState(false); const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState(''); const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
@@ -108,14 +108,7 @@ export function AppShell({
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose; const ToggleIcon = collapsed ? PanelLeftOpen : PanelLeftClose;
const auditTotal = useMemo( const auditTotal = useMemo(() => auditNotifications.reduce((sum, item) => sum + item.count, 0), [auditNotifications]);
() => auditNotifications.reduce((sum, item) => sum + item.count, 0),
[auditNotifications],
);
const alertTotal = useMemo(
() => alertNotifications.reduce((sum, item) => sum + item.count, 0),
[alertNotifications],
);
async function changeOwnPassword() { async function changeOwnPassword() {
if (!currentPassword || newPassword.length < 6) { if (!currentPassword || newPassword.length < 6) {
@@ -246,7 +239,10 @@ export function AppShell({
lockRequested.current = false; lockRequested.current = false;
markUserActivity(); markUserActivity();
}; };
const onLogout = () => { clearSession(portal); navigate(loginPath, { replace: true }); }; const onLogout = () => {
clearSession(portal);
navigate(loginPath, { replace: true });
};
const lockedEvent = sessionEvent(portal, 'locked'); const lockedEvent = sessionEvent(portal, 'locked');
const unlockedEvent = sessionEvent(portal, 'unlocked'); const unlockedEvent = sessionEvent(portal, 'unlocked');
const logoutEvent = sessionEvent(portal, 'logout'); const logoutEvent = sessionEvent(portal, 'logout');
@@ -266,13 +262,16 @@ export function AppShell({
channel = undefined; channel = undefined;
} }
setReauthenticationHandler(() => new Promise<void>((resolve, reject) => { setReauthenticationHandler(
() =>
new Promise<void>((resolve, reject) => {
reauthenticationResolve.current = resolve; reauthenticationResolve.current = resolve;
reauthenticationReject.current = reject; reauthenticationReject.current = reject;
setReauthenticationPassword(''); setReauthenticationPassword('');
setReauthenticationError(''); setReauthenticationError('');
setReauthenticationOpen(true); setReauthenticationOpen(true);
})); }),
);
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
const session = readSession(portal); const session = readSession(portal);
@@ -330,14 +329,30 @@ export function AppShell({
}, [auditTotal, title]); }, [auditTotal, title]);
return ( return (
<div className={['app-shell', collapsed ? 'app-shell--collapsed' : '', mobileNavOpen ? 'app-shell--mobile-nav-open' : ''].filter(Boolean).join(' ')}> <div
<aside aria-label="应用导航" className={['sidebar', mobileNavOpen ? 'sidebar--mobile-open' : ''].filter(Boolean).join(' ')}> 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="sidebar-brand-row">
<div className="brand-block"> <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--full" src="/logo/logo1.png" />
<img alt={`${title} logo`} className="brand-logo brand-logo--compact" src="/logo/logo2.png" /> <img alt={`${title} logo`} className="brand-logo brand-logo--compact" src="/logo/logo2.png" />
</div> </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} /> <X size={20} />
</button> </button>
</div> </div>
@@ -349,7 +364,9 @@ export function AppShell({
<button <button
aria-expanded={!closedSections[section.title]} aria-expanded={!closedSections[section.title]}
className="side-nav-group-toggle" 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" type="button"
> >
<section.icon size={18} strokeWidth={2.1} /> <section.icon size={18} strokeWidth={2.1} />
@@ -363,7 +380,9 @@ export function AppShell({
) : ( ) : (
<p>{section.title}</p> <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) => { {section.items.map((item) => {
const Icon = item.icon; const Icon = item.icon;
@@ -395,93 +414,101 @@ export function AppShell({
</div> </div>
</aside> </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"> <main className="main-area">
<header className="topbar"> <header className="topbar">
<div className="topbar-left"> <div className="topbar-left">
<span className="desktop-nav-toggle">
<button <button
className="icon-button desktop-nav-toggle" className="icon-button"
onClick={() => setCollapsed((value) => !value)} onClick={() => setCollapsed((value) => !value)}
type="button" type="button"
aria-label={collapsed ? '展开导航' : '收起导航'} aria-label={collapsed ? '展开导航' : '收起导航'}
> >
<ToggleIcon size={18} /> <ToggleIcon size={18} />
</button> </button>
</span>
<span className="mobile-nav-toggle">
<button <button
aria-expanded={mobileNavOpen} aria-expanded={mobileNavOpen}
aria-label={mobileNavOpen ? '关闭导航' : '打开导航'} aria-label={mobileNavOpen ? '关闭导航' : '打开导航'}
className="icon-button mobile-nav-toggle" className="icon-button"
onClick={() => setMobileNavOpen((open) => !open)} onClick={() => setMobileNavOpen((open) => !open)}
type="button" type="button"
> >
<Menu size={20} /> <Menu size={20} />
</button> </button>
</span>
<div className="mobile-topbar-brand"> <div className="mobile-topbar-brand">
<img alt={`${title} logo`} src="/logo/logo1.png" /> <img alt={`${title} logo`} src="/logo/logo1.png" />
</div> </div>
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
<button className="icon-button topbar-help" type="button" aria-label="帮助中心"> <span className="topbar-help">
<button className="icon-button" type="button" aria-label="帮助中心">
<CircleHelp size={18} /> <CircleHelp size={18} />
</button> </button>
{alertNotifications.length ? ( </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"> <div className="notice-menu-wrap">
<button <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); }}
type="button"
>
<Bell size={18} />
{alertTotal > 0 ? <span className="notice-count">{alertTotal}</span> : null}
</button>
{alertNoticeOpen ? (
<div className="notice-popover notice-popover--alerts" role="menu">
<div className="notice-popover__header">
<strong></strong>
<span className={alertTotal === 0 ? 'is-zero' : ''}>{alertTotal} </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>
))}
</div>
) : null}
</div>
) : null}
{auditNotifications.length ? <div className="notice-menu-wrap">
<button
aria-expanded={noticeOpen}
aria-haspopup="menu" aria-haspopup="menu"
className={['icon-button', auditTotal > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')} className={['icon-button', auditTotal > 0 ? 'has-dot' : ''].filter(Boolean).join(' ')}
onClick={() => { setNoticeOpen((open) => !open); setAlertNoticeOpen(false); }} onClick={() => setOpenNotice((open) => (open === 'audits' ? null : 'audits'))}
type="button" type="button"
aria-label="通知" aria-label="通知"
> >
<ClipboardList size={18} /> <ClipboardList size={18} />
{auditTotal > 0 ? <span className="notice-count">{auditTotal}</span> : null} {auditTotal > 0 ? <span className="notice-count">{auditTotal}</span> : null}
</button> </button>
{noticeOpen ? ( {openNotice === 'audits' ? (
<div className="notice-popover" role="menu"> <div className="notice-popover" role="menu">
<div className="notice-popover__header"> <div className="notice-popover__header">
<strong></strong> <strong></strong>
<span className={auditTotal === 0 ? 'is-zero' : ''}>{auditTotal} </span> <span className={auditTotal === 0 ? 'is-zero' : ''}>{auditTotal} </span>
</div> </div>
{auditNotifications.length ? auditNotifications.map((item) => ( {auditNotifications.length ? (
<NavLink key={item.to} onClick={() => setNoticeOpen(false)} role="menuitem" to={item.to}> auditNotifications.map((item) => (
<NavLink key={item.to} onClick={() => setOpenNotice(null)} role="menuitem" to={item.to}>
<span>{item.label}</span> <span>{item.label}</span>
<strong className={item.count === 0 ? 'is-zero' : ''}>{item.count}</strong> <strong className={item.count === 0 ? 'is-zero' : ''}>{item.count}</strong>
</NavLink> </NavLink>
)) : <p></p>} ))
) : (
<p></p>
)}
</div>
) : null}
</div> </div>
) : null} ) : null}
</div> : null}
<div className="user-menu-wrap"> <div className="user-menu-wrap">
<button <button
aria-expanded={userMenuOpen} aria-expanded={userMenuOpen}
@@ -499,14 +526,29 @@ export function AppShell({
</button> </button>
{userMenuOpen ? ( {userMenuOpen ? (
<div className="user-menu-popover" role="menu"> <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} /> <KeyRound size={16} />
</button> </button>
<button onClick={() => { <button
onClick={() => {
setUserMenuOpen(false); setUserMenuOpen(false);
void logout(); void logout();
}} role="menuitem" type="button"> }}
role="menuitem"
type="button"
>
<LogOut size={16} /> <LogOut size={16} />
退 退
</button> </button>
@@ -516,35 +558,81 @@ export function AppShell({
</div> </div>
</header> </header>
<div className="page-content"> <div className="page-content">{routesSuspended ? null : <Outlet />}</div>
{routesSuspended ? null : <Outlet />}
</div>
</main> </main>
<Modal <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)} onClose={() => setPasswordModalOpen(false)}
open={passwordModalOpen} open={passwordModalOpen}
title="修改密码" title="修改密码"
> >
<div className="admin-system-modal-form"> <div className="admin-system-modal-form">
<Input label="当前密码" onChange={(event) => setCurrentPassword(event.target.value)} required type="password" value={currentPassword} /> <Input
<Input label="新密码" onChange={(event) => setNewPassword(event.target.value)} required type="password" value={newPassword} /> label="当前密码"
<Input label="确认新密码" onChange={(event) => setConfirmPassword(event.target.value)} required type="password" value={confirmPassword} /> 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} {passwordError ? <p className="form-error">{passwordError}</p> : null}
</div> </div>
</Modal> </Modal>
<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} onClose={() => undefined}
open={locked} open={locked}
title="会话已安全锁定" title="会话已安全锁定"
> >
<p>使 4 </p> <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} {unlockError ? <p className="login-error">{unlockError}</p> : null}
</Modal> </Modal>
<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)} onClose={() => setIdleWarningSeconds(null)}
open={!locked && idleWarningSeconds !== null} open={!locked && idleWarningSeconds !== null}
title="会话即将锁定" title="会话即将锁定"
@@ -552,13 +640,40 @@ export function AppShell({
<p> {formatCountdown(idleWarningSeconds ?? 0)} 使</p> <p> {formatCountdown(idleWarningSeconds ?? 0)} 使</p>
</Modal> </Modal>
<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></>} footer={
onClose={() => { reauthenticationReject.current?.(new Error('已取消敏感操作')); reauthenticationResolve.current = null; reauthenticationReject.current = null; setReauthenticationOpen(false); }} <>
<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} open={reauthenticationOpen}
title="敏感操作身份验证" title="敏感操作身份验证"
> >
<p> 30 </p> <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} {reauthenticationError ? <p className="login-error">{reauthenticationError}</p> : null}
</Modal> </Modal>
</div> </div>
@@ -569,7 +684,8 @@ function isShellNavItemActive(pathname: string, itemPath: string) {
if (pathname === itemPath) return true; if (pathname === itemPath) return true;
if (itemPath === '/admin' || itemPath === '/client') return false; if (itemPath === '/admin' || itemPath === '/client') return false;
if (pathname.startsWith(`${itemPath}/`)) return true; 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; if (itemPath === '/admin/customer-enterprises' && /^\/admin\/customers\/[^/]+\/edit$/.test(pathname)) return true;
return false; return false;
} }
+6
View File
@@ -10,6 +10,12 @@
"src/styles/components.css" "src/styles/components.css"
], ],
"files": [ "files": [
{
"file": "src/layouts/AlertNotificationMenu.css",
"owners": ["src/layouts/AlertNotificationMenu.tsx"],
"stylelintLegacy": false,
"roots": ["alert-notification-menu"]
},
{ {
"file": "src/apps/admin/ReportMaterialImportModal.css", "file": "src/apps/admin/ReportMaterialImportModal.css",
"owners": ["src/apps/admin/ReportMaterialImportModal.tsx"], "owners": ["src/apps/admin/ReportMaterialImportModal.tsx"],