Commit 963009de authored by Andy-bubu's avatar Andy-bubu

feat: 接入统一消息通知中心

parent 15a1e978
......@@ -7,6 +7,7 @@ function resolvePackageRoot(packageName: string) {
const ROUTE_PATHS = {
login: '/login',
notifications: '/notifications',
resourceStat: '/resource/stat',
resourceBooking: '/resource/booking',
} as const;
......@@ -39,6 +40,10 @@ export default defineConfig({
path: '/resource',
redirect: ROUTE_PATHS.resourceStat,
},
{
path: ROUTE_PATHS.notifications,
component: './project_manager_xy/notifications/NotificationListPage',
},
{
path: ROUTE_PATHS.resourceStat,
component: './project_manager_xy/resource-stat/index',
......
......@@ -39,6 +39,26 @@ Umi routes
- 页面继续拥有 Tabs、筛选、分页、DataGrid 临时选择、Modal draft、loading/error/success 状态;DataGrid 临时选择不替代当前项目业务状态。
- 迁移只把这些状态绑定到当前 HeroUI props,不改业务值、字段名、请求参数或触发时机。
## 消息通知架构
```text
Layout route/path change + 60s timer + notification-state event
-> notificationService.loadUnreadNotificationCount
-> AppShell
-> DashboardNavbar Badge.Anchor / Badge / Bell Button
/notifications
-> NotificationListPage page state
-> notificationService
-> existing DataService notice API wrappers
```
- `notificationService.js` 只对既有 `queryUserNotice`、`readUserNotice`、`unReadNoticeCount` 和 `exacteActionFunction` 做页面适配,不修改接口协议。
- 应用内事件固定为 `project-manager-xy:notification-state`,避免与其他应用共享前端状态通道。
- 站内链接只剥离 `/project_manager_xy` 自身 base;其他应用路径及 HTTP(S) 地址新窗口打开。
- 通知正文始终作为纯文本 React child 渲染,不使用通知 HTML。
- 本应用不在完整功能白名单内,因此架构中不增加 shortTitle、Subject View 权限和材料中心分支。
## 主题与样式所有权
- `theme.css`:不可变 HeroUI 核心 Token。
......
......@@ -77,6 +77,18 @@
- [x] KPI、三类图表和 Tooltip。
- [x] 控制台零 runtime/slot/provider/duplicate Context 错误。
## 消息通知(2026-08-27)
- [x] 按跨应用白名单只实施消息通知,不加入 shortTitle、视图授权或材料中心。
- [x] 新增独立 `/notifications` 路由,标题映射为“通知消息”。
- [x] Navbar 使用 HeroUI Badge.Anchor + Badge + 36px Bell Button + Tooltip,位于主题按钮左侧;Sidebar 无通知入口。
- [x] 未读数 0 隐藏,1-99 显示真实值,100 及以上显示 `99+`,可访问名称保留真实数量。
- [x] 消息/待办、全部/未读、300ms 搜索、固定 10 条分页均使用 HeroUI compound API。
- [x] 单条已读、全部已读、动态 action `{notice_id}` 和关联流程跳转复用平台原通知接口。
- [x] 本应用 base 链接只剥离 `/project_manager_xy`,站外/其他应用链接新窗口打开;通知内容只渲染纯文本。
- [x] 四视口浅色/深色与 0/1/99/100、已读、全部已读、动态操作、站内外跳转的最终浏览器复验。
- [x] 本轮最终 build、audit_project、依赖与路径扫描。
## 终检
- [x] `npm run build`。
......@@ -96,6 +108,7 @@
- 选择器专项回归:项目负责人和项目协作人均通过连续双选、重新打开保留勾选、单项取消、触发器即时回显及官方 ClearButton 全部清空;筛选状态/负责人及表单枚举通过选择回显、清除和实时查询。桌面浅色与 390x844 深色均通过,移动弹层无横向溢出。
- 退出菜单实际跳转到 `/project_manager_xy/login`;取消周进度与删除确认不会触发保存/删除请求。
- 最后一次目标页面重载与退出验收均为 0 error、0 warning。
- 2026-08-27 通知专项:目标与问题整改在 1440x900、1280x720、768x1024、390x844 的浅色/深色同时打开;两端所有视口均为 0 页面横向溢出,Bell 均为 36x36 且右侧计算位置一致。真实通知接口当前为空态;Playwright 会话内网络夹具验证了 0/1/99/100、消息/待办、全部/未读、转义搜索、分页、单条/全部已读、动态 action、站内/站外跳转及纯文本安全,夹具未写入仓库。
- 浏览器截图与交互快照仅作为本地临时验收产物,统一通过 `.gitignore` 排除,不纳入源代码管理。
- 2026-08-20 甘特图专项复核:代表性项目数据下,1440x900、1280x720、768x1024、390x844 的浅色/深色实渲染通过;L1 Card 为 24px,L2/L3 Surface 为 8px,页面级横向溢出为 0,时间轴在 768px 和 390px 下分别形成 `606 < 2364`、`236 < 2364` 的模块内滚动边界。
- 2026-08-20 甘特图专项复核:周/月/年 Tabs、上一时间段、今天、任务点击编辑、实际/时间 ProgressBar 和周进展 Tooltip 打开态通过;代表性数据控制台 0 error、0 warning。
......
# 部门任务管理鑫元设计系统
更新日期:2026-08-26
更新日期:2026-08-27
本项目直接继承鑫元 HeroUI 设计系统,以“问题整改”运行结果为唯一结构、尺寸、间距和交互基准。业务页面不得建立第二套视觉语言。
......@@ -52,6 +52,13 @@
- KPI:HeroUI Pro KPI compound,24px 外卡、16px board gap、实际 data-count 布局。
- 图表:HeroUI Pro PieChart、BarChart、ComposedChart 与 ChartTooltip;数据系列只用共享 8 色 Token。
- 个人日历:HeroUI Pro Agenda 月视图;跨日期项目使用 MonthSpanningEvent,单日项目使用 MonthEvent,导航和日期状态使用 Agenda 官方 compound API。
- 消息通知:Navbar 直接使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`;36px Bell 固定在主题按钮左侧。独立通知页使用 HeroUI Tabs、SearchField、Card、Pagination 和 HeroUI Pro EmptyState,侧栏不增加通知入口。
## 跨应用功能范围
- 本应用真实名称为“部门任务管理”,不在鑫元六个完整功能应用白名单中。
- 本次只接入所有应用必须具备的消息通知能力。
- 不接入 Grid/Detail `shortTitle`、Subject View 权限或审计材料中心,也不保留这些能力的休眠 helper、route 或 service。
## 响应式与主题
......
......@@ -103,3 +103,17 @@
- 权威主题 SHA-256:`a0bf6f243aaf2c94cc8b577145d33d765f8ecab863b3a3b4a358bad4d1a05901`,`cmp` 通过。
浏览器中,目标与问题整改已在 1440x900、1280x720、768x1024、390x844 的浅色/深色模式同时打开比较;两端八组视口的页面级横向溢出均为 0。目标已实际操作登录、退出、Sidebar、Navbar、应用切换、Dropdown、Tabs、筛选、Autocomplete、DatePicker、Checkbox、DataGrid、Pagination、ActionBar、项目/周进度/导入/删除弹窗、KPI、三类图表和 Tooltip,最后一次控制台检查为 0 error、0 warning。截图与快照位于 `.playwright-cli/`。
## 2026-08-27 跨应用五功能审计
- 应用身份:`APPLICATION_DISPLAY_NAME='部门任务管理'`,base/publicPath 为 `/project_manager_xy/`。
- 白名单结论:不属于“问题整改、审计项目管理、警示案例库、廉洁从业管理、廉洁风险防控、党风廉政建设”六个完整功能应用,只实施功能 4 消息通知。
- 明确排除:Grid/Detail shortTitle、Subject View 授权、审计材料中心/file protocol,不新增相关 route、service 或 dormant helper。
- 用户授权现状:登录用户应用授权来自 `user.applications[]`;当前 Andy 的真实响应中 `applications=[]`。本次通知能力不读取或合并任何其他应用授权。
- 原始目标状态:路由只有 `/login`、`/resource/stat`、`/resource/booking`;Navbar 无 Bell,Sidebar 无通知项;DataService 已有平台通知接口包装。
- 通知接口:`POST /notice/customer/query`、`PUT /notice/customer/read`、`GET /notice/customer/count`、`PUT /notice/customer/{action}/action/{fname}`,继续使用既有函数、参数及请求时机。
- Shell 数据流:`Layout -> AppShell -> DashboardNavbar`;新增 count 只沿该数据流传递,路由变化、自定义事件和 60 秒定时器触发刷新。
- 基准对照:问题整改 `/notifications` 的分类 Tabs、状态 Tabs、搜索、空态、Bell 位置和页面间距已在真实浏览器 1440x900 下打开记录;目标修改前确认无通知入口和通知路由。
- 最终浏览器对照:目标与问题整改在 1440x900、1280x720、768x1024、390x844 的浅色/深色均同时实渲染;所有组合页面横向溢出为 0,Bell 为 36x36 且右侧几何一致。390x844 深色截图中 Navbar、两组 Tabs、全部已读、搜索和 EmptyState 几何一致。
- 真实接口当前返回通知空列表和未读 0;浏览器临时路由夹具覆盖未读 1/99/100(100 显示 `99+` 且 aria 保留 100)、转义搜索 `a.*[`、未读服务端状态、两页 Pagination、单条/全部已读、动态 action、站内/站外跳转与恶意 HTML 纯文本渲染。夹具只存在于 Playwright 会话,没有写入源码或业务数据。
- 最终目标通知页控制台为 0 error、0 warning;登录时人为误读验证码产生的历史请求错误已通过全新浏览器会话隔离,不计入页面最终控制台结果。
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';
......@@ -58,6 +59,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, Sun } from './AppIcons';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, 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">
......@@ -18,6 +25,38 @@ 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={
normalizedNotificationCount > 0
? `通知消息,${normalizedNotificationCount}条未读`
: '通知消息'
}
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,6 +4,7 @@ export const APPLICATION_ROUTE_NAME = APPLICATION_BASE_PATH.replace(/^\/+|\/+$/g
export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = Object.freeze({
'/notifications': '通知消息',
'/resource/stat': '资源统计',
'/resource/booking': '资源预定',
});
......@@ -5,6 +5,10 @@ import {
import AppShell from '@/components/AppShell';
import { BarChart, Calendar } from '@/components/AppIcons';
import ApplicationSwitcherDialog from '@/pages/project_manager_xy/ApplicationSwitcherDialog';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from '@/pages/project_manager_xy/notifications/notificationService';
import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { useContext, useEffect, useMemo, useState } from 'react';
......@@ -27,6 +31,7 @@ export default function ProjectManagerLayout() {
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] || APPLICATION_DISPLAY_NAME,
......@@ -37,10 +42,35 @@ export default function ProjectManagerLayout() {
document.title = `${currentTitle} - ${APPLICATION_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}
......
import {
exacteActionFunction,
queryUserNotice,
readUserNotice,
unReadNoticeCount,
} from '@/services/DataService';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'project-manager-xy: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,
};
}
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 match = getNotificationContent(notification).match(
/https?:\/\/[^\s<>"']+|\/[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 isCurrentOrigin =
typeof window !== 'undefined' && url.origin === window.location.origin;
if (isCurrentOrigin && url.pathname.startsWith('/project_manager_xy/')) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch {
return null;
}
}
const appPrefix = '/project_manager_xy';
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));
}
}
......@@ -257,3 +257,33 @@ const result = await queryUsers(0, 20, {
- 增加拖拽调整计划时间
- 增加项目分组、筛选、统计看板
- 如未来成员进度不再共享,再拆分为“项目主单 + 人员分配单”
---
## 8. 消息通知流程
### 8.1 功能入口
- 登录后 Navbar 主题按钮左侧显示通知 Bell,点击进入 `/notifications`。
- Sidebar 不设置通知菜单项;页面标题由 Shell 显示为“通知消息”。
- 未读数为 0 时不显示 Badge,1-99 显示真实值,100 及以上显示 `99+`。
### 8.2 查询与筛选
- 页面按“消息/待办”分类,按“全部/未读”状态查看。
- 内容搜索输入后 300ms 发起查询,关键词作为转义后的 `content.$regex` 传入;切换分类、状态或关键词均回到第 1 页。
- 每页固定 10 条,使用 `POST /notice/customer/query`;未读状态通过接口 `status: 'unread'` 查询,不对当前页做前端过滤。
### 8.3 已读、待办与跳转
- 单条“标为已读”调用 `PUT /notice/customer/read` 并传通知 `name`;“全部已读”调用同一接口且不传具体 `name`。
- 待办按钮从通知 action 读取 action name 和 method name,调用原动态 action 接口,payload 固定为 `{notice_id: notification._id}`。
- 打开关联流程前,未读通知必须先成功标记已读;失败时保留当前页面并提示错误。
- `/project_manager_xy/...` 站内地址只移除自身 base 后使用应用路由跳转;HTTP(S) 和其他应用绝对路径在新窗口打开。
- 通知内容始终按纯文本展示,不解析或执行 HTML。
### 8.4 状态刷新与异常
- Shell 在首次加载、路由变化、通知状态事件和每 60 秒刷新未读数。
- 列表加载失败显示可重试 Alert;操作失败保留当前数据并显示 Toast。
- 该功能不改变 `pm_project_task` 的查询、字段、校验、分页、保存回调或业务状态流。
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