Commit 082b0507 authored by Andy-bubu's avatar Andy-bubu

feat: add Xinyuan notification center

parent 50992ed4
......@@ -10,6 +10,7 @@
/.umi-test
/dist
/.mfsu
/.playwright-cli/
.swc
.pnpm-store
.DS_Store
......@@ -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',
......
......@@ -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`。
......@@ -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 继续交由平台权限/环境修复,前端未伪造成功状态或绕过既有请求拦截器。
......@@ -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 权限或审计材料中心文件工作流,即使相邻基准仓库存在这些实现。
## 数据保护
......
# 模型分析 UI 审计
审计日期:2026-08-21
审计日期:2026-08-27
实现基准:已确认的“问题整改”应用。
......@@ -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。
......@@ -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: '转发审批' },
......
......@@ -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);
......
......@@ -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, Button, Description, Dropdown, Label, Tooltip } from '@heroui/react';
import { Avatar, Badge, 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,6 +215,37 @@ export default function ProjectManagerLayout() {
<Sidebar.Trigger aria-label="切换侧栏" className="rc-navbar-trigger"><SidebarPanelIcon /></Sidebar.Trigger>
<h1 className="xy-navbar-title">{pageTitle}</h1>
<Navbar.Spacer />
<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
......@@ -194,6 +260,7 @@ export default function ProjectManagerLayout() {
</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>
);
}
......@@ -25,6 +25,7 @@
- 侧栏应用切换入口打开 HeroUI 应用切换弹窗,不改变当前模型监控路由和登录状态。
- 可访问应用继续由 `GET /application/info` 与当前登录用户的 `applications` 合并得到,保留原排序、回退和跳转规则。
- 应用分组从 `GET /info` 返回的 `site.group` 动态读取;每个分组通过 `applications` 中的应用 `name` 关联,前端不写死分组名称或成员。
- 全局通知入口位于 Navbar 主题按钮左侧,点击进入 `/notifications`;Sidebar 不增加通知菜单。通知中心属于平台能力,不改变模型、批次、批次数据或转发审批状态流,详细协议见 `notifications/notifications.business-flow.md`。
- 没有动态分组配置时展示“全部应用”;存在动态分组时,未被任何分组引用的可访问应用展示在“其他应用”中,空分组不渲染。
## 3. 外部数据源契约
......
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>
);
}
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));
}
}
# 模型分析通知中心业务流程
## 功能入口
- 路由:`/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 权限或审计材料中心文件工作流。
......@@ -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` 配置,前端不得写死流程动作。
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