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

feat: add notification center

parent 2abf70b5
......@@ -8,6 +8,7 @@ function resolvePackageRoot(packageName: string) {
const ROUTE_PATHS = {
login: '/login',
processMonitor: '/process_monitor',
notifications: '/notifications',
processTemplateList: '/process_monitor/:processId',
processDetail: '/process_monitor/:processId/:application',
} as const;
......@@ -47,6 +48,10 @@ export default defineConfig({
path: ROUTE_PATHS.processMonitor,
component: './process_monitor/ProcessMonitor',
},
{
path: ROUTE_PATHS.notifications,
component: './notifications/NotificationListPage',
},
{
path: ROUTE_PATHS.processTemplateList,
component: './process_monitor/ProcessTemplateList',
......
......@@ -48,3 +48,18 @@
- `fields_definition` 继续兼容数组/对象映射并驱动动态列。
- 附件继续先调用 `sync_progress:get_attachment_link`。
- 登录参数、验证码、密码加密、logout、权限、保存回调、请求时机和分页协议保持不变。
## 通知中心架构
- `src/pages/notifications/NotificationListPage.jsx` 只负责通知列表状态和 HeroUI 交互;
`notificationService.js` 负责通知 API、状态判断、动作调用和 base-aware 跳转。
- 真实未读数由 `GET /notice/customer/count` 注入 AppShell/Navbar;通知操作派发
`oa-process-monitor:notification-state` 事件,Shell 监听该事件并每 60 秒轮询刷新。
- `POST /notice/customer/query` 使用 `{ page, page_size, filter, status }`,消息/待办由
`filter.category` 区分,未读通过 `status: "unread"`;读取使用
`PUT /notice/customer/read`,动态 action 使用
`PUT /notice/customer/:action/action/:fname` 和 `{ notice_id }`。
- 站内链接仅剥离 `/oa_process_monitor_new/` 后交给 Umi history;其他应用路径与 HTTP(S)
链接在新窗口打开。通知文本不作为 HTML 插入 DOM。
- Sidebar 不显示通知菜单项,通知中心是 Navbar 全局动作;通知失败使用 HeroUI Toast/Alert
反馈,不改变原有登录、退出和业务页面请求。
......@@ -29,6 +29,22 @@
- [x] 同步更新 `process-monitor.business-flow.md`
- [N/A] 图表:目标应用没有图表业务页面
## 白名单功能范围
- [x] OA流程监控按跨应用白名单仅实施功能 4:消息通知
- [N/A] Grid/Detail shortTitle:非六个完整功能应用,明确不实施
- [N/A] Subject View 授权:非六个完整功能应用,明确不实施
- [N/A] 审计材料中心文件流程:非六个完整功能应用,明确不实施
## 消息通知
- [x] `/notifications` 独立路由和应用 base-aware 导航
- [x] Navbar `Badge.Anchor + Badge + Button + Tooltip`,真实未读数和 99+ 上限
- [x] 消息/待办、全部/未读、300ms 转义搜索、固定 10 条分页
- [x] 单条已读、全部已读、动态 action 和 `{ notice_id }` 请求体
- [x] 站内链接剥离自身 base,外部链接新窗口,通知内容纯文本渲染
- [x] 状态事件刷新和 60 秒未读轮询
## 浏览器验收
- [x] 1440x900 浅色/深色目标与基准对照
......@@ -38,7 +54,7 @@
- [x] 登录、Sidebar、Navbar、应用切换、Modal、Autocomplete 打开态和空列表
- [x] 干净退出与应用切换会话控制台 0 error / 0 warning
- [x] Autocomplete、Modal、应用切换和移动 Sidebar 对话框可访问名称
- [~] populated DataGrid、Checkbox 半选、ActionBar、真实记录 CRUD、实例详情和附件:缺少有效业务会话
- [~] populated DataGrid、Checkbox 半选、ActionBar、真实记录 CRUD、实例详情和附件:缺少有效业务会话;不属于本次通知白名单范围
## 最终门槛
......
......@@ -52,3 +52,17 @@
- 浅色使用 `xy-logo-red.png`,深色使用 `xy-logo-white.png`,不滤镜、不反色、不替换 RSM 标识。
- 根元素持有 `.light/.dark` 与匹配的 `data-theme`,所有 Portal 继承根主题。
- 组件外观只来自 HeroUI CSS、Pro CSS、权威 theme.css 和批准的共享 Tokens。
## 通知中心
- `OA流程监控` 不在六个完整功能应用白名单内,本项目只实施消息通知,不加入
`shortTitle`、Subject View 授权或审计材料中心文件流程。
- 通知页路由为 `/notifications`,沿用应用 base `/oa_process_monitor_new/`。
- Navbar 使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,Ant Design
`BellOutlined` 位于主题按钮左侧;未读数为 0 时隐藏徽标,1-99 显示真实值,100 及以上显示
`99+`,可访问名称保留真实数量。
- 通知页使用 HeroUI Tabs、SearchField、Card、Chip、Pagination、Toast 和 HeroUI Pro
EmptyState。查询固定每页 10 条,搜索延迟 300ms 并转义正则字符,未读状态通过服务端
`status: "unread"` 查询。
- 单条已读、全部已读和服务端动态待办 action 继续使用平台接口;action 请求体只传
`{ notice_id }`。通知内容按纯文本渲染,禁止执行服务端 HTML。
# OA流程监控鑫元 UI 审计
审计日期:2026-08-20
审计日期:2026-08-27
## 范围与基准
- 目标仓库:`oa_process_monitor`,分支 `codex/xinyuan-ui-ux`。
- 整体实现基准:本机 `problem_rectification` 的实际代码与运行页面;应用切换专项基准为本机 `model_analysis` 当前实现。
- 路由基址:`/oa_process_monitor_new/`。
- 活动路由:`/login`、`/process_monitor`、`/process_monitor/:processId`、`/process_monitor/:processId/:application`。
- 活动路由:`/login`、`/process_monitor`、`/notifications`、`/process_monitor/:processId`、`/process_monitor/:processId/:application`。
- 业务依据:`src/services/record-api.md`、`src/services/subjectsDefinition.md`、`src/pages/process_monitor/process-monitor.business-flow.md`。
## 安装与依赖结论
......@@ -16,6 +16,7 @@
- 已在该目录成功执行用户指定的官方 `pnpm dlx hpsetup@latest` 授权安装命令;授权值未写入源码、文档、环境文件或 Git 提交。
- `@heroui-pro/react@1.0.0-beta.8` 解析到 `/Users/liuchenlong/Desktop/job/oa_process_monitor/node_modules/.pnpm/@heroui-pro+react@1.0.0-beta.8/node_modules/@heroui-pro/react/package.json`。
- `@heroui/react@3.2.4`、`@heroui/styles@3.2.4` 使用正式版本,并与问题整改当前运行依赖一致,不再存在跨项目 `file:` 依赖。
- 本次功能没有改动 `package.json` 或 `pnpm-lock.yaml`;HeroUI Pro 已在目标仓库独立安装,因而没有再次执行安装命令。
- `package.json` 写入正式版本依赖及 Pro 运行依赖;`package-lock.json` 已移除,`pnpm-lock.yaml` 已更新并作为唯一锁文件保留,Umi 的 `npmClient` 已切换为 `pnpm`。
- `@heroui-pro/react/css`、`sidebar`、`navbar`、`data-grid`、`drop-zone`、`action-bar`、`empty-state` 均通过 ESM 导入检查,CSS 解析目标文件存在。
- 监控分组 Tabs 使用问题整改相同的 compound 布局合同;同步 HeroUI 3.2.4 后,Tabs 根轨道与各标签均按内容宽度收敛,不再均分或占满页面剩余宽度。
......@@ -46,6 +47,23 @@
- 仅按批准合同把列表固定为每页 10 条并移除 page-size UI;请求仍使用原有 `page` / `page_size` 协议。
- 401 仍在相同响应时机清理登录态并跳转登录页,只修正为带 Umi base 的应用内地址。
## 通知中心审计
- 按跨应用白名单判定,OA流程监控仅新增通知中心;没有新增 shortTitle、视图权限或材料中心
文件服务/路由。
- 目标与问题整改均以 Andy 真实账号登录,在 1280x720 下打开 `/notifications` 对照,Shell、
Navbar Bell/主题按钮、Tabs、搜索、空状态和间距一致;目标真实接口返回 0 条通知。
- 浏览器内路由拦截覆盖了 0/1/99/100 未读数、消息/待办、未读查询、正则转义搜索、分页、
单条已读、全部已读、动态 action、站内/外部跳转和纯文本 HTML 渲染。拦截只存在于 Playwright
会话,不写入仓库或服务端数据。
- 通知 API 实测:`POST /api/notice/customer/query`、`GET /api/notice/customer/count` 均
`200`;动态 action 使用 `PUT /api/notice/customer/approval/action/approve`,请求体为
`{ "notice_id": "..." }`;控制台 0 error / 0 warning。
- 目标与问题整改通知页已在 1440x900、1280x720、768x1024、390x844 的浅色/深色验证,
均无页面横向溢出。1280x720 两边使用真实登录和真实 0 条通知;其余基准视口在验证码会话失效后
使用浏览器内只读认证/空通知响应夹具,只验证视觉结构,不写入仓库或服务端。截图均保存在
`/tmp`,未纳入版本控制。
## 浏览器证据与限制
- 已覆盖 1440x900、1280x720、768x1024、390x844;包含浅色、深色、Sidebar offcanvas、移动 Sidebar、应用切换和创建分组 Modal。
......@@ -65,3 +83,6 @@
- 旧组件、原生可见控件、页面私有视觉 CSS、页面局部 CSS/LESS、`file:`/本机路径依赖扫描:均为零。
- `theme.css` 与 Skill 权威副本逐字节一致;`tokens.css`、`shell.css`、`tailwind.css` 与问题整改基准逐字节一致。
- HeroUI/React Aria 依赖树和 Pro 子路径检查:通过;目标仓库不存在相邻项目依赖、软链接或本机 vendor 替代包。
- `pnpm list` 显示应用运行层统一为 React/ReactDOM 19.2.8、HeroUI 3.2.4、React Aria
Components 1.20.0;完整 `npm ls` 仍报告 Umi/Ant Design 传递依赖声明的旧 React peer 警告,
属于本次功能前已存在的依赖元数据风险,本次未更改该依赖树。
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';
......@@ -63,6 +64,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);
......
......@@ -20,6 +20,8 @@ function getInitialTheme() {
export default function AppShell({
children,
notificationCount,
notificationPath,
open,
onOpenChange,
title,
......@@ -52,6 +54,10 @@ export default function AppShell({
<DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={
notificationPath ? () => history.push(notificationPath) : undefined
}
theme={theme}
title={title}
onThemeChange={setTheme}
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
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
......
......@@ -4,4 +4,5 @@ export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
'/process_monitor': '流程监控',
'/notifications': '通知消息',
});
This diff is collapsed.
import { APPLICATION_BASE_PATH } from '@/config/application';
import {
exacteActionFunction,
queryUserNotice,
readUserNotice,
unReadNoticeCount,
} from '@/services/DataService';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'oa-process-monitor: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),
total: Number(response?.total_notices ?? response?.total ?? 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 escapedBase = APPLICATION_BASE_PATH.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = content.match(
new RegExp(`https?:\\/\\/[^\\s<>"']+|${escapedBase}[^\\s<>"']+`),
);
return trimTarget(match?.[0]);
}
export function resolveNotificationTarget(target) {
const normalizedTarget = trimTarget(target);
if (!normalizedTarget) return null;
const appPrefix = APPLICATION_BASE_PATH.replace(/\/$/, '');
if (/^https?:\/\//i.test(normalizedTarget)) {
try {
const url = new URL(normalizedTarget);
if (url.pathname === appPrefix || url.pathname.startsWith(`${appPrefix}/`)) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch (error) {
return null;
}
}
if (normalizedTarget === appPrefix) {
return { href: '/', external: false };
}
if (!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));
}
}
......@@ -3,6 +3,10 @@ import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import { ProcessList } from '@/components/AppIcons';
import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application';
import { useUserInfo } from '@/hooks/user';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from '@/pages/notifications/notificationService';
import { SiteContext } from '@/wrapper/Site';
import { useContext, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation } from 'umi';
......@@ -19,6 +23,7 @@ export default function ProcessMonitorLayout() {
const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const currentTitle = useMemo(
() => ROUTE_TITLES[location.pathname] || (location.pathname === '/process_monitor' ? '流程监控' : '流程详情'),
......@@ -29,10 +34,35 @@ export default function ProcessMonitorLayout() {
document.title = `${currentTitle} - ${APPLICATION_DISPLAY_NAME}`;
}, [currentTitle]);
useEffect(() => {
let cancelled = false;
const refreshUnreadCount = async () => {
try {
const count = await loadUnreadNotificationCount();
if (!cancelled) setUnreadNotificationCount(count);
} catch (error) {
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)}
onOpenChange={setSidebarOpen}
......
......@@ -183,3 +183,27 @@ Shell 中的应用切换入口继续读取当前登录用户可访问应用,
- `src/pages/process_monitor/ProcessTemplateList.jsx`
- `src/pages/process_monitor/ProcessDetail.jsx`
- `src/pages/process_monitor/processMonitorService.js`
## 9. 消息通知流程
### 9.1 入口与范围
Navbar 的 Bell 进入 `/notifications`。OA流程监控不在六个完整功能应用白名单内,因此本项目
只提供通知中心,不提供 shortTitle、视图授权或材料中心文件能力;Sidebar 不增加通知菜单。
### 9.2 查询与筛选
页面默认查询消息,支持切换待办、全部/未读和 300ms 防抖文本搜索。查询固定每页 10 条,未读
筛选发送 `status: "unread"` 给 `/notice/customer/query`,搜索文本先转义正则字符。
### 9.3 已读与待办
单条已读调用 `/notice/customer/read` 并传通知 `name`;全部已读沿用平台接口的空名称请求。
待办动作读取服务端 `actions[]`,调用 `/notice/customer/:action/action/:fname`,请求体只含
`{ notice_id }`。成功后通过 `oa-process-monitor:notification-state` 通知 Shell 刷新未读数。
### 9.4 关联流程与安全
通知中的站内 URL 只剥离 `/oa_process_monitor_new/` 后交给 Umi history;其他应用或外部
HTTP(S) 链接新窗口打开。`content` 始终作为纯文本渲染,不执行服务端 HTML。Navbar 首次加载
及后续每 60 秒调用 `/notice/customer/count`,真实 0 条时不显示徽标,100 及以上显示 `99+`。
......@@ -257,6 +257,20 @@ Query:`calculated=true|false`
- PUT `/record/:record/restore`
- DELETE `/record/clear?subject=xxx` (清空主题记录,默认软删)
## 3.7 通知中心接口
通知中心沿用平台通知接口,不改变记录 API 协议:
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| POST | `/notice/customer/query` | 分页查询消息/待办;请求体包含 `page`、`page_size`、`filter`、`status` |
| PUT | `/notice/customer/read` | 单条已读传 `{ name }`;全部已读沿用平台空名称请求 |
| GET | `/notice/customer/count` | 获取真实未读数量 |
| PUT | `/notice/customer/:action/action/:fname` | 执行服务端配置的待办动作,正文为 `{ notice_id }` |
通知查询的 `filter.category` 使用 `message` 或 `todo`,未读列表向服务端发送
`status: "unread"`;前端不在当前页自行推断未读总数。
## 4. 常见问题 & 注意事项
1. **name 与 _id**:路径参数 `:name` 同时支持业务 name 和 ObjectId 字符串。
......
......@@ -208,3 +208,16 @@ type DetailField = {
- 实例列表页的显示列必须由 `metadata.fields_definition` 驱动,禁止在页面内写死固定业务列
- `related_progress` 写入时只传模板记录 `_id`
- `detail` 仅作为动态明细展示源,不在前端构造成固定 schema 后回写
## 5. 通知对象(展示协议)
通知不属于本项目 Subject 记录写入范围,页面仅消费平台返回对象。展示所需字段包括:
- `name` / `_id`:通知和动态操作标识
- `title`、`content` 或 `content_compile`:标题与纯文本内容
- `category`:`message` 或 `todo`
- `from` / `created_by`:发送者展示信息
- `data.logs`:当前用户已读记录;`data.url` 或 `settings.url/link`:关联流程
- `actions[]`:服务端配置的待办动作,执行时只发送通知 `_id` 作为 `notice_id`
页面不把通知对象转换为业务 Record,也不修改任何 Subject 字段结构。
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