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

feat: add notification center

parent 2abf70b5
...@@ -8,6 +8,7 @@ function resolvePackageRoot(packageName: string) { ...@@ -8,6 +8,7 @@ function resolvePackageRoot(packageName: string) {
const ROUTE_PATHS = { const ROUTE_PATHS = {
login: '/login', login: '/login',
processMonitor: '/process_monitor', processMonitor: '/process_monitor',
notifications: '/notifications',
processTemplateList: '/process_monitor/:processId', processTemplateList: '/process_monitor/:processId',
processDetail: '/process_monitor/:processId/:application', processDetail: '/process_monitor/:processId/:application',
} as const; } as const;
...@@ -47,6 +48,10 @@ export default defineConfig({ ...@@ -47,6 +48,10 @@ export default defineConfig({
path: ROUTE_PATHS.processMonitor, path: ROUTE_PATHS.processMonitor,
component: './process_monitor/ProcessMonitor', component: './process_monitor/ProcessMonitor',
}, },
{
path: ROUTE_PATHS.notifications,
component: './notifications/NotificationListPage',
},
{ {
path: ROUTE_PATHS.processTemplateList, path: ROUTE_PATHS.processTemplateList,
component: './process_monitor/ProcessTemplateList', component: './process_monitor/ProcessTemplateList',
......
...@@ -48,3 +48,18 @@ ...@@ -48,3 +48,18 @@
- `fields_definition` 继续兼容数组/对象映射并驱动动态列。 - `fields_definition` 继续兼容数组/对象映射并驱动动态列。
- 附件继续先调用 `sync_progress:get_attachment_link`。 - 附件继续先调用 `sync_progress:get_attachment_link`。
- 登录参数、验证码、密码加密、logout、权限、保存回调、请求时机和分页协议保持不变。 - 登录参数、验证码、密码加密、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 @@ ...@@ -29,6 +29,22 @@
- [x] 同步更新 `process-monitor.business-flow.md` - [x] 同步更新 `process-monitor.business-flow.md`
- [N/A] 图表:目标应用没有图表业务页面 - [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 浅色/深色目标与基准对照 - [x] 1440x900 浅色/深色目标与基准对照
...@@ -38,7 +54,7 @@ ...@@ -38,7 +54,7 @@
- [x] 登录、Sidebar、Navbar、应用切换、Modal、Autocomplete 打开态和空列表 - [x] 登录、Sidebar、Navbar、应用切换、Modal、Autocomplete 打开态和空列表
- [x] 干净退出与应用切换会话控制台 0 error / 0 warning - [x] 干净退出与应用切换会话控制台 0 error / 0 warning
- [x] Autocomplete、Modal、应用切换和移动 Sidebar 对话框可访问名称 - [x] Autocomplete、Modal、应用切换和移动 Sidebar 对话框可访问名称
- [~] populated DataGrid、Checkbox 半选、ActionBar、真实记录 CRUD、实例详情和附件:缺少有效业务会话 - [~] populated DataGrid、Checkbox 半选、ActionBar、真实记录 CRUD、实例详情和附件:缺少有效业务会话;不属于本次通知白名单范围
## 最终门槛 ## 最终门槛
......
...@@ -52,3 +52,17 @@ ...@@ -52,3 +52,17 @@
- 浅色使用 `xy-logo-red.png`,深色使用 `xy-logo-white.png`,不滤镜、不反色、不替换 RSM 标识。 - 浅色使用 `xy-logo-red.png`,深色使用 `xy-logo-white.png`,不滤镜、不反色、不替换 RSM 标识。
- 根元素持有 `.light/.dark` 与匹配的 `data-theme`,所有 Portal 继承根主题。 - 根元素持有 `.light/.dark` 与匹配的 `data-theme`,所有 Portal 继承根主题。
- 组件外观只来自 HeroUI CSS、Pro CSS、权威 theme.css 和批准的共享 Tokens。 - 组件外观只来自 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 审计 # OA流程监控鑫元 UI 审计
审计日期:2026-08-20 审计日期:2026-08-27
## 范围与基准 ## 范围与基准
- 目标仓库:`oa_process_monitor`,分支 `codex/xinyuan-ui-ux`。 - 目标仓库:`oa_process_monitor`,分支 `codex/xinyuan-ui-ux`。
- 整体实现基准:本机 `problem_rectification` 的实际代码与运行页面;应用切换专项基准为本机 `model_analysis` 当前实现。 - 整体实现基准:本机 `problem_rectification` 的实际代码与运行页面;应用切换专项基准为本机 `model_analysis` 当前实现。
- 路由基址:`/oa_process_monitor_new/`。 - 路由基址:`/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`。 - 业务依据:`src/services/record-api.md`、`src/services/subjectsDefinition.md`、`src/pages/process_monitor/process-monitor.business-flow.md`。
## 安装与依赖结论 ## 安装与依赖结论
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
- 已在该目录成功执行用户指定的官方 `pnpm dlx hpsetup@latest` 授权安装命令;授权值未写入源码、文档、环境文件或 Git 提交。 - 已在该目录成功执行用户指定的官方 `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-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:` 依赖。 - `@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`。 - `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 解析目标文件存在。 - `@heroui-pro/react/css`、`sidebar`、`navbar`、`data-grid`、`drop-zone`、`action-bar`、`empty-state` 均通过 ESM 导入检查,CSS 解析目标文件存在。
- 监控分组 Tabs 使用问题整改相同的 compound 布局合同;同步 HeroUI 3.2.4 后,Tabs 根轨道与各标签均按内容宽度收敛,不再均分或占满页面剩余宽度。 - 监控分组 Tabs 使用问题整改相同的 compound 布局合同;同步 HeroUI 3.2.4 后,Tabs 根轨道与各标签均按内容宽度收敛,不再均分或占满页面剩余宽度。
...@@ -46,6 +47,23 @@ ...@@ -46,6 +47,23 @@
- 仅按批准合同把列表固定为每页 10 条并移除 page-size UI;请求仍使用原有 `page` / `page_size` 协议。 - 仅按批准合同把列表固定为每页 10 条并移除 page-size UI;请求仍使用原有 `page` / `page_size` 协议。
- 401 仍在相同响应时机清理登录态并跳转登录页,只修正为带 Umi base 的应用内地址。 - 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。 - 已覆盖 1440x900、1280x720、768x1024、390x844;包含浅色、深色、Sidebar offcanvas、移动 Sidebar、应用切换和创建分组 Modal。
...@@ -65,3 +83,6 @@ ...@@ -65,3 +83,6 @@
- 旧组件、原生可见控件、页面私有视觉 CSS、页面局部 CSS/LESS、`file:`/本机路径依赖扫描:均为零。 - 旧组件、原生可见控件、页面私有视觉 CSS、页面局部 CSS/LESS、`file:`/本机路径依赖扫描:均为零。
- `theme.css` 与 Skill 权威副本逐字节一致;`tokens.css`、`shell.css`、`tailwind.css` 与问题整改基准逐字节一致。 - `theme.css` 与 Skill 权威副本逐字节一致;`tokens.css`、`shell.css`、`tailwind.css` 与问题整改基准逐字节一致。
- HeroUI/React Aria 依赖树和 Pro 子路径检查:通过;目标仓库不存在相邻项目依赖、软链接或本机 vendor 替代包。 - 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 BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined'; import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined'; import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined'; import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined'; import CheckOutlined from '@ant-design/icons/CheckOutlined';
...@@ -63,6 +64,7 @@ export const Bank = withSize(BankOutlined); ...@@ -63,6 +64,7 @@ export const Bank = withSize(BankOutlined);
export const Archive = withSize(InboxOutlined); export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined); export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined); export const BarChart = withSize(BarChartOutlined);
export const Bell = withSize(BellOutlined);
export const Calendar = withSize(CalendarOutlined); export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined); export const Check = withSize(CheckOutlined);
export const ChevronDown = withSize(DownOutlined); export const ChevronDown = withSize(DownOutlined);
......
...@@ -20,6 +20,8 @@ function getInitialTheme() { ...@@ -20,6 +20,8 @@ function getInitialTheme() {
export default function AppShell({ export default function AppShell({
children, children,
notificationCount,
notificationPath,
open, open,
onOpenChange, onOpenChange,
title, title,
...@@ -52,6 +54,10 @@ export default function AppShell({ ...@@ -52,6 +54,10 @@ export default function AppShell({
<DashboardSidebar {...sidebarProps} /> <DashboardSidebar {...sidebarProps} />
<Sidebar.Main className="app-main-glass rc-app-main"> <Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar <DashboardNavbar
notificationCount={notificationCount}
onOpenNotifications={
notificationPath ? () => history.push(notificationPath) : undefined
}
theme={theme} theme={theme}
title={title} title={title}
onThemeChange={setTheme} onThemeChange={setTheme}
......
import { Navbar } from '@heroui-pro/react/navbar'; import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar'; import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react'; import { Badge, Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons'; 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 { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark'; const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式'; const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(0, Number(notificationCount) || 0);
return ( return (
<Navbar className="app-navbar rc-navbar" maxWidth="full"> <Navbar className="app-navbar rc-navbar" maxWidth="full">
...@@ -20,6 +27,34 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) { ...@@ -20,6 +27,34 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
<h1 className="app-navbar-page-title rc-navbar-title">{title}</h1> <h1 className="app-navbar-page-title rc-navbar-title">{title}</h1>
<Navbar.Spacer /> <Navbar.Spacer />
<div className="rc-navbar-actions"> <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}> <Tooltip delay={0}>
<Button <Button
isIconOnly isIconOnly
......
...@@ -4,4 +4,5 @@ export const THEME_STORAGE_KEY = 'xinyuan_color_theme'; ...@@ -4,4 +4,5 @@ export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({ export const ROUTE_TITLES = Object.freeze({
'/process_monitor': '流程监控', '/process_monitor': '流程监控',
'/notifications': '通知消息',
}); });
import { Bell, Close, MagnifyingGlassIcon } from '@/components/AppIcons';
import { useUserInfo } from '@/hooks/user';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
Chip,
Pagination,
SearchField,
Spinner,
Tabs,
toast,
} from '@heroui/react';
import { useCallback, useEffect, useMemo, 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 NotificationPagination({ isDisabled, page, total, onPageChange }) {
const totalPages = Math.ceil(total / NOTIFICATION_PAGE_SIZE);
const start = Math.max(0, Math.min(page - 2, totalPages - 5));
const end = Math.min(totalPages, start + 5);
return (
<Pagination size="sm">
<Pagination.Summary>
共 {total} 条,第 {total ? page + 1 : 0} / {totalPages} 页
</Pagination.Summary>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous
isDisabled={isDisabled || page <= 0}
onPress={() => onPageChange(page - 1)}
>
<Pagination.PreviousIcon />
<span>上一页</span>
</Pagination.Previous>
</Pagination.Item>
{Array.from({ length: end - start }, (_, index) => start + index).map(
(pageIndex) => (
<Pagination.Item key={pageIndex}>
<Pagination.Link
isActive={pageIndex === page}
onPress={() => onPageChange(pageIndex)}
>
{pageIndex + 1}
</Pagination.Link>
</Pagination.Item>
),
)}
<Pagination.Item>
<Pagination.Next
isDisabled={isDisabled || page + 1 >= totalPages}
onPress={() => onPageChange(page + 1)}
>
<span>下一页</span>
<Pagination.NextIcon />
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination>
);
}
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" variant="soft">
<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 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">
{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)}
>
进入流程
</Button>
) : null}
</div>
</Card.Content>
</Card>
);
}
export default function NotificationListPage() {
const userInfo = useUserInfo();
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 totalPages = useMemo(
() => Math.ceil(total / NOTIFICATION_PAGE_SIZE),
[total],
);
useEffect(() => {
if (totalPages > 0 && page >= totalPages) setPage(totalPages - 1);
}, [page, totalPages]);
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(
() => 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 className="w-auto min-w-max flex-none whitespace-nowrap" id={option.key} key={option.key}>
{option.label}
<Tabs.Indicator />
</Tabs.Tab>
))}
</Tabs.List>
</Tabs.ListContainer>
</Tabs>
<Button
isDisabled={loading || unreadTotal === 0}
isPending={busyKey === 'all'}
variant="secondary"
onPress={handleMarkAllRead}
>
全部已读
</Button>
</div>
<div className="rc-page-toolbar rc-page-toolbar-responsive">
<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
className="w-fit max-w-full min-w-0"
selectedKey={readStatus}
onSelectionChange={(key) => {
setReadStatus(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">
{READ_STATUS_OPTIONS.map((option) => (
<Tabs.Tab className="w-auto min-w-max flex-none whitespace-nowrap" 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 ? (
<NotificationPagination
isDisabled={loading}
page={page}
total={total}
onPageChange={setPage}
/>
) : null}
</div>
);
}
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'; ...@@ -3,6 +3,10 @@ import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import { ProcessList } from '@/components/AppIcons'; import { ProcessList } from '@/components/AppIcons';
import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application'; import { APPLICATION_DISPLAY_NAME, ROUTE_TITLES } from '@/config/application';
import { useUserInfo } from '@/hooks/user'; import { useUserInfo } from '@/hooks/user';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from '@/pages/notifications/notificationService';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { useContext, useEffect, useMemo, useState } from 'react'; import { useContext, useEffect, useMemo, useState } from 'react';
import { Outlet, useLocation } from 'umi'; import { Outlet, useLocation } from 'umi';
...@@ -19,6 +23,7 @@ export default function ProcessMonitorLayout() { ...@@ -19,6 +23,7 @@ export default function ProcessMonitorLayout() {
const siteInfo = useContext(SiteContext); const siteInfo = useContext(SiteContext);
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false); const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
const currentTitle = useMemo( const currentTitle = useMemo(
() => ROUTE_TITLES[location.pathname] || (location.pathname === '/process_monitor' ? '流程监控' : '流程详情'), () => ROUTE_TITLES[location.pathname] || (location.pathname === '/process_monitor' ? '流程监控' : '流程详情'),
...@@ -29,10 +34,35 @@ export default function ProcessMonitorLayout() { ...@@ -29,10 +34,35 @@ export default function ProcessMonitorLayout() {
document.title = `${currentTitle} - ${APPLICATION_DISPLAY_NAME}`; document.title = `${currentTitle} - ${APPLICATION_DISPLAY_NAME}`;
}, [currentTitle]); }, [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 ( return (
<> <>
<AppShell <AppShell
menuGroups={MENU_GROUPS} menuGroups={MENU_GROUPS}
notificationCount={unreadNotificationCount}
notificationPath="/notifications"
onLogout={userInfo?.logout} onLogout={userInfo?.logout}
onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)} onOpenApplicationSwitcher={() => setIsApplicationDialogOpen(true)}
onOpenChange={setSidebarOpen} onOpenChange={setSidebarOpen}
......
...@@ -183,3 +183,27 @@ Shell 中的应用切换入口继续读取当前登录用户可访问应用, ...@@ -183,3 +183,27 @@ Shell 中的应用切换入口继续读取当前登录用户可访问应用,
- `src/pages/process_monitor/ProcessTemplateList.jsx` - `src/pages/process_monitor/ProcessTemplateList.jsx`
- `src/pages/process_monitor/ProcessDetail.jsx` - `src/pages/process_monitor/ProcessDetail.jsx`
- `src/pages/process_monitor/processMonitorService.js` - `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` ...@@ -257,6 +257,20 @@ Query:`calculated=true|false`
- PUT `/record/:record/restore` - PUT `/record/:record/restore`
- DELETE `/record/clear?subject=xxx` (清空主题记录,默认软删) - 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. 常见问题 & 注意事项 ## 4. 常见问题 & 注意事项
1. **name 与 _id**:路径参数 `:name` 同时支持业务 name 和 ObjectId 字符串。 1. **name 与 _id**:路径参数 `:name` 同时支持业务 name 和 ObjectId 字符串。
......
...@@ -208,3 +208,16 @@ type DetailField = { ...@@ -208,3 +208,16 @@ type DetailField = {
- 实例列表页的显示列必须由 `metadata.fields_definition` 驱动,禁止在页面内写死固定业务列 - 实例列表页的显示列必须由 `metadata.fields_definition` 驱动,禁止在页面内写死固定业务列
- `related_progress` 写入时只传模板记录 `_id` - `related_progress` 写入时只传模板记录 `_id`
- `detail` 仅作为动态明细展示源,不在前端构造成固定 schema 后回写 - `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