Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
P
project_manager_xy
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
David Yang
project_manager_xy
Commits
963009de
Commit
963009de
authored
Aug 27, 2026
by
Andy-bubu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: 接入统一消息通知中心
parent
15a1e978
Hide whitespace changes
Inline
Side-by-side
Showing
13 changed files
with
688 additions
and
4 deletions
+688
-4
.umirc.ts
.umirc.ts
+5
-0
ui-architecture.md
docs/ui-architecture.md
+20
-0
ui-migration-checklist.md
docs/ui-migration-checklist.md
+13
-0
xinyuan-design-system.md
docs/xinyuan-design-system.md
+8
-1
xinyuan-ui-audit.md
docs/xinyuan-ui-audit.md
+14
-0
AppIcons.jsx
src/components/AppIcons.jsx
+2
-0
AppShell.jsx
src/components/AppShell.jsx
+6
-0
DashboardNavbar.jsx
src/components/DashboardNavbar.jsx
+42
-3
application.js
src/config/application.js
+1
-0
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+30
-0
NotificationListPage.jsx
...project_manager_xy/notifications/NotificationListPage.jsx
+339
-0
notificationService.js
...s/project_manager_xy/notifications/notificationService.js
+178
-0
project-management.business-flow.md
...es/project_manager_xy/project-management.business-flow.md
+30
-0
No files found.
.umirc.ts
View file @
963009de
...
@@ -7,6 +7,7 @@ function resolvePackageRoot(packageName: string) {
...
@@ -7,6 +7,7 @@ function resolvePackageRoot(packageName: string) {
const
ROUTE_PATHS
=
{
const
ROUTE_PATHS
=
{
login
:
'/login'
,
login
:
'/login'
,
notifications
:
'/notifications'
,
resourceStat
:
'/resource/stat'
,
resourceStat
:
'/resource/stat'
,
resourceBooking
:
'/resource/booking'
,
resourceBooking
:
'/resource/booking'
,
}
as
const
;
}
as
const
;
...
@@ -39,6 +40,10 @@ export default defineConfig({
...
@@ -39,6 +40,10 @@ export default defineConfig({
path
:
'/resource'
,
path
:
'/resource'
,
redirect
:
ROUTE_PATHS
.
resourceStat
,
redirect
:
ROUTE_PATHS
.
resourceStat
,
},
},
{
path
:
ROUTE_PATHS
.
notifications
,
component
:
'./project_manager_xy/notifications/NotificationListPage'
,
},
{
{
path
:
ROUTE_PATHS
.
resourceStat
,
path
:
ROUTE_PATHS
.
resourceStat
,
component
:
'./project_manager_xy/resource-stat/index'
,
component
:
'./project_manager_xy/resource-stat/index'
,
...
...
docs/ui-architecture.md
View file @
963009de
...
@@ -39,6 +39,26 @@ Umi routes
...
@@ -39,6 +39,26 @@ Umi routes
-
页面继续拥有 Tabs、筛选、分页、DataGrid 临时选择、Modal draft、loading/error/success 状态;DataGrid 临时选择不替代当前项目业务状态。
-
页面继续拥有 Tabs、筛选、分页、DataGrid 临时选择、Modal draft、loading/error/success 状态;DataGrid 临时选择不替代当前项目业务状态。
-
迁移只把这些状态绑定到当前 HeroUI props,不改业务值、字段名、请求参数或触发时机。
-
迁移只把这些状态绑定到当前 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。
-
`theme.css`
:不可变 HeroUI 核心 Token。
...
...
docs/ui-migration-checklist.md
View file @
963009de
...
@@ -77,6 +77,18 @@
...
@@ -77,6 +77,18 @@
-
[
x
]
KPI、三类图表和 Tooltip。
-
[
x
]
KPI、三类图表和 Tooltip。
-
[
x
]
控制台零 runtime/slot/provider/duplicate Context 错误。
-
[
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`
。
-
[
x
]
`npm run build`
。
...
@@ -96,6 +108,7 @@
...
@@ -96,6 +108,7 @@
-
选择器专项回归:项目负责人和项目协作人均通过连续双选、重新打开保留勾选、单项取消、触发器即时回显及官方 ClearButton 全部清空;筛选状态/负责人及表单枚举通过选择回显、清除和实时查询。桌面浅色与 390x844 深色均通过,移动弹层无横向溢出。
-
选择器专项回归:项目负责人和项目协作人均通过连续双选、重新打开保留勾选、单项取消、触发器即时回显及官方 ClearButton 全部清空;筛选状态/负责人及表单枚举通过选择回显、清除和实时查询。桌面浅色与 390x844 深色均通过,移动弹层无横向溢出。
-
退出菜单实际跳转到
`/project_manager_xy/login`
;取消周进度与删除确认不会触发保存/删除请求。
-
退出菜单实际跳转到
`/project_manager_xy/login`
;取消周进度与删除确认不会触发保存/删除请求。
-
最后一次目标页面重载与退出验收均为 0 error、0 warning。
-
最后一次目标页面重载与退出验收均为 0 error、0 warning。
-
2026-08-27 通知专项:目标与问题整改在 1440x900、1280x720、768x1024、390x844 的浅色/深色同时打开;两端所有视口均为 0 页面横向溢出,Bell 均为 36x36 且右侧计算位置一致。真实通知接口当前为空态;Playwright 会话内网络夹具验证了 0/1/99/100、消息/待办、全部/未读、转义搜索、分页、单条/全部已读、动态 action、站内/站外跳转及纯文本安全,夹具未写入仓库。
-
浏览器截图与交互快照仅作为本地临时验收产物,统一通过
`.gitignore`
排除,不纳入源代码管理。
-
浏览器截图与交互快照仅作为本地临时验收产物,统一通过
`.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 甘特图专项复核:代表性项目数据下,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-20 甘特图专项复核:周/月/年 Tabs、上一时间段、今天、任务点击编辑、实际/时间 ProgressBar 和周进展 Tooltip 打开态通过;代表性数据控制台 0 error、0 warning。
...
...
docs/xinyuan-design-system.md
View file @
963009de
# 部门任务管理鑫元设计系统
# 部门任务管理鑫元设计系统
更新日期:2026-08-2
6
更新日期:2026-08-2
7
本项目直接继承鑫元 HeroUI 设计系统,以“问题整改”运行结果为唯一结构、尺寸、间距和交互基准。业务页面不得建立第二套视觉语言。
本项目直接继承鑫元 HeroUI 设计系统,以“问题整改”运行结果为唯一结构、尺寸、间距和交互基准。业务页面不得建立第二套视觉语言。
...
@@ -52,6 +52,13 @@
...
@@ -52,6 +52,13 @@
-
KPI:HeroUI Pro KPI compound,24px 外卡、16px board gap、实际 data-count 布局。
-
KPI:HeroUI Pro KPI compound,24px 外卡、16px board gap、实际 data-count 布局。
-
图表:HeroUI Pro PieChart、BarChart、ComposedChart 与 ChartTooltip;数据系列只用共享 8 色 Token。
-
图表:HeroUI Pro PieChart、BarChart、ComposedChart 与 ChartTooltip;数据系列只用共享 8 色 Token。
-
个人日历:HeroUI Pro Agenda 月视图;跨日期项目使用 MonthSpanningEvent,单日项目使用 MonthEvent,导航和日期状态使用 Agenda 官方 compound API。
-
个人日历: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。
## 响应式与主题
## 响应式与主题
...
...
docs/xinyuan-ui-audit.md
View file @
963009de
...
@@ -103,3 +103,17 @@
...
@@ -103,3 +103,17 @@
-
权威主题 SHA-256:
`a0bf6f243aaf2c94cc8b577145d33d765f8ecab863b3a3b4a358bad4d1a05901`
,
`cmp`
通过。
-
权威主题 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/`
。
浏览器中,目标与问题整改已在 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;登录时人为误读验证码产生的历史请求错误已通过全新浏览器会话隔离,不计入页面最终控制台结果。
src/components/AppIcons.jsx
View file @
963009de
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'
;
...
@@ -58,6 +59,7 @@ export const Bank = withSize(BankOutlined);
...
@@ -58,6 +59,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
);
...
...
src/components/AppShell.jsx
View file @
963009de
...
@@ -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
}
...
...
src/components/DashboardNavbar.jsx
View file @
963009de
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
{
B
adge
,
B
utton
,
Tooltip
}
from
'@heroui/react'
;
import
{
Moon
,
Sun
}
from
'./AppIcons'
;
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
{
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"
>
...
@@ -18,6 +25,38 @@ export default function DashboardNavbar({ onThemeChange, theme, title }) {
...
@@ -18,6 +25,38 @@ 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=
{
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
}
>
<
Tooltip
delay=
{
0
}
>
<
Button
<
Button
isIconOnly
isIconOnly
...
...
src/config/application.js
View file @
963009de
...
@@ -4,6 +4,7 @@ export const APPLICATION_ROUTE_NAME = APPLICATION_BASE_PATH.replace(/^\/+|\/+$/g
...
@@ -4,6 +4,7 @@ export const APPLICATION_ROUTE_NAME = APPLICATION_BASE_PATH.replace(/^\/+|\/+$/g
export
const
THEME_STORAGE_KEY
=
'xinyuan_color_theme'
;
export
const
THEME_STORAGE_KEY
=
'xinyuan_color_theme'
;
export
const
ROUTE_TITLES
=
Object
.
freeze
({
export
const
ROUTE_TITLES
=
Object
.
freeze
({
'/notifications'
:
'通知消息'
,
'/resource/stat'
:
'资源统计'
,
'/resource/stat'
:
'资源统计'
,
'/resource/booking'
:
'资源预定'
,
'/resource/booking'
:
'资源预定'
,
});
});
src/pages/project_manager_xy/Layout.jsx
View file @
963009de
...
@@ -5,6 +5,10 @@ import {
...
@@ -5,6 +5,10 @@ import {
import
AppShell
from
'@/components/AppShell'
;
import
AppShell
from
'@/components/AppShell'
;
import
{
BarChart
,
Calendar
}
from
'@/components/AppIcons'
;
import
{
BarChart
,
Calendar
}
from
'@/components/AppIcons'
;
import
ApplicationSwitcherDialog
from
'@/pages/project_manager_xy/ApplicationSwitcherDialog'
;
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
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
...
@@ -27,6 +31,7 @@ export default function ProjectManagerLayout() {
...
@@ -27,6 +31,7 @@ export default function ProjectManagerLayout() {
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
]
||
APPLICATION_DISPLAY_NAME
,
()
=>
ROUTE_TITLES
[
location
.
pathname
]
||
APPLICATION_DISPLAY_NAME
,
...
@@ -37,10 +42,35 @@ export default function ProjectManagerLayout() {
...
@@ -37,10 +42,35 @@ export default function ProjectManagerLayout() {
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
{
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
)
}
open=
{
sidebarOpen
}
open=
{
sidebarOpen
}
...
...
src/pages/project_manager_xy/notifications/NotificationListPage.jsx
0 → 100644
View file @
963009de
import
{
Bell
,
Close
,
MagnifyingGlassIcon
,
OpenExternal
}
from
'@/components/AppIcons'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Avatar
,
Button
,
Card
,
Chip
,
Pagination
,
SearchField
,
Spinner
,
Tabs
,
toast
,
}
from
'@heroui/react'
;
import
{
useCallback
,
useContext
,
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
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"
>
<
Card
.
Title
className=
"break-words"
>
{
notification
?.
title
||
(
category
===
'todo'
?
'待办通知'
:
'通知消息'
)
}
</
Card
.
Title
>
<
Card
.
Description
className=
"mt-1"
>
{
sender
}
·
{
formatNotificationTime
(
notification
?.
sent_at
||
notification
?.
created_at
)
}
</
Card
.
Description
>
</
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"
>
{
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
);
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
NOTIFICATION_PAGE_SIZE
));
const
pageIndexes
=
useMemo
(()
=>
{
const
pageWindowStart
=
Math
.
max
(
0
,
Math
.
min
(
page
-
2
,
totalPages
-
5
));
const
pageWindowEnd
=
total
?
Math
.
min
(
totalPages
,
pageWindowStart
+
5
)
:
0
;
return
Array
.
from
(
{
length
:
pageWindowEnd
-
pageWindowStart
},
(
_
,
index
)
=>
pageWindowStart
+
index
,
);
},
[
page
,
total
,
totalPages
]);
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
,
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
(
()
=>
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
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
aria
-
label=
"通知加载中"
className=
"flex min-h-64 items-center justify-center"
><
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
aria
-
busy=
{
loading
}
className=
"flex min-w-0 flex-col gap-3"
>
{
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
?
(
<
Pagination
size=
"sm"
>
<
Pagination
.
Summary
>
共
{
total
}
条通知,第
{
page
+
1
}
/
{
totalPages
}
页
</
Pagination
.
Summary
>
<
Pagination
.
Content
>
<
Pagination
.
Item
><
Pagination
.
Previous
isDisabled=
{
loading
||
page
<=
0
}
onPress=
{
()
=>
setPage
(
page
-
1
)
}
><
Pagination
.
PreviousIcon
/><
span
>
上一页
</
span
></
Pagination
.
Previous
></
Pagination
.
Item
>
{
pageIndexes
.
map
((
pageIndex
)
=>
(
<
Pagination
.
Item
key=
{
pageIndex
}
><
Pagination
.
Link
isActive=
{
pageIndex
===
page
}
onPress=
{
()
=>
setPage
(
pageIndex
)
}
>
{
pageIndex
+
1
}
</
Pagination
.
Link
></
Pagination
.
Item
>
))
}
<
Pagination
.
Item
><
Pagination
.
Next
isDisabled=
{
loading
||
page
+
1
>=
totalPages
}
onPress=
{
()
=>
setPage
(
page
+
1
)
}
><
span
>
下一页
</
span
><
Pagination
.
NextIcon
/></
Pagination
.
Next
></
Pagination
.
Item
>
</
Pagination
.
Content
>
</
Pagination
>
)
:
null
}
</
div
>
);
}
src/pages/project_manager_xy/notifications/notificationService.js
0 → 100644
View file @
963009de
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
));
}
}
src/pages/project_manager_xy/project-management.business-flow.md
View file @
963009de
...
@@ -257,3 +257,33 @@ const result = await queryUsers(0, 20, {
...
@@ -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`
的查询、字段、校验、分页、保存回调或业务状态流。
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment