Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
M
model_analysis
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
model_analysis
Commits
082b0507
Commit
082b0507
authored
Aug 27, 2026
by
Andy-bubu
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add Xinyuan notification center
parent
50992ed4
Hide whitespace changes
Inline
Side-by-side
Showing
14 changed files
with
909 additions
and
25 deletions
+909
-25
.gitignore
.gitignore
+1
-0
.umirc.ts
.umirc.ts
+5
-0
ui-architecture.md
docs/ui-architecture.md
+6
-1
ui-migration-checklist.md
docs/ui-migration-checklist.md
+9
-2
xinyuan-design-system.md
docs/xinyuan-design-system.md
+8
-0
xinyuan-ui-audit.md
docs/xinyuan-ui-audit.md
+31
-6
app.js
src/constants/app.js
+1
-0
xinyuan.css
src/design-system/xinyuan.css
+15
-0
Layout.jsx
src/pages/project_manager_xy/Layout.jsx
+84
-16
model-monitor.business-flow.md
src/pages/project_manager_xy/model-monitor.business-flow.md
+1
-0
NotificationListPage.jsx
...project_manager_xy/notifications/NotificationListPage.jsx
+475
-0
notificationService.js
...s/project_manager_xy/notifications/notificationService.js
+183
-0
notifications.business-flow.md
...t_manager_xy/notifications/notifications.business-flow.md
+37
-0
record-api.md
src/services/record-api.md
+53
-0
No files found.
.gitignore
View file @
082b0507
...
...
@@ -10,6 +10,7 @@
/.umi-test
/dist
/.mfsu
/.playwright-cli/
.swc
.pnpm-store
.DS_Store
.umirc.ts
View file @
082b0507
...
...
@@ -7,6 +7,7 @@ function resolvePackageRoot(packageName: string) {
const
ROUTE_PATHS
=
{
login
:
'/login'
,
notifications
:
'/notifications'
,
modelOverview
:
'/models/overview'
,
models
:
'/models'
,
modelForwards
:
'/models/forwards'
,
...
...
@@ -59,6 +60,10 @@ export default defineConfig({
path
:
'/resource/booking'
,
redirect
:
ROUTE_PATHS
.
models
,
},
{
path
:
ROUTE_PATHS
.
notifications
,
component
:
'./project_manager_xy/notifications/NotificationListPage'
,
},
{
path
:
ROUTE_PATHS
.
modelOverview
,
component
:
'./project_manager_xy/ModelOverview'
,
...
...
docs/ui-architecture.md
View file @
082b0507
...
...
@@ -16,7 +16,10 @@
## 组件边界
-
`Layout.jsx`
:HeroUI Pro Sidebar/Navbar、主题切换、应用切换、退出。
-
`Layout.jsx`
:HeroUI Pro Sidebar/Navbar、主题切换、应用切换、退出;移动端
`Sidebar.Mobile`
使用 HeroUI Pro
`Sheet.Heading`
提供不可见但可访问的 Dialog 标题。
-
`Layout.jsx`
:从通知 service 读取真实未读数,监听跨页面状态事件并每 60 秒刷新;Navbar 只负责铃铛展示和路由跳转。
-
`notifications/notificationService.js`
:复用
`DataService`
的平台通知请求,负责 300ms 搜索参数、未读状态、已读事件、动态 action 和 base-aware 链接解析。
-
`notifications/NotificationListPage.jsx`
:HeroUI 原生消息/待办列表、搜索、状态 Tabs、Pagination、EmptyState 与 Toast,不持有模型监控业务状态。
-
`ApplicationSwitcherDialog.jsx`
:HeroUI Modal/Card/Surface/Avatar;从
`SiteContext`
的动态
`site.group`
构建应用分组,不在 UI 层写死分组数据。
-
`XinyuanModal.jsx`
:HeroUI
`Modal.Root > Backdrop > Container > Dialog`
的业务无关组合,集中实现 320–1216px 设计系统宽度梯度和 28px 标准内容 inset;扩展尺寸由官方
`lg`
Container 加共享最大宽度实现,不向 HeroUI v3 传递无效的
`xl/4xl`
size。圆角、背景、阴影和交互状态仍由 HeroUI 官方 Modal 样式负责。
-
`ModelMonitorShared.jsx`
:业务中性的 SearchField、DataGrid、Pagination、ActionBar、状态 Chip、动态字段组合。
...
...
@@ -27,6 +30,8 @@
页面状态和 service 调用保持迁移前的业务边界。组件只把 HeroUI 的
`onPress`
、
`onChange`
、
`onSelectionChange`
和
`selectedKeys`
映射回已有 handler;API 路径、metadata、Record/Subject 字段和审批状态不在 UI 层重定义。
通知是本项目唯一获准新增的跨应用能力。
`/notifications`
复用既有
`/notice/customer/*`
API;模型分析不引入
`subjectView`
、
`shortTitle`
、View 授权或材料中心附件适配代码。
## 删除边界
`src/catalyst-ui-kit`
、不可达资源预约模板及 ECharts 统计模板已从仓库移除。它们不在
`.umirc.ts`
的活动路由中,备份为
`/tmp/model-analysis-legacy-ui.iaSi6Y`
。
docs/ui-migration-checklist.md
View file @
082b0507
...
...
@@ -11,6 +11,9 @@
-
[
x
]
DataGrid 原生选择、真实溢出固定列、横向滚动、单一空态
-
[
x
]
Pagination 固定 10 条、最多五页窗口、无页大小选择
-
[
x
]
ActionBar 固定定位并对齐活动内容区
-
[
x
]
模型分析按跨应用白名单只接入消息通知,未引入
`shortTitle`
、View 权限或材料中心文件功能
-
[
x
]
新增
`/notifications`
,支持消息/待办、全部/未读、300ms 搜索、固定 10 条分页、单条/全部已读、动态 action 和关联跳转
-
[
x
]
Navbar 使用 HeroUI Badge.Anchor + Badge + Button + Tooltip,Bell 位于主题按钮左侧,Sidebar 无通知入口
-
[
x
]
弹窗使用 HeroUI Modal/AlertDialog;全部业务 Modal 接入共享 320–1216px 尺寸梯度,审批详情使用单一 secondary Surface
-
[
x
]
批量同步弹窗使用 HeroUI Pro Stepper/ListView、HeroUI ScrollShadow 和正确 Checkbox anatomy;移除手写灰色模型/批次分组及重复 Surface
-
[
x
]
KPI 使用 HeroUI Pro compound API 与 16px grid gap
...
...
@@ -32,6 +35,10 @@
-
[
x
]
1280x720 登录页浅色/深色
-
[
x
]
768x1024 登录页浅色/深色
-
[
x
]
390x844 登录页浅色/深色
-
[
]
鉴权后 Shell、退出、应用切换、Dropdown、Tabs、DataGrid、Pagination、ActionBar、Modal、KPI 实数据验收
-
[
x
]
目标与问题整改通知页在 1440x900、1280x720、768x1024、390x844 完成浅色/深色同视口对照
-
[
x
]
未读 0/1/99/100、消息/待办、全部/未读、搜索、单条/全部已读、动态 action、站内外跳转、HTML 纯文本、Pagination、EmptyState、Toast
-
[
x
]
Navbar 36px Bell/主题按钮、8px 间距、48/32/16px gutter;移动 offcanvas、Escape、无页面横向溢出
-
[
x
]
最终通知验收控制台 0 error / 0 warning
-
[
]
真实通知数据端到端验收:Andy 会话调用
`/notice/customer/count`
和
`/notice/customer/query`
返回 401
鉴权后验收必须使用真实登录凭据或既有有效 session;不得伪造 token、用户或业务数据。2026-08-21 检查现有 Playwright 会话,未发现模型分析的有效登录态,因此该项当前被真实凭据阻断
。
浏览器结构和交互验收所用通知响应仅存在于 Playwright route 内存,不写入仓库、不作为业务数据。真实接口 401 继续交由平台权限/环境修复,前端未伪造成功状态或绕过既有请求拦截器
。
docs/xinyuan-design-system.md
View file @
082b0507
...
...
@@ -15,6 +15,8 @@
-
Sidebar 为 HeroUI Pro offcanvas,宽 240px,折叠为零宽;单菜单组不显示 GroupLabel。
-
Sidebar Footer 使用直接
`Dropdown.Trigger`
、HeroUI Avatar 和真实退出回调。
-
Navbar 为透明 64px HeroUI Pro Navbar;Trigger 使用与基准一致的实心 16px Sidebar 图标。
-
Navbar 通知使用 HeroUI
`Badge.Anchor + Badge + Button + Tooltip`
,36px Ant Bell 位于主题按钮左侧;未读 0 隐藏、1-99 原值、100+ 显示
`99+`
,Sidebar 不重复展示通知入口。
-
Navbar Bell 与主题按钮进入同一
`rc-navbar-actions`
动作组,间距固定 8px;两者静止态均使用 HeroUI
`--default`
中性 Surface,Hover 使用
`--default-hover`
。
-
Navbar 与页面共用 48/32/16px 对称 gutter。
-
登录页使用居中 28rem Card、36px Logo、40px 字段、24px 字段节奏和 46px 提交按钮;验证码及原鉴权协议保留。
-
应用切换为 HeroUI Modal,宽 46rem、高 42rem,移动一列、
`sm`
起两列,36px identity well。
...
...
@@ -29,6 +31,12 @@
-
普通 Modal 保持 HeroUI 官方 24px 圆角和 Overlay shadow,统一使用 28px panel inset;Modal 内承担真实 L2 边界的
`Surface secondary`
使用基准项目一致的
`rounded-2xl`
(16px),不得保留大面积直角灰色块。关闭按钮直接使用无子节点的
`Modal.CloseTrigger`
,由 HeroUI 渲染原生 16px CloseIcon,禁止替换为受字体基线影响的第三方图标;关闭按钮、标题、正文和 Footer 对齐同一内容边界,标题到正文 12px、正文到 Footer 24px。批量同步使用紧凑 HeroUI Pro Stepper,年份工具区使用正确 Checkbox compound anatomy,批次选择使用单一 HeroUI Pro ListView
`primary`
原生多选和 HeroUI ScrollShadow,不使用页面自制灰色分组块或重复 Surface。
-
KPI:HeroUI Pro KPI compound API;四项使用
`.rc-kpi-grid`
16px gap,标题不附加说明图标。
-
当前活动业务没有图表;不得为视觉验收制造图表或测试业务数据。未来增加时只使用 HeroUI Pro chart 与 ChartTooltip。
-
通知中心使用 HeroUI Tabs、SearchField、Card、Avatar、Chip、Pagination、Toast 和 HeroUI Pro EmptyState;一级模块桌面间距 32px、移动端 24px,工具栏间距 16px;页面不设置组件背景、颜色、描边、圆角、阴影或状态皮肤。
## 跨应用功能范围
-
当前应用显示名为“模型分析”,不属于问题整改、审计项目管理、警示案例库、廉洁从业管理、廉洁风险防控、党风廉政建设六个完整功能应用。
-
本项目只实施全局消息通知。不得加入 Grid/Detail
`shortTitle`
、Subject View 权限或审计材料中心文件工作流,即使相邻基准仓库存在这些实现。
## 数据保护
...
...
docs/xinyuan-ui-audit.md
View file @
082b0507
# 模型分析 UI 审计
审计日期:2026-08-2
1
审计日期:2026-08-2
7
实现基准:已确认的“问题整改”应用。
...
...
@@ -12,8 +12,9 @@
-
实际解析:
`node_modules/.pnpm/@heroui-pro+react@1.0.0-beta.8/node_modules/@heroui-pro/react/package.json`
-
完整包文件:
`dist/index.js`
、
`dist/css/index.css`
均存在。
-
已验证子路径:
`sidebar`
、
`navbar`
、
`data-grid`
、
`drop-zone`
、
`action-bar`
、
`empty-state`
。
-
本轮复核:
`@heroui-pro/react@1.0.0-beta.8`
解析到当前仓库
`node_modules/.pnpm`
,
`dist/index.js`
与
`dist/css/index.css`
存在,六个 JS 子路径动态 import 全部成功;CSS export 可解析,裸 Node 不执行 CSS 文件。
-
依赖变化:更新
`package.json`
和
`pnpm-lock.yaml`
,新增
`pnpm-workspace.yaml`
;删除旧
`package-lock.json`
与本机 vendor TGZ。唯一锁文件为
`pnpm-lock.yaml`
。
-
依赖中不存在
`file:`
、相邻项目
`node_modules`
、
软链接或
`/Users/...`
路径
。
-
依赖中不存在
`file:`
、相邻项目
`node_modules`
、
手工软链接替代或
`/Users/...`
路径;
`node_modules`
内仅有 pnpm 正常生成的内容寻址链接
。
## 业务与路由
...
...
@@ -25,6 +26,7 @@
|
`/models/:modelId/batches`
| 模型批次 | HeroUI Pro DataGrid、Modal、ProgressBar |
|
`/models/:modelId/batches/:batchId/data`
| 批次数据 | DataGrid 原生选择、固定列、Pagination、ActionBar、Modal |
|
`/models/forwards`
| 转发审批 | Tabs、DataGrid、单一 secondary Surface 详情、4xl Modal |
|
`/notifications`
| 通知消息 | HeroUI Tabs/SearchField/Card/Pagination/Toast、HeroUI Pro EmptyState;消息/待办与全部/未读 |
历史
`/analysis/*`
与
`/resource/*`
仅重定向到上述活动路由。不可达的资源预约/统计模板以及
`src/catalyst-ui-kit`
已从仓库移除,备份位于
`/tmp/model-analysis-legacy-ui.iaSi6Y`
。
...
...
@@ -34,7 +36,16 @@
-
Auth wrapper 继续通过
`getUserInfo()`
合并用户;退出继续调用
`userLogout(username)`
,最终清理登录态并进入
`/login`
。
-
模型同步、批次同步、动态 Subject、隐藏记录、转发、填写和两级审批的 service、参数、校验、调用顺序与状态条件未修改。
-
批次数据每页固定 10 条;Pagination 最多显示五个连续页码,无页大小选择器。
-
没有生成测试数字、浏览器 fixture 或伪造业务数据。
-
仓库没有生成测试数字、浏览器 fixture 或伪造业务数据;验收 route 响应只存在于 Playwright 会话内存。
## 跨应用功能审计
-
应用统一显示名为“模型分析”,应用 route/name 为
`model_analysis`
,base/publicPath 为
`/model_analysis/`
;不在六个完整功能应用白名单内,因此只实施消息通知。
-
当前模型监控页面由固定 Subject 与自有 service 驱动,没有动态 Grid/Detail Subject View 链路;未发现可接入
`shortTitle`
或
`viewAuth`
的合法范围。
-
Andy 本轮登录返回
`type: admin`
且
`applications: []`
。通知能力沿用平台用户会话,不从其他应用复制或合并授权,也不读取
`integrity_risk_manager`
。
-
项目已有材料归档
`materialService.js`
,但本轮没有可编辑通用 file 字段或详情草稿事务接入需求;该业务保持不变,没有加入问题整改的材料中心文件流。
-
`DataService.js`
已存在
`queryUserNotice`
、
`readUserNotice`
、
`unReadNoticeCount`
、
`exacteActionFunction`
。本轮只增加通知业务 service、页面、Navbar 状态和路由,没有改变这些 API 函数的 URL、方法或请求体。
-
真实 Andy 会话下,当前环境的
`/notice/customer/count`
与
`/notice/customer/query`
返回 401;原请求拦截器按既有规则清理会话。该后端权限/环境阻塞保留并在浏览器验收中单独报告,不在前端伪造数据规避。
## 组件与样式审计
...
...
@@ -45,6 +56,7 @@
-
核心列表均使用 DataGrid;空态由
`renderEmptyState`
单一渲染。
-
页面视觉色、背景、描边、圆角、阴影及 HeroUI 内部状态覆盖扫描为零;次要文字直接使用 HeroUI
`Description`
,必填状态由
`Label isRequired`
管理。
-
`xinyuan.css`
仅保留批准的登录基准、Shell 几何、应用切换宽高和 KPI 布局;组件外观由权威主题及官方 variant 管理。
-
Navbar 通知与主题按钮复用批准的共享动作组:36x36px、8px 间距、HeroUI 中性 Surface;移动端 Sidebar Dialog 通过 HeroUI Pro
`Sheet.Heading`
获得可访问名称,复验控制台无警告。
-
当前业务需求没有活动图表;旧 ECharts 页面已移除,因此没有伪造图表或业务数据。
## 主题
...
...
@@ -57,6 +69,19 @@
## 浏览器证据
-
目标:
`http://localhost:8004/model_analysis/`
-
基准:
`http://localhost:8002/problem_rectification_new/`
-
同一 Playwright 会话内完成登录页 1440x900 基准比较;关键卡片、字段、验证码和提交按钮几何一致。
-
最终多视口、浅/深色与鉴权页面验收结果见
`docs/ui-migration-checklist.md`
。
-
基准:
`http://localhost:8005/problem_rectification_new/`
-
使用独立 Playwright 会话同时打开目标通知页和问题整改通知页,在 1440x900、1280x720、768x1024、390x844 的浅色与深色完成同视口对照。基准真实登录受验证码接口拒绝,因此仅在浏览器内存中拦截鉴权/通知请求用于结构比较,未写入仓库或充当业务数据。
-
桌面通知页的一级 Tabs、搜索、状态 Tabs、Card 和 Pagination 几何与基准一致;目标继续执行当前合同要求的 48/32/16px Shell gutter。所有视口
`scrollWidth === clientWidth`
。
-
Navbar Bell/主题按钮均为 36x36px、间距 8px;未读 0/1/99/100 分别验证为隐藏/
`1`
/
`99`
/
`99+`
,Badge 可访问名称保留真实计数。
-
验证消息/待办、全部/未读、300ms 搜索、单条已读、全部已读、动态 action、站内去 base 跳转、外链新窗口、HTML 纯文本、Pagination、EmptyState 与 Toast;移动端 offcanvas 可由 Escape 关闭,Dialog 标题为“模型分析导航”。最终目标控制台 0 error / 0 warning。
-
真实 Andy 会话仍被
`/notice/customer/count`
与
`/notice/customer/query`
的 401 阻断;因此前端实现和浏览器结构验收完成,但真实通知数据的端到端验收未完成。
## 最终扫描
-
`npm run build`
:成功,通知页异步 chunk 已生成。
-
`git diff --check`
:成功。
-
`bash /Users/liuchenlong/.codex/skills/xinyuan-design-system/scripts/audit_project.sh "$(git rev-parse --show-toplevel)"`
:退出码 0;脚本的 CommonJS 探测对六个 ESM Pro 子路径报告 warning,后续
`npm run verify:ui-runtime`
动态 import 全部成功。
-
审计脚本另保留 7 条当前分支既有启发式 warning:共享 Modal panel 探测、DataGrid typography token、DataGrid 主次字段层级、人员身份组合、可点击主字段、固定列 stacking rule、
`--rc-shadow-card`
基准比对。本轮通知范围未扩大修改这些既有模块。
-
旧 Catalyst/Headless/旧组件 import、可见原生 select/date/file、通知页私有视觉 class、HeroUI 内部 BEM/伪元素覆盖:扫描无命中。
-
`file:`
、相邻
`node_modules`
和
`/Users/...`
package/lock 依赖:扫描无命中。
-
`pnpm list @heroui-pro/react @heroui/react @heroui/styles @react-aria/utils react react-dom react-aria-components --depth 1`
:应用运行时为 React/ReactDOM 19.2.7、HeroUI 3.2.2、React Aria Components 1.20.0。
src/constants/app.js
View file @
082b0507
...
...
@@ -38,6 +38,7 @@ export function stripAppBase(pathname = '/') {
}
export
const
ROUTE_TITLES
=
[
{
match
:
/^
\/
notifications$/
,
title
:
'通知消息'
},
{
match
:
/^
\/
models
\/[^/]
+
\/
batches
\/[^/]
+
\/
data$/
,
title
:
'批次数据'
},
{
match
:
/^
\/
models
\/[^/]
+
\/
batches$/
,
title
:
'模型批次'
},
{
match
:
/^
\/
models
\/
forwards$/
,
title
:
'转发审批'
},
...
...
src/design-system/xinyuan.css
View file @
082b0507
...
...
@@ -319,6 +319,21 @@ input.login-input:-webkit-autofill {
padding-inline
:
var
(
--rc-shell-inline-gutter
);
}
.rc-navbar-actions
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
}
.rc-navbar-icon-button.button
{
background
:
var
(
--default
);
}
.rc-navbar-icon-button.button
:hover
,
.rc-navbar-icon-button.button
[
data-hovered
=
'true'
]
{
background
:
var
(
--default-hover
);
}
.xy-navbar
.sidebar__trigger
,
.xy-navbar
[
data-slot
=
'sidebar-trigger'
]
{
width
:
var
(
--rc-navbar-trigger-size
);
...
...
src/pages/project_manager_xy/Layout.jsx
View file @
082b0507
...
...
@@ -13,6 +13,7 @@ import { UserContext } from '@/wrapper/Auth';
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
AppstoreOutlined
,
BellOutlined
,
DatabaseOutlined
,
LogoutOutlined
,
MoonOutlined
,
...
...
@@ -20,10 +21,15 @@ import {
SunOutlined
,
UnorderedListOutlined
,
}
from
'@ant-design/icons'
;
import
{
Avatar
,
B
utton
,
Description
,
Dropdown
,
Label
,
Tooltip
}
from
'@heroui/react'
;
import
{
Avatar
,
B
adge
,
Button
,
Description
,
Dropdown
,
Label
,
Toast
,
Tooltip
}
from
'@heroui/react'
;
import
{
Navbar
}
from
'@heroui-pro/react/navbar'
;
import
{
Sheet
}
from
'@heroui-pro/react/sheet'
;
import
{
Sidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
loadUnreadNotificationCount
,
NOTIFICATION_STATE_EVENT
,
}
from
'@/pages/project_manager_xy/notifications/notificationService'
;
import
{
useCallback
,
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
Outlet
,
history
,
useLocation
}
from
'umi'
;
const
MENU_ITEMS
=
[
...
...
@@ -140,6 +146,7 @@ export default function ProjectManagerLayout() {
const
siteInfo
=
useContext
(
SiteContext
);
const
[
theme
,
setTheme
]
=
useState
(
getInitialTheme
);
const
[
isApplicationDialogOpen
,
setIsApplicationDialogOpen
]
=
useState
(
false
);
const
[
unreadNotificationCount
,
setUnreadNotificationCount
]
=
useState
(
0
);
const
pageTitle
=
useMemo
(()
=>
getRouteTitle
(
location
.
pathname
),
[
location
.
pathname
]);
const
isDark
=
theme
===
'dark'
;
...
...
@@ -152,6 +159,31 @@ export default function ProjectManagerLayout() {
document
.
title
=
`
${
pageTitle
}
-
${
APP_DISPLAY_NAME
}
`
;
},
[
pageTitle
]);
const
refreshUnreadNotificationCount
=
useCallback
(
async
()
=>
{
try
{
setUnreadNotificationCount
(
await
loadUnreadNotificationCount
());
}
catch
(
error
)
{
setUnreadNotificationCount
(
0
);
}
},
[]);
useEffect
(()
=>
{
refreshUnreadNotificationCount
();
window
.
addEventListener
(
NOTIFICATION_STATE_EVENT
,
refreshUnreadNotificationCount
,
);
const
timer
=
window
.
setInterval
(
refreshUnreadNotificationCount
,
60000
);
return
()
=>
{
window
.
removeEventListener
(
NOTIFICATION_STATE_EVENT
,
refreshUnreadNotificationCount
,
);
window
.
clearInterval
(
timer
);
};
},
[
refreshUnreadNotificationCount
]);
const
themeLabel
=
isDark
?
'切换至浅色模式'
:
'切换至深色模式'
;
return
(
...
...
@@ -168,6 +200,9 @@ export default function ProjectManagerLayout() {
/>
</
Sidebar
>
<
Sidebar
.
Mobile
className=
"xy-sidebar"
>
<
Sheet
.
Heading
className=
"sr-only"
>
{
APP_DISPLAY_NAME
}
导航
</
Sheet
.
Heading
>
<
SidebarContents
pathname=
{
location
.
pathname
}
userInfo=
{
userInfo
}
...
...
@@ -180,20 +215,52 @@ export default function ProjectManagerLayout() {
<
Sidebar
.
Trigger
aria
-
label=
"切换侧栏"
className=
"rc-navbar-trigger"
><
SidebarPanelIcon
/></
Sidebar
.
Trigger
>
<
h1
className=
"xy-navbar-title"
>
{
pageTitle
}
</
h1
>
<
Navbar
.
Spacer
/>
<
Tooltip
delay=
{
0
}
>
<
Button
isIconOnly
aria
-
label=
{
themeLabel
}
aria
-
pressed=
{
isDark
}
className=
"xy-icon-button rc-navbar-icon-button"
size=
"sm"
variant=
"ghost"
onPress=
{
()
=>
setTheme
(
isDark
?
'light'
:
'dark'
)
}
>
{
isDark
?
<
SunOutlined
/>
:
<
MoonOutlined
/>
}
</
Button
>
<
Tooltip
.
Content
>
{
themeLabel
}
</
Tooltip
.
Content
>
</
Tooltip
>
<
div
className=
"rc-navbar-actions"
>
<
Tooltip
delay=
{
0
}
>
<
Badge
.
Anchor
>
<
Button
isIconOnly
aria
-
label=
{
unreadNotificationCount
>
0
?
`通知消息,${unreadNotificationCount}条未读`
:
'通知消息'
}
className=
"xy-icon-button rc-navbar-icon-button"
size=
"sm"
variant=
"ghost"
onPress=
{
()
=>
history
.
push
(
'/notifications'
)
}
>
<
BellOutlined
/>
</
Button
>
{
unreadNotificationCount
>
0
?
(
<
Badge
aria
-
label=
{
`${unreadNotificationCount}条未读通知`
}
color=
"danger"
size=
"sm"
>
{
unreadNotificationCount
>
99
?
'99+'
:
unreadNotificationCount
}
</
Badge
>
)
:
null
}
</
Badge
.
Anchor
>
<
Tooltip
.
Content
>
通知消息
</
Tooltip
.
Content
>
</
Tooltip
>
<
Tooltip
delay=
{
0
}
>
<
Button
isIconOnly
aria
-
label=
{
themeLabel
}
aria
-
pressed=
{
isDark
}
className=
"xy-icon-button rc-navbar-icon-button"
size=
"sm"
variant=
"ghost"
onPress=
{
()
=>
setTheme
(
isDark
?
'light'
:
'dark'
)
}
>
{
isDark
?
<
SunOutlined
/>
:
<
MoonOutlined
/>
}
</
Button
>
<
Tooltip
.
Content
>
{
themeLabel
}
</
Tooltip
.
Content
>
</
Tooltip
>
</
div
>
</
Navbar
.
Header
>
</
Navbar
>
<
div
className=
"xy-shell-scroll"
>
...
...
@@ -209,6 +276,7 @@ export default function ProjectManagerLayout() {
userInfo=
{
userInfo
}
onOpenChange=
{
setIsApplicationDialogOpen
}
/>
<
Toast
.
Provider
maxVisibleToasts=
{
3
}
placement=
"top"
/>
</
Sidebar
.
Provider
>
);
}
src/pages/project_manager_xy/model-monitor.business-flow.md
View file @
082b0507
...
...
@@ -25,6 +25,7 @@
-
侧栏应用切换入口打开 HeroUI 应用切换弹窗,不改变当前模型监控路由和登录状态。
-
可访问应用继续由
`GET /application/info`
与当前登录用户的
`applications`
合并得到,保留原排序、回退和跳转规则。
-
应用分组从
`GET /info`
返回的
`site.group`
动态读取;每个分组通过
`applications`
中的应用
`name`
关联,前端不写死分组名称或成员。
-
全局通知入口位于 Navbar 主题按钮左侧,点击进入
`/notifications`
;Sidebar 不增加通知菜单。通知中心属于平台能力,不改变模型、批次、批次数据或转发审批状态流,详细协议见
`notifications/notifications.business-flow.md`
。
-
没有动态分组配置时展示“全部应用”;存在动态分组时,未被任何分组引用的可访问应用展示在“其他应用”中,空分组不渲染。
## 3. 外部数据源契约
...
...
src/pages/project_manager_xy/notifications/NotificationListPage.jsx
0 → 100644
View file @
082b0507
import
{
BellOutlined
,
CloseOutlined
,
ExportOutlined
,
SearchOutlined
,
}
from
'@ant-design/icons'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Avatar
,
Button
,
Card
,
Chip
,
Pagination
,
SearchField
,
Spinner
,
Tabs
,
toast
,
}
from
'@heroui/react'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
useCallback
,
useContext
,
useEffect
,
useState
}
from
'react'
;
import
{
history
}
from
'umi'
;
import
{
executeNotificationAction
,
extractNotificationTarget
,
getNotificationContent
,
getNotificationSender
,
isNotificationRead
,
loadNotificationPage
,
loadUnreadNotificationCount
,
markAllNotificationsRead
,
markNotificationRead
,
NOTIFICATION_PAGE_SIZE
,
resolveNotificationTarget
,
}
from
'./notificationService'
;
const
CATEGORY_OPTIONS
=
[
{
key
:
'message'
,
label
:
'消息'
},
{
key
:
'todo'
,
label
:
'待办'
},
];
const
READ_STATUS_OPTIONS
=
[
{
key
:
'all'
,
label
:
'全部'
},
{
key
:
'unread'
,
label
:
'未读'
},
];
function
formatNotificationTime
(
value
)
{
if
(
!
value
)
return
'时间未知'
;
const
date
=
new
Date
(
value
);
if
(
Number
.
isNaN
(
date
.
getTime
()))
return
String
(
value
);
return
new
Intl
.
DateTimeFormat
(
'zh-CN'
,
{
year
:
'numeric'
,
month
:
'2-digit'
,
day
:
'2-digit'
,
hour
:
'2-digit'
,
minute
:
'2-digit'
,
second
:
'2-digit'
,
hour12
:
false
,
}).
format
(
date
);
}
function
NotificationItem
({
notification
,
category
,
currentUserId
,
isBusy
,
onAction
,
onMarkRead
,
onOpenTarget
,
})
{
const
isRead
=
isNotificationRead
(
notification
,
currentUserId
);
const
sender
=
getNotificationSender
(
notification
);
const
content
=
getNotificationContent
(
notification
)
||
'暂无通知内容'
;
const
target
=
resolveNotificationTarget
(
extractNotificationTarget
(
notification
));
const
actions
=
Array
.
isArray
(
notification
?.
actions
)
?
notification
.
actions
:
[];
const
avatarText
=
sender
.
trim
().
slice
(
0
,
1
).
toUpperCase
()
||
'系'
;
return
(
<
Card
>
<
Card
.
Content
className=
"flex flex-col gap-4 p-4 sm:p-5"
>
<
div
className=
"flex min-w-0 items-start gap-3"
>
<
Avatar
className=
"shrink-0"
size=
"sm"
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
)
}
>
<
ExportOutlined
aria
-
hidden=
"true"
/>
进入流程
</
Button
>
)
:
null
}
</
div
>
</
Card
.
Content
>
</
Card
>
);
}
function
NotificationPagination
({
page
,
total
,
isDisabled
,
onPageChange
})
{
const
totalPages
=
Math
.
max
(
1
,
Math
.
ceil
(
total
/
NOTIFICATION_PAGE_SIZE
));
const
pageWindowStart
=
Math
.
max
(
0
,
Math
.
min
(
page
-
2
,
totalPages
-
5
));
const
pageWindowEnd
=
Math
.
min
(
totalPages
,
pageWindowStart
+
5
);
return
(
<
Pagination
size=
"sm"
>
<
Pagination
.
Summary
>
共
{
total
}
条通知,第
{
page
+
1
}
/
{
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
:
pageWindowEnd
-
pageWindowStart
},
(
_
,
index
)
=>
pageWindowStart
+
index
,
).
map
((
pageIndex
)
=>
(
<
Pagination
.
Item
key=
{
pageIndex
}
>
<
Pagination
.
Link
isActive=
{
pageIndex
===
page
}
isDisabled=
{
isDisabled
}
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
>
);
}
export
default
function
NotificationListPage
()
{
const
userInfo
=
useContext
(
UserContext
);
const
[
category
,
setCategory
]
=
useState
(
'message'
);
const
[
readStatus
,
setReadStatus
]
=
useState
(
'all'
);
const
[
searchText
,
setSearchText
]
=
useState
(
''
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
page
,
setPage
]
=
useState
(
0
);
const
[
notifications
,
setNotifications
]
=
useState
([]);
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
unreadTotal
,
setUnreadTotal
]
=
useState
(
0
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
busyKey
,
setBusyKey
]
=
useState
(
''
);
const
[
reloadKey
,
setReloadKey
]
=
useState
(
0
);
useEffect
(()
=>
{
const
timer
=
window
.
setTimeout
(()
=>
{
setKeyword
(
searchText
.
trim
());
setPage
(
0
);
},
300
);
return
()
=>
window
.
clearTimeout
(
timer
);
},
[
searchText
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
setLoading
(
true
);
setError
(
''
);
loadNotificationPage
({
page
,
pageSize
:
NOTIFICATION_PAGE_SIZE
,
category
,
readStatus
,
keyword
,
})
.
then
((
result
)
=>
{
if
(
cancelled
)
return
;
setNotifications
(
result
.
list
);
setTotal
(
result
.
total
);
})
.
catch
((
requestError
)
=>
{
if
(
cancelled
)
return
;
setNotifications
([]);
setTotal
(
0
);
setError
(
requestError
?.
message
||
'通知列表加载失败,请稍后重试'
);
})
.
finally
(()
=>
{
if
(
!
cancelled
)
setLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
loadUnreadNotificationCount
()
.
then
((
count
)
=>
{
if
(
!
cancelled
)
setUnreadTotal
(
count
);
})
.
catch
(()
=>
{
if
(
!
cancelled
)
setUnreadTotal
(
0
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
reloadKey
]);
const
refresh
=
useCallback
(()
=>
setReloadKey
((
value
)
=>
value
+
1
),
[]);
const
runItemOperation
=
useCallback
(
async
(
key
,
operation
,
successMessage
)
=>
{
setBusyKey
(
key
);
try
{
await
operation
();
toast
.
success
(
successMessage
,
{
timeout
:
3000
});
refresh
();
return
true
;
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'操作失败,请稍后重试'
,
{
timeout
:
4000
,
});
return
false
;
}
finally
{
setBusyKey
(
''
);
}
},
[
refresh
],
);
const
handleMarkRead
=
useCallback
(
(
notification
)
=>
runItemOperation
(
notification
.
name
,
()
=>
markNotificationRead
(
notification
.
name
),
'已标记为已读'
,
),
[
runItemOperation
],
);
const
handleMarkAllRead
=
useCallback
(
()
=>
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=
"flex min-w-0 flex-col gap-6 sm:gap-8"
>
<
div
className=
"flex min-w-0 flex-col items-stretch justify-between gap-4 md:flex-row md:items-center"
>
<
Tabs
className=
"w-fit max-w-full min-w-0"
selectedKey=
{
category
}
onSelectionChange=
{
(
key
)
=>
{
setCategory
(
String
(
key
));
setPage
(
0
);
}
}
>
<
Tabs
.
ListContainer
className=
"w-fit max-w-full min-w-0"
>
<
Tabs
.
List
aria
-
label=
"通知分类"
className=
"w-max min-w-max"
>
{
CATEGORY_OPTIONS
.
map
((
option
)
=>
(
<
Tabs
.
Tab
id=
{
option
.
key
}
key=
{
option
.
key
}
className=
"w-auto min-w-max flex-none whitespace-nowrap"
>
{
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=
"flex min-w-0 flex-wrap items-center justify-between gap-4"
>
<
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
>
<
SearchOutlined
/>
</
SearchField
.
SearchIcon
>
<
SearchField
.
Input
placeholder=
"搜索通知内容"
/>
<
SearchField
.
ClearButton
aria
-
label=
"清空通知搜索"
>
<
CloseOutlined
/>
</
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
id=
{
option
.
key
}
key=
{
option
.
key
}
className=
"w-auto min-w-max flex-none whitespace-nowrap"
>
{
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
>
<
BellOutlined
/>
</
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
>
);
}
src/pages/project_manager_xy/notifications/notificationService.js
0 → 100644
View file @
082b0507
import
{
exacteActionFunction
,
queryUserNotice
,
readUserNotice
,
unReadNoticeCount
,
}
from
'@/services/DataService'
;
import
{
APP_BASE_PATH
}
from
'@/constants/app'
;
export
const
NOTIFICATION_PAGE_SIZE
=
10
;
export
const
NOTIFICATION_STATE_EVENT
=
'model-analysis: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
content
=
getNotificationContent
(
notification
);
const
escapedBasePath
=
APP_BASE_PATH
.
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
'
\\
$&'
);
const
match
=
content
.
match
(
new
RegExp
(
`https?:\\/\\/[^\\s<>"']+|
${
escapedBasePath
}
\\/[^\\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
);
if
(
url
.
pathname
===
APP_BASE_PATH
||
url
.
pathname
.
startsWith
(
`
${
APP_BASE_PATH
}
/`
))
{
return
resolveNotificationTarget
(
`
${
url
.
pathname
}${
url
.
search
}${
url
.
hash
}
`
);
}
return
{
href
:
url
.
toString
(),
external
:
true
};
}
catch
(
error
)
{
return
null
;
}
}
if
(
normalizedTarget
!==
APP_BASE_PATH
&&
!
normalizedTarget
.
startsWith
(
`
${
APP_BASE_PATH
}
/`
)
)
{
return
normalizedTarget
.
startsWith
(
'/'
)
?
{
href
:
normalizedTarget
,
external
:
true
}
:
null
;
}
return
{
href
:
normalizedTarget
.
slice
(
APP_BASE_PATH
.
length
)
||
'/'
,
external
:
false
,
};
}
export
function
notifyNotificationStateChanged
()
{
if
(
typeof
window
!==
'undefined'
)
{
window
.
dispatchEvent
(
new
CustomEvent
(
NOTIFICATION_STATE_EVENT
));
}
}
src/pages/project_manager_xy/notifications/notifications.business-flow.md
0 → 100644
View file @
082b0507
# 模型分析通知中心业务流程
## 功能入口
-
路由:
`/notifications`
,部署后完整地址为
`/model_analysis/notifications`
。
-
Navbar 在主题按钮左侧显示 36px 通知铃铛;Sidebar 不提供重复入口。
-
Bell 与主题按钮间距为 8px;移动端 Sidebar Dialog 具有“模型分析导航”可访问标题。
-
未读数量通过
`GET /notice/customer/count`
获取,0 隐藏、1-99 显示真实值、100 及以上显示
`99+`
;页面操作完成后通过
`model-analysis:notification-state`
事件刷新,并每 60 秒自动刷新。
## 列表与筛选
1.
页面通过
`POST /notice/customer/query`
查询通知,每页固定 10 条。
2.
一级分类为
`message`
(消息)和
`todo`
(待办)。
3.
状态为全部或未读;未读向服务端发送
`status: "unread"`
,不得只过滤当前页。
4.
搜索输入延迟 300ms,将转义后的内容写入
`filter.content.$regex`
,并回到第 1 页。
5.
通知正文以普通文本渲染,不解析或执行服务端 HTML。
## 已读与待办状态
-
单条“标为已读”调用
`PUT /notice/customer/read`
并传通知
`name`
。
-
“全部已读”调用同一平台接口但不传具体名称。
-
待办按钮按通知返回的
`actions`
动态渲染;执行时调用
`PUT /notice/customer/:action/action/:methodName`
,请求体只包含
`{ notice_id }`
。
-
操作成功后显示 HeroUI Toast,重新查询列表和真实未读数量;失败保留当前页面状态并显示错误。
## 关联流程跳转
-
优先读取通知
`settings.url/settings.link/data.url/data.link`
,无配置时才从纯文本内容识别 URL。
-
`/model_analysis`
内的链接只去除当前应用 base 后交给 Umi 路由。
-
其他应用路径和外部 HTTP(S) 地址在新窗口打开,并使用
`noopener,noreferrer`
。
-
打开关联流程前先按现有协议标记当前通知已读;不得执行通知 HTML 或脚本。
## 异常与限制
-
所有请求继续使用现有登录 Token、请求拦截器和退出处理,不新增通知专用鉴权。
-
接口 401/403、网络失败或动态 action 配置缺失时不得伪造成功状态或未读数量。
-
通知中心不读取或修改模型监控 Subject、Record、审批字段及用户
`applications`
授权结构。
-
本应用不在五项完整功能白名单中,不实现
`shortTitle`
、Subject View 权限或审计材料中心文件工作流。
src/services/record-api.md
View file @
082b0507
...
...
@@ -231,3 +231,56 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
-
同步部分失败时保留已成功记录,并把批次标记为
`partial`
。
-
同步校验失败时不得开始写入动态数据 Record。
-
页面必须分别呈现 loading、success、error 状态;已创建批次不提供重试入口。
## 7. 通知中心接口
通知中心复用平台原接口,不新增模型分析私有通知协议。
### 7.1 查询通知
`POST /notice/customer/query`
```
json
{
"page"
:
0
,
"page_size"
:
10
,
"filter"
:
{
"category"
:
"message"
,
"content"
:
{
"$regex"
:
"转义后的搜索词"
,
"$options"
:
"i"
}
},
"status"
:
"unread"
}
```
-
`category`
取平台值
`message`
或
`todo`
。
-
全部状态传
`status: null`
;未读状态传
`status: "unread"`
,由服务端过滤全部记录。
-
搜索仅写入
`filter.content`
,并在发送前转义正则字符。
-
分页固定每页 10 条,页码从 0 开始。
### 7.2 未读数量
`GET /notice/customer/count`
Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及以上显示
`99+`
。
### 7.3 标记已读
`PUT /notice/customer/read`
-
单条已读请求体为
`{ "name": "通知名称" }`
。
-
全部已读沿用平台协议,调用同一接口且不提供具体通知名称。
### 7.4 执行待办动作
`PUT /notice/customer/:action/action/:methodName`
```
json
{
"notice_id"
:
"通知 _id"
}
```
`:action`
与
`:methodName`
必须来自通知返回的动态
`actions`
配置,前端不得写死流程动作。
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