Commit e5b0ea0c authored by Andy-bubu's avatar Andy-bubu

feat: add Xinyuan notification center

parent 59f021c1
......@@ -11,6 +11,7 @@ const ROUTE_PATHS = {
auditReport: '/audit_report',
auditReportDetail: '/audit_report/:reportId',
auditReportFill: '/audit_report_fill',
notifications: '/notifications',
} as const;
const APP_BASE_PATH = '/audit_manager_new/';
......@@ -50,6 +51,10 @@ export default defineConfig({
path: ROUTE_PATHS.auditReport,
component: './audit_management/AuditReport',
},
{
path: ROUTE_PATHS.notifications,
component: './audit_management/notifications/NotificationListPage',
},
],
},
{
......
......@@ -7,7 +7,7 @@
- 应用显示名:`监察稽核管理`。登录、Sidebar、Navbar、应用切换当前项和浏览器标题必须一致。
- 主色:`#9E1B1B`。选中、焦点、Checkbox、日期、Pagination 和信息态统一使用品牌 Token。
- 浅色 Logo 使用 `src/assets/identity/xy-logo-red.png`;暗色 Logo 使用 `xy-logo-white.png`。不得滤镜、反色、加底板或替换为 RSM 标识。
- 基础组件使用 `@heroui/react`,高级导航、DataGrid、DropZone、ActionBar 和 EmptyState 使用本地镜像 `@heroui-pro/react`。
- 基础组件使用 `@heroui/react`,高级导航、DataGrid、DropZone、ActionBar 和 EmptyState 使用本仓独立正式安装的 `@heroui-pro/react`。
- 图标只从 `src/components/AppIcons.jsx` 导出,底层统一使用 Ant Design Icons。
- 样式导入顺序固定为 Tailwind、HeroUI、HeroUI Pro、鑫元 Token、项目页面样式。
......@@ -27,6 +27,7 @@
- Navbar 高 64px、透明无边框;使用 HeroUI Pro 默认样式的 filled `Sidebar.Trigger` 图标。
- Sidebar 底部用户行整体可点击,菜单内提供退出登录并复用现有 auth service。
- 主题切换位于 Navbar 最右侧,作用于文档根节点并保存到 `xinyuan_color_theme`。
- 通知 Bell 使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,固定 36px 并紧邻主题按钮左侧。0 不显示 Badge,1-99 显示真实值,100 及以上显示 `99+`;点击进入 `/notifications`,Sidebar 不设通知入口。
- 登录采用居中认证 Card。花形 Logo 约 36px,标题约 32px/600;不重复显示“登录”标题。
- 账号、密码、验证码可见高度统一为 40px,字段组可见间距 24px,提交按钮上方保留 24px。
- 登录字段有值或自动填充时使用 `#fbf1f1`,Hover 使用 `#faeaea`,Focus 使用鑫元红描边和浅红焦点环。
......@@ -49,7 +50,7 @@
- 操作按钮 32px 正圆、16px Ant 图标;1-3 个操作直接展示,4 个及以上保留两个高频操作,其余进入更多菜单。
- 状态和标签为单行紧凑 Chip,12px/500;不使用蓝、青、玫红或页面私有方角标签。
- 多选动作仅显示在当前视口底部居中的 ActionBar,右侧使用关闭图标清除选择。
- Pagination 提供 10/20/50 页容量,当前页使用圆形品牌描边和品牌文字,距离 DataGrid 约 8px。
- 业务列表沿用各模块既有分页口径;通知列表固定每页 10 条且不显示页容量选择器。Pagination 距离 DataGrid 约 8px。
- 成功反馈使用顶部居中的 3-4 秒 Toast,不得用页面流 Alert 推挤筛选或表格。
## 6. 表单、上传与弹窗
......
......@@ -26,6 +26,7 @@
- 多选:共享 Autocomplete composition 在 `Autocomplete.Value` 内使用 HeroUI `TagGroup + Tag + Tag.RemoveButton`;根 `selectedKeys` 是唯一选择状态,移除操作回写同一个 `onSelectionChange`,业务页面只负责值协议映射。
- 列表:Pro DataGrid 原生 selection/sort/fixed columns + HeroUI Pagination + Pro ActionBar/EmptyState。
- 弹层:HeroUI Modal/AlertDialog/Dropdown/Tooltip,根主题覆盖 body Portal。
- 通知:页面直接组合 HeroUI Tabs/SearchField/Card/Chip/Badge/Tooltip 与 Pro EmptyState;业务 service 复用 `DataService` 的 `/notice/customer/*` 接口,Shell 只持有真实未读计数和 60 秒刷新事件,不在 Sidebar 建立第二入口。
- 应用切换:共享 `ApplicationSwitcherDialog` 直接组合 HeroUI Modal/Card/Surface/Avatar/Chip 与 Pro EmptyState;`SiteContext.siteInfo.group` 只负责动态分组与顺序,`UserContext.applications` 和 `/application/info` 继续负责访问集合与详情。授权域 `audit_manager` 规范化为唯一当前项,并导航到部署基路径 `/audit_manager_new/`。
- 上传:Pro DropZone,页面边界接收真实 File。
- KPI/图表:仅业务存在时使用 Pro KPI/PieChart/BarChart/ComposedChart/ChartTooltip。
......@@ -45,6 +46,8 @@
UI 迁移不得改变 `record-api.md` 和 `subjectsDefinition.md` 的 URL、method、参数、metadata 结构、引用 ID、ISO 日期、权限、分页/排序口径或请求时机。业务流程变化需同步 `src/pages/audit_management/audit-management.business-flow.md`;报告项目 Tabs 仅改变并列快照的可见方式,搜索作用域收敛到当前项目,不改变查询请求或持久化数据。
跨应用五项能力按显示名白名单分流。“监察稽核管理”不属于六个完整功能应用,因此本仓只接入消息通知;不得新增 shortTitle、Subject View 权限或材料中心文件工作流。通知路由保留部署 base `/audit_manager_new/`,授权域仍为 `audit_manager`。
多值引用 composition 只在页面边界把 HeroUI 的 key 集合映射为原有 `_id[]`。禁止保存完整引用对象、维护第二套内部选择状态,或在 Autocomplete 外部渲染已选 Chip 列表。
## 完成定义
......
# UI 迁移清单
状态日期:2026-08-26。`完成` 仅表示代码、业务保护、双应用浏览器对照和扫描门槛全部通过。
状态日期:2026-08-27。`完成` 仅表示代码、业务保护、双应用浏览器对照和扫描门槛全部通过。
| 模块 | 路由/入口 | 代码迁移 | 浏览器对照 | 业务保护 |
| --- | --- | --- | --- | --- |
......@@ -10,6 +10,7 @@
| AppShell/Navbar/Sidebar | 鉴权路由 | 完成 | 缺有效登录态 | 代码复核完成 |
| 用户菜单/退出 | Sidebar footer | 完成 | 缺有效登录态 | 回调保留 |
| 应用切换 | Shell Modal | 分组/数量/唯一当前态完成 | 2026-08-26 双应用均停在真实登录门禁 | `/info` 分组、应用接口与部署导航保留 |
| 消息通知 | `/notifications`、Navbar Bell | 完成 | 四视口双主题及完整交互完成 | 复用平台通知接口;无 Sidebar 入口 |
| 全应用弹窗尺寸 | HeroUI Modal / AlertDialog | 完成 | 待有效登录态复验 | md 480 / lg 640 / 3xl 960 / 应用切换 736 |
| 稽核项目 | `/audit_process` | 完成 | 缺有效登录态 | 代码复核完成 |
| 多选字段 | 稽核项目表单 | 完成 | 缺有效登录态/打开态复验 | 框内 Tag;`_id[]` 与请求时机保留 |
......@@ -40,6 +41,9 @@
- [x] `npm run build` 与 `git diff --check` 通过;`audit_project.sh` 退出码 0,并报告非活动遗留。
- [x] HeroUI/React Aria 依赖树、Pro 子路径、file/path 扫描通过。
- [x] 无活动页面业务协议变化。
- [x] 功能白名单已审计:本应用仅实施消息通知,未引入 shortTitle、视图权限或材料中心能力。
- [x] 通知列表、真实未读计数、单条/全部已读、动态 action 和 base-aware 跳转已接入。
- [x] 通知 0/1/99/100、已读/全部已读、动态动作、站内外跳转与 Navbar 响应式浏览器验收完成。
## 非活动遗留
......
......@@ -15,6 +15,7 @@
- HeroUI Pro Sidebar 宽 240px,`collapsible="offcanvas"`,关闭后宽度为零且无图标轨道。
- HeroUI Pro Navbar 高 64px;`Sidebar.Trigger` 使用 Pro 原生图形。
- Navbar 与页面内容采用对称 48px/32px/16px gutter;主题操作在最右。
- 通知使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,36px Ant Bell 紧邻主题操作左侧;0 隐藏 Badge、1-99 显示真实值、100 及以上显示 `99+`。点击进入独立 `/notifications` 页面,Sidebar 不重复放置通知入口。
- 用户行由 `Dropdown.Trigger` 打开真实菜单,退出调用目标应用现有服务。
- 应用切换使用 HeroUI Modal,标题固定为“应用切换”,副标题固定为“选择你可访问的应用”;弹窗宽 `min(46rem, 100vw - 2rem)`、高不超过 `min(42rem, 100dvh - 2rem)`,28px 内边距,只有 Modal Body 滚动。
- 应用按 `/info` 的 `site.group` 与组内成员顺序动态分区,隐藏无可访问应用的分组,未被配置引用的可访问应用进入“其他应用”;无分组配置时显示“全部应用”。分组标题右侧显示真实可访问数量,禁止硬编码分组或应用。
......@@ -38,6 +39,7 @@
- 枚举多选和远程引用多选统一使用 Autocomplete 的 `selectionMode="multiple"`、`selectedKeys` 与 `onSelectionChange`。已选项必须在 `Autocomplete.Value` 内使用 HeroUI `TagGroup > TagGroup.List > Tag + Tag.RemoveButton` 展示,Tag 在触发框内换行并支持键盘访问和逐项移除;禁止在选择框下方或外部另起 Chip/Badge/Tag 列表。
- 多选触发框、选项选中态、Tag 与移除动作必须由同一个受控数组派生。引用字段查询时机不变,移除和提交仍只传 `_id[]`,不得传完整对象。单选字段继续显示普通 `Autocomplete.Value` 文本;状态 Chip、只读 DataGrid 标签和文件列表不套用多选字段规则。
- 页面级 Tabs 的根节点、ListContainer 和 List 必须按自身文本内容收缩,不得使用 `flex-1`、`w-full` 或剩余空间填充轨道;同行业务操作通过外层布局靠右。只有一个可选视图时隐藏轨道。
- 通知页沿用问题整改基准的消息/待办、全部/未读两级内容宽度 Tabs,300ms 搜索、固定 10 条分页、单条/全部已读和动态 action。通知正文只作纯文本;自身 base 链接站内跳转,其他应用与外部链接新窗口打开。
- 同一业务视图内存在结构与操作一致的并列数据集时,使用第二级 HeroUI Tabs 切换并一次只展示当前数据集,不顺序堆叠重复表头。监察稽核报告固定采用“进行中/历史报告”一级 Tabs 和“稽核项目”二级 Tabs;两级轨道均为内容宽度,不使用等分或整行拉伸样式。
- 二级项目 Tabs 只控制可见快照,不改变整体 KPI、保存、提交、权限或数据索引;列表搜索作用于当前项目,Tabs 保持完整稳定,切换项目后继续使用该项目原始 `processIndex / pointIndex` 回调。
- 项目名称已经出现在二级 Tabs 时,当前项目列表区不重复项目标题或完成数;整体完成数由页面 KPI 承担。二级 Tabs 后使用 16px 间距进入列表工具栏,再以 16px 间距进入 DataGrid。
......
......@@ -33,6 +33,7 @@
| `/audit_report` | 稽核报告 | Site + Auth、当前/历史报告 |
| `/audit_report/:reportId` | 历史报告详情 | Site + Auth、详情与导出 |
| `/audit_report_fill` | 外部填报 | Site、ticket/report/owner 参数 |
| `/notifications` | 消息通知 | Site + Auth、平台通知查询/已读/动态动作 |
`process_monitor`、`RecordDetailDrawer`、`record-workbench` 未进入 `.umirc.ts`,属于非活动存量范围,不计入活动页面完成声明;它们仍有 Catalyst/Headless 引用,恢复路由前必须迁移。
......@@ -76,8 +77,28 @@
- 多值引用的 `selectedKeys`、Tag 和移除动作必须来自同一个 `_id[]`;不得维护第二套选择状态或提交完整对象。
- 无真实登录凭据时不能伪造业务数据或声称已完成登录后浏览器验收。
## 2026-08-27 跨应用功能审计
- 应用显示名为“监察稽核管理”,不在六个完整功能应用白名单内;本轮只实施功能 4“消息通知”。Grid/detail shortTitle、Subject View 权限和材料中心文件能力均保持未接入。
- 部署 base/publicPath 为 `/audit_manager_new/`,应用授权域为 `audit_manager`。通知内部链接只允许去除自身 base,不复用问题整改的 `issue_rectification` 标识。
- `UserContext` 从 `/user/info` 提供 `_id`、`applications[]` 和现有 logout;本轮不改变 applications 授权结构、登录或退出流程。
- 现有 `DataService` 已提供 `POST /notice/customer/query`、`GET /notice/customer/count`、`PUT /notice/customer/read` 和动态 action 接口,无需新增 API 协议。
- 原 Shell 数据流为 `Layout -> AppShell -> DashboardNavbar`;Navbar 原先只有主题按钮,Sidebar 无通知项。接入点确定为 Layout 持有真实未读数、AppShell 传递导航回调、Navbar 原生 Badge compound。
- `record-workbench`、`RecordDetailDrawer`、`subjectView.js`、文件字段协议与业务保存回调已审计,但均不在活动路由和本轮白名单范围;不修改这些遗留实现。
- HeroUI Pro 为目标仓独立正式安装的 `1.0.0-beta.8`,解析到本仓 pnpm virtual store;要求的 css/sidebar/navbar/data-grid/drop-zone/action-bar/empty-state 子路径均可导入。`package.json` 与 `pnpm-lock.yaml` 没有 `file:`、相邻 node_modules 或绝对本机依赖。
- 新通知页面仅直接使用 `@heroui/react`、`@heroui-pro/react/empty-state` 和共享 Ant 图标,不新增 Catalyst、Headless UI、页面 CSS 或可见原生控件。
## 验收证据与剩余边界
### 2026-08-27 通知中心验收证据
- 目标预览:`http://localhost:8000/audit_manager_new/`;问题整改基准同时运行于 `http://localhost:8012/problem_rectification_new/`。
- 真实账号 Andy 完成目标应用验证码登录;真实接口返回通知总数 0,确认 `/notice/customer/query` 与 `/notice/customer/count` 请求成功、0 Badge 隐藏、空态可用。验证码失败重试仅发生在浏览器会话,不改登录代码或业务数据。
- 浏览器会话内仅拦截通知接口提供代表性消息/待办行,未写入服务端。目标与基准页面在 1440x900、1280x720、768x1024、390x844 及 light/dark 截图完成几何检查;Shell gutter、Bell/主题按钮、Tabs、搜索、卡片、分页和移动端换行无横向溢出。
- 已验证消息/待办切换、全部/未读查询、未读 `status`、300ms 转义搜索、纯文本正文(`<b>` 不执行)、动态 action `{notice_id}`、单条已读 `{name}`、全部已读空对象、站内去 base 路由及跨应用新窗口跳转。
- Badge 边界通过浏览器拦截 count 响应验证:0 隐藏、1 显示 `1`、99 显示 `99`、100 显示 `99+`;通知按钮保持主题按钮左侧 36px。
- 目标和基准均使用全新有效会话完成最终截图,console 均为 0 error / 0 warning;四视口下 `documentElement.scrollWidth === clientWidth`,无页面级横向溢出。
- `npm run build` 与 `git diff --check` 通过。
- 2026-08-26 应用切换变更后重新执行生产构建,Webpack 编译通过;目标独立开发服务运行于 `http://localhost:8011/audit_manager_new/`,`model_analysis` 基准运行于 `http://localhost:8005/model_analysis/`,两者 HTTP 200。
- 使用全新 Playwright 会话分别进入目标 `/audit_process` 和基准 `/models/overview`,两者都由真实鉴权重定向至各自登录页,登录页 console 均为 0 error / 0 warning。现存带 `browser-only-token` 的 QA 会话未用于验收,也未向目标注入用户、token 或业务数据。
......
import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined';
......@@ -62,6 +63,7 @@ export const Bank = withSize(BankOutlined);
export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined);
export const Bell = withSize(BellOutlined);
export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined);
export const ChevronDown = withSize(DownOutlined);
......
......@@ -15,7 +15,15 @@ function getInitialTheme() {
}
}
export default function AppShell({ children, open, onOpenChange, title, ...sidebarProps }) {
export default function AppShell({
children,
notificationCount,
notificationPath,
open,
onOpenChange,
title,
...sidebarProps
}) {
const [theme, setTheme] = useState(getInitialTheme);
useLayoutEffect(() => {
......@@ -41,7 +49,15 @@ export default function AppShell({ children, open, onOpenChange, title, ...sideb
>
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar theme={theme} title={title} onThemeChange={setTheme} />
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={
notificationPath ? () => history.push(notificationPath) : undefined
}
theme={theme}
title={title}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
<div className="rc-app-page">{children}</div>
</div>
......
import { Moon, SidebarPanel, Sun } from '@/components/AppIcons';
import { Bell, Moon, SidebarPanel, Sun } from '@/components/AppIcons';
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Badge, Button, Tooltip } from '@heroui/react';
export default function DashboardNavbar({ onThemeChange, theme, title }) {
export default function DashboardNavbar({
notificationCount = 0,
onOpenNotifications,
onThemeChange,
theme,
title,
}) {
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
......@@ -20,6 +27,34 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer />
<div className="rc-navbar-actions">
{onOpenNotifications ? (
<Tooltip delay={0}>
<Badge.Anchor>
<Button
isIconOnly
aria-label="通知消息"
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={onOpenNotifications}
>
<Bell size={16} />
</Button>
{normalizedNotificationCount > 0 ? (
<Badge
aria-label={`${normalizedNotificationCount}条未读通知`}
color="danger"
size="sm"
>
{normalizedNotificationCount > 99
? '99+'
: normalizedNotificationCount}
</Badge>
) : null}
</Badge.Anchor>
<Tooltip.Content>通知消息</Tooltip.Content>
</Tooltip>
) : null}
<Tooltip delay={0}>
<Button
isIconOnly
......
......@@ -10,6 +10,7 @@ export const APP_NAV_ITEMS = Object.freeze([
]);
export function getAppRouteTitle(pathname) {
if (pathname === '/notifications') return '通知消息';
if (pathname.startsWith('/audit_report/')) return '报告详情';
return APP_NAV_ITEMS.find((item) => pathname === item.path)?.label || APP_DISPLAY_NAME;
}
......@@ -2,6 +2,10 @@ import AppShell from '@/components/AppShell';
import { FileText, Project } from '@/components/AppIcons';
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import { APP_DISPLAY_NAME, getAppRouteTitle } from '@/constants/app';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from '@/pages/audit_management/notifications/notificationService';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { useContext, useEffect, useMemo, useState } from 'react';
......@@ -24,6 +28,7 @@ export default function AuditManagementLayout() {
const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const currentTitle = useMemo(
() => getAppRouteTitle(location.pathname),
[location.pathname],
......@@ -33,10 +38,35 @@ export default function AuditManagementLayout() {
document.title = `${currentTitle} - ${APP_DISPLAY_NAME}`;
}, [currentTitle]);
useEffect(() => {
let cancelled = false;
const refreshUnreadCount = async () => {
try {
const count = await loadUnreadNotificationCount();
if (!cancelled) setUnreadNotificationCount(count);
} catch {
if (!cancelled) setUnreadNotificationCount(0);
}
};
refreshUnreadCount();
window.addEventListener(NOTIFICATION_STATE_EVENT, refreshUnreadCount);
const timer = window.setInterval(refreshUnreadCount, 60000);
return () => {
cancelled = true;
window.removeEventListener(NOTIFICATION_STATE_EVENT, refreshUnreadCount);
window.clearInterval(timer);
};
}, [location.pathname]);
return (
<>
<AppShell
menuGroups={MENU_GROUPS}
notificationCount={unreadNotificationCount}
notificationPath="/notifications"
onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)}
open={sidebarOpen}
......
......@@ -6,6 +6,7 @@
- `/audit_report`:进行中报告复核、历史报告列表
- `/audit_report/:reportId`:已归档报告只读详情
- `/audit_report_fill?owner=:departmentId&report=:reportId&ticket=:ticket`:责任部门填报
- `/notifications`:当前用户的消息与待办通知
应用部署基路径为 `/audit_manager_new/`。主应用导航使用 HeroUI Pro Sidebar,桌面端采用 offcanvas 折叠,移动端使用 `Sidebar.Mobile`。
......@@ -81,7 +82,21 @@
| 报告进行中 | 进行中 | `metadata.status=current` |
| 报告提交 | 已归档 | `metadata.status=history` |
## 7. 当前边界
## 7. 消息通知
1. Navbar 主题按钮左侧显示通知 Bell;未读数为 0 时隐藏 Badge,1-99 显示真实数值,100 及以上显示 `99+`。点击后进入 `/notifications`,Sidebar 不提供重复入口。
2. Shell 首次进入、路由变化、通知状态事件和每 60 秒调用 `GET /notice/customer/count` 刷新未读数;请求失败回退为 0,不制造本地计数。
3. 通知页按 `message / todo` 和 `all / unread` 查询。搜索内容在 300ms 防抖后作为转义正则提交,分页固定每页 10 条;未读筛选向服务端传 `status=unread`。
4. 单条已读调用 `PUT /notice/customer/read` 并提交通知 `name`;全部已读沿用同一接口的空 `name` 协议。待办动态操作调用通知配置的 action/method,并只提交 `{notice_id}`。
5. 通知正文始终作为纯文本渲染,不执行服务端 HTML。进入关联流程前先标记当前通知已读。
6. 以 `/audit_manager_new` 开头的站内目标去除自身 base 后交给 Umi 路由;其他应用路径和外部 HTTP(S) 链接在新窗口打开。
异常与限制:
- 列表或操作失败时保留当前分类、筛选和页码,并显示可重试错误或 Toast。
- 通知页不改变稽核项目、报告、权限、保存回调及请求时机。
## 8. 当前边界
- 本次重构提供按责任部门复制填报链接,不自动发送邮件。
- 旧版邮件发送依赖未收录于 `record-api.md` 的邮件接口;在补齐接口契约、发件身份、失败重试和审计要求前不接入。
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
Chip,
SearchField,
Spinner,
Tabs,
toast,
} from '@heroui/react';
import { useCallback, useContext, useEffect, useState } from 'react';
import { history } from 'umi';
import {
executeNotificationAction,
extractNotificationTarget,
getNotificationContent,
getNotificationSender,
isNotificationRead,
loadNotificationPage,
loadUnreadNotificationCount,
markAllNotificationsRead,
markNotificationRead,
NOTIFICATION_PAGE_SIZE,
resolveNotificationTarget,
} from './notificationService';
const CATEGORY_OPTIONS = [
{ key: 'message', label: '消息' },
{ key: 'todo', label: '待办' },
];
const READ_STATUS_OPTIONS = [
{ key: 'all', label: '全部' },
{ key: 'unread', label: '未读' },
];
function formatNotificationTime(value) {
if (!value) return '时间未知';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(date);
}
function NotificationItem({
notification,
category,
currentUserId,
isBusy,
onAction,
onMarkRead,
onOpenTarget,
}) {
const isRead = isNotificationRead(notification, currentUserId);
const sender = getNotificationSender(notification);
const content = getNotificationContent(notification) || '暂无通知内容';
const target = resolveNotificationTarget(extractNotificationTarget(notification));
const actions = Array.isArray(notification?.actions) ? notification.actions : [];
const avatarText = sender.trim().slice(0, 1).toUpperCase() || '系';
return (
<Card>
<Card.Content className="flex flex-col gap-4 p-4 sm:p-5">
<div className="flex min-w-0 items-start gap-3">
<Avatar className="shrink-0" size="sm">
<Avatar.Fallback>{avatarText}</Avatar.Fallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h2 className="break-words text-sm font-medium text-foreground sm:text-base">
{notification?.title || (category === 'todo' ? '待办通知' : '通知消息')}
</h2>
<p className="mt-1 text-xs text-muted sm:text-sm">
{sender} · {formatNotificationTime(notification?.sent_at || notification?.created_at)}
</p>
</div>
<Chip color={isRead ? undefined : 'danger'} size="sm" variant="soft">
{isRead ? (category === 'todo' ? '已完成' : '已读') : category === 'todo' ? '待处理' : '未读'}
</Chip>
</div>
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-foreground">
{content}
</p>
</div>
</div>
<div className="flex min-h-8 flex-wrap items-center justify-end gap-2">
{!isRead ? (
<Button
isDisabled={isBusy}
size="sm"
variant="ghost"
onPress={() => onMarkRead(notification)}
>
标为已读
</Button>
) : null}
{category === 'todo'
? actions.map((action, index) => (
<Button
isDisabled={isBusy || isRead}
key={`${action?.label || 'action'}-${index}`}
size="sm"
variant="secondary"
onPress={() => onAction(notification, action)}
>
{action?.label || '处理'}
</Button>
))
: null}
{target ? (
<Button
isDisabled={isBusy}
size="sm"
variant="primary"
onPress={() => onOpenTarget(notification, target)}
>
<OpenExternal size={14} />
进入流程
</Button>
) : null}
</div>
</Card.Content>
</Card>
);
}
export default function NotificationListPage() {
const userInfo = useContext(UserContext);
const [category, setCategory] = useState('message');
const [readStatus, setReadStatus] = useState('all');
const [searchText, setSearchText] = useState('');
const [keyword, setKeyword] = useState('');
const [page, setPage] = useState(0);
const [notifications, setNotifications] = useState([]);
const [total, setTotal] = useState(0);
const [unreadTotal, setUnreadTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [busyKey, setBusyKey] = useState('');
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const timer = window.setTimeout(() => {
setKeyword(searchText.trim());
setPage(0);
}, 300);
return () => window.clearTimeout(timer);
}, [searchText]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
loadNotificationPage({
page,
pageSize: NOTIFICATION_PAGE_SIZE,
category,
readStatus,
keyword,
})
.then((result) => {
if (cancelled) return;
setNotifications(result.list);
setTotal(result.total);
})
.catch((requestError) => {
if (cancelled) return;
setNotifications([]);
setTotal(0);
setError(requestError?.message || '通知列表加载失败,请稍后重试');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [category, keyword, page, readStatus, reloadKey]);
useEffect(() => {
let cancelled = false;
loadUnreadNotificationCount()
.then((count) => {
if (!cancelled) setUnreadTotal(count);
})
.catch(() => {
if (!cancelled) setUnreadTotal(0);
});
return () => {
cancelled = true;
};
}, [reloadKey]);
const refresh = useCallback(() => setReloadKey((value) => value + 1), []);
const runItemOperation = useCallback(
async (key, operation, successMessage) => {
setBusyKey(key);
try {
await operation();
toast.success(successMessage, { timeout: 3000 });
refresh();
return true;
} catch (operationError) {
toast.danger(operationError?.message || '操作失败,请稍后重试', {
timeout: 4000,
});
return false;
} finally {
setBusyKey('');
}
},
[refresh],
);
const handleMarkRead = useCallback(
(notification) =>
runItemOperation(
notification.name,
() => markNotificationRead(notification.name),
'已标记为已读',
),
[runItemOperation],
);
const handleMarkAllRead = useCallback(async () => {
await runItemOperation(
'all',
markAllNotificationsRead,
'全部通知已标记为已读',
);
}, [runItemOperation]);
const handleAction = useCallback(
(notification, action) =>
runItemOperation(
notification.name,
() => executeNotificationAction(notification, action),
'待办已处理',
),
[runItemOperation],
);
const handleOpenTarget = useCallback(
async (notification, target) => {
setBusyKey(notification.name);
try {
if (!isNotificationRead(notification, userInfo?._id)) {
await markNotificationRead(notification.name);
}
if (target.external) {
window.open(target.href, '_blank', 'noopener,noreferrer');
} else {
history.push(target.href);
}
refresh();
} catch (operationError) {
toast.danger(operationError?.message || '无法打开通知关联流程', {
timeout: 4000,
});
} finally {
setBusyKey('');
}
},
[refresh, userInfo?._id],
);
return (
<div className="rc-page-stack">
<div className="rc-page-toolbar rc-page-toolbar-responsive">
<Tabs
className="w-fit max-w-full min-w-0"
selectedKey={category}
onSelectionChange={(key) => {
setCategory(String(key));
setPage(0);
}}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="通知分类" className="w-max min-w-max">
{CATEGORY_OPTIONS.map((option) => (
<Tabs.Tab id={option.key} key={option.key}>
{option.label}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
<Button
isDisabled={loading || unreadTotal === 0}
isLoading={busyKey === 'all'}
variant="secondary"
onPress={handleMarkAllRead}
>
全部已读
</Button>
</div>
<div className="rc-page-toolbar">
<div className="w-full min-w-0 sm:w-80 lg:w-96">
<SearchField
aria-label="搜索通知内容"
fullWidth
value={searchText}
variant="primary"
onChange={setSearchText}
>
<SearchField.Group>
<SearchField.SearchIcon>
<MagnifyingGlassIcon size={16} />
</SearchField.SearchIcon>
<SearchField.Input placeholder="搜索通知内容" />
<SearchField.ClearButton aria-label="清空通知搜索">
<Close size={12} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
</div>
<Tabs
selectedKey={readStatus}
onSelectionChange={(key) => {
setReadStatus(String(key));
setPage(0);
}}
>
<Tabs.ListContainer>
<Tabs.List aria-label="通知状态">
{READ_STATUS_OPTIONS.map((option) => (
<Tabs.Tab id={option.key} key={option.key}>
{option.label}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
</div>
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
<Button size="sm" variant="ghost" onPress={refresh}>
重试
</Button>
</Alert>
) : null}
{loading && notifications.length === 0 ? (
<div className="flex min-h-64 items-center justify-center" aria-label="通知加载中">
<Spinner size="sm" />
</div>
) : null}
{!loading && !error && notifications.length === 0 ? (
<EmptyState className="min-h-64" size="md">
<EmptyState.Media>
<Bell size={22} />
</EmptyState.Media>
<EmptyState.Header>
<EmptyState.Title>
{category === 'todo' ? '暂无待办通知' : '暂无通知消息'}
</EmptyState.Title>
<EmptyState.Description>
{keyword
? '没有找到匹配的通知,请调整搜索条件。'
: readStatus === 'unread'
? '当前没有未读通知。'
: '新的通知会显示在这里。'}
</EmptyState.Description>
</EmptyState.Header>
</EmptyState>
) : null}
{notifications.length > 0 ? (
<div className="flex min-w-0 flex-col gap-3" aria-busy={loading}>
{notifications.map((notification) => (
<NotificationItem
category={category}
currentUserId={userInfo?._id}
isBusy={busyKey === notification.name}
key={notification.name || notification._id}
notification={notification}
onAction={handleAction}
onMarkRead={handleMarkRead}
onOpenTarget={handleOpenTarget}
/>
))}
</div>
) : null}
{!error && total > 0 ? (
<DataTablePagination
isDisabled={loading}
itemLabel="条通知"
page={page}
pageSize={NOTIFICATION_PAGE_SIZE}
total={total}
onPageChange={setPage}
/>
) : null}
</div>
);
}
import {
exacteActionFunction,
queryUserNotice,
readUserNotice,
unReadNoticeCount,
} from '@/services/DataService';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'audit-manager:notification-state';
function toArray(value) {
return Array.isArray(value) ? value : [];
}
function toId(value) {
if (value && typeof value === 'object') {
return String(value._id || value.id || value.name || '');
}
return String(value || '');
}
function escapeRegExp(value) {
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export async function loadNotificationPage({
page = 0,
pageSize = NOTIFICATION_PAGE_SIZE,
category = 'message',
readStatus = 'all',
keyword = '',
} = {}) {
const filter = { category };
const normalizedKeyword = String(keyword || '').trim();
if (normalizedKeyword) {
filter.content = {
$regex: escapeRegExp(normalizedKeyword),
$options: 'i',
};
}
const response = await queryUserNotice(
page,
pageSize,
filter,
readStatus === 'unread' ? 'unread' : null,
);
return {
list: toArray(response?.list),
page: Number(response?.current_page ?? page) || 0,
pageSize: Number(response?.page_size ?? pageSize) || pageSize,
total: Number(response?.total_notices ?? response?.total ?? 0) || 0,
totalPages: Number(response?.total_pages ?? 0) || 0,
};
}
export async function loadUnreadNotificationCount() {
const response = await unReadNoticeCount();
return Math.max(
0,
Number(
response?.total_notices ??
response?.count ??
response?.total ??
response ??
0,
) || 0,
);
}
export async function markNotificationRead(name) {
if (!name) throw new Error('通知缺少已读标识');
await readUserNotice(name);
notifyNotificationStateChanged();
}
export async function markAllNotificationsRead() {
await readUserNotice();
notifyNotificationStateChanged();
}
export async function executeNotificationAction(notification, action) {
const actionName =
typeof action?.action === 'string'
? action.action
: action?.action?.name || action?.name;
const methodName = action?.method_name || action?.methodName;
if (!actionName || !methodName) {
throw new Error('该待办操作缺少执行配置');
}
const result = await exacteActionFunction(actionName, methodName, {
notice_id: notification?._id,
});
notifyNotificationStateChanged();
return result;
}
export function isNotificationRead(notification, userId) {
const normalizedUserId = toId(userId);
if (!normalizedUserId) return false;
return toArray(notification?.data?.logs).some(
(log) => toId(log?.user_id) === normalizedUserId,
);
}
export function getNotificationSender(notification) {
return (
notification?.from?.display_name ||
notification?.from?.username ||
notification?.created_by?.display_name ||
notification?.created_by?.username ||
'系统'
);
}
export function getNotificationContent(notification) {
return String(notification?.content || notification?.content_compile || '').trim();
}
function trimTarget(target) {
return String(target || '').replace(/[),.;!?,。;!?)]+$/u, '');
}
export function extractNotificationTarget(notification) {
const configuredTarget =
notification?.settings?.url ||
notification?.settings?.link ||
notification?.data?.url ||
notification?.data?.link;
if (configuredTarget) return trimTarget(configuredTarget);
const content = getNotificationContent(notification);
const match = content.match(
/https?:\/\/[^\s<>"']+|\/audit_manager_new\/[^\s<>"']+|\/audit_[A-Za-z0-9_/-]+(?:\?[^\s<>"']*)?/,
);
return trimTarget(match?.[0]);
}
export function resolveNotificationTarget(target) {
const normalizedTarget = trimTarget(target);
if (!normalizedTarget) return null;
if (/^https?:\/\//i.test(normalizedTarget)) {
try {
const url = new URL(normalizedTarget);
const isSameOrigin =
typeof window !== 'undefined' && url.origin === window.location.origin;
if (
isSameOrigin &&
(url.pathname === '/audit_manager_new' ||
url.pathname.startsWith('/audit_manager_new/'))
) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch {
return null;
}
}
const appPrefix = '/audit_manager_new';
if (
normalizedTarget !== appPrefix &&
!normalizedTarget.startsWith(`${appPrefix}/`)
) {
return normalizedTarget.startsWith('/')
? { href: normalizedTarget, external: true }
: null;
}
return {
href: normalizedTarget.slice(appPrefix.length) || '/',
external: false,
};
}
export function notifyNotificationStateChanged() {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(NOTIFICATION_STATE_EVENT));
}
}
......@@ -268,4 +268,15 @@ Query:`calculated=true|false`
7. **导入接口**:支持 name 自动转 id,但仅限同一 subject 内查找。
如需补充特定 subject 的 metadata 字段规范、文件上传流程、权限点详细对应表等,请继续提供信息,我可以进一步扩展对应章节。
## 5. 用户通知
以下接口沿用平台现有通知协议,均由当前登录用户鉴权:
- `POST /notice/customer/query`:查询通知;请求体为 `{page, page_size, filter, status}`。`filter.category` 为 `message` 或 `todo`,内容搜索使用转义后的 `filter.content.$regex`;未读筛选传 `status: "unread"`。
- `GET /notice/customer/count`:返回当前用户真实未读数量。
- `PUT /notice/customer/read`:单条已读传 `{name}`;全部已读沿用 `{name: undefined}` 的现有请求形态。
- `PUT /notice/customer/:action/action/:fname`:执行服务端配置的待办动作,请求体只增加 `{notice_id}`。
通知分页固定每页 10 条。前端不得执行 `content` 或 `content_compile` 中的 HTML,也不得本地伪造未读数。
```
......@@ -313,3 +313,7 @@ type DetailField = {
- `PUT /record/:name`:更新项目控制点、报告填报与状态
附件填报复用现有 `DataService.createDocument(formData)` 文档上传能力;上传成功返回的文档对象存放在报告快照控制点的 `value` 中。
## 7. 通知数据边界
消息通知由平台 `/notice/customer/*` 接口提供,不是本应用 Subject,不通过 `/subject`、`/record` 或 `metadata` 协议读写。本次通知中心不新增或修改任何 Subject、View、稽核项目字段或报告字段。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment