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

feat: add notification center

parent 1da38cbc
...@@ -40,6 +40,11 @@ export default defineConfig({ ...@@ -40,6 +40,11 @@ export default defineConfig({
// redirect: '/chat', // redirect: '/chat',
component: './Home', component: './Home',
}, },
{
name: '通知消息',
path: '/notifications',
component: './Notifications',
},
// { // {
// name: '聊天', // name: '聊天',
// path: '/chat', // path: '/chat',
......
...@@ -78,3 +78,25 @@ Umi route /ai_chat/ ...@@ -78,3 +78,25 @@ Umi route /ai_chat/
## 验收边界 ## 验收边界
业务保护优先于制造测试状态。当前账号没有真实会话,且没有提供审计知识库 Excel,因此不会创建测试会话或伪造风险点数据。会话行操作和解析后 DataGrid 仍需在真实数据可用时完成最终操作验收;这两个项目在检查清单中保持进行中,不作为“迁移完成”宣称。 业务保护优先于制造测试状态。当前账号没有真实会话,且没有提供审计知识库 Excel,因此不会创建测试会话或伪造风险点数据。会话行操作和解析后 DataGrid 仍需在真实数据可用时完成最终操作验收;这两个项目在检查清单中保持进行中,不作为“迁移完成”宣称。
## 通知架构
```text
Umi /ai_chat/notifications
-> existing current_user authentication and AppShell
-> AppShell GET /api/notice/customer/count
-> DashboardNavbar HeroUI Badge
-> notification page
-> message/todo and all/unread Tabs
-> debounced SearchField
-> plain notification Cards and dynamic actions
-> HeroUI compound Pagination
-> PUT /api/notice/customer/read
-> notification state event -> list/count refresh
-> PUT /api/notice/customer/{action}/action/{method}
```
- 通知计数由 `AppShell` 所有,页面不复制 Navbar 状态;通知状态事件和 60 秒轮询复用同一刷新函数。
- 通知页沿用现有 `current_user` 登录协议、AppShell、退出和应用切换;不读取其他应用授权来决定通知可见性。
- 内部目标只接受当前 `/ai_chat` base 并转为 Umi 内部路径;其他绝对路径和外部 HTTP(S) 目标作为外链处理。
- 本应用只实施通知能力;`shortTitle`、Subject/View 权限与材料中心文件流程不进入架构。
...@@ -77,3 +77,19 @@ ...@@ -77,3 +77,19 @@
- [x] HeroUI Pro 指定子路径 ESM 导入检查通过 - [x] HeroUI Pro 指定子路径 ESM 导入检查通过
- [x] 授权值扫描为零 - [x] 授权值扫描为零
- [x] 未提交、未推送、未合并 - [x] 未提交、未推送、未合并
## 消息通知增量
- [x] 确认应用为“仅消息通知”范围;功能 1、2、3、5 不适用且未实施
- [x] 审计 `current_user`、`applications[]`、basePath、Shell 数据流、通知接口和现有回调
- [x] 新增独立 `/notifications` 路由,保留 `/ai_chat/` base
- [x] Navbar 使用 HeroUI Badge.Anchor、Badge、Button、Tooltip;36px Bell 位于主题按钮左侧
- [x] Sidebar 无通知入口,未读 0/1/99/100 显示规则通过
- [x] 消息/待办、全部/未读、搜索、防抖、分页、单条/全部已读通过
- [x] 动态 action 路径及 `{notice_id}` 请求体通过
- [x] 站内链接去除自身 base;其他应用与外部链接新窗口打开
- [x] 通知内容保持纯文本,不执行 HTML
- [x] 1440x900、1280x720、768x1024、390x844 浅色/深色通过
- [x] 与“问题整改”同视口对照通过;目标通知页控制台 0 error / 0 warning
- [x] 浏览器内存夹具未进入源码、文档或业务数据
- [x] Andy 真实验证码登录、空通知接口、Sidebar 真实退出通过
...@@ -70,3 +70,11 @@ Inter 字体文件独立存放于 `public/fonts/`,并由 `.umirc.ts` 注入稳 ...@@ -70,3 +70,11 @@ Inter 字体文件独立存放于 `public/fonts/`,并由 `.umirc.ts` 注入稳
- 页面、业务模块和共享 JSX 的私有视觉 utility 扫描为零;共享外观仅位于权威主题、批准 Tokens 和共享 Shell 设计系统层。 - 页面、业务模块和共享 JSX 的私有视觉 utility 扫描为零;共享外观仅位于权威主题、批准 Tokens 和共享 Shell 设计系统层。
- Ant Design、Ant Design Pro、Ant Design X、Ant X Markdown、antd-style、Headless UI 和 Catalyst UI Kit 活动引用为零;后续开发必须持续保持为零。 - Ant Design、Ant Design Pro、Ant Design X、Ant X Markdown、antd-style、Headless UI 和 Catalyst UI Kit 活动引用为零;后续开发必须持续保持为零。
- 当前业务没有真实 Tabs、筛选、KPI、图表或分页;不得为补齐目录而新增非业务模块。 - 当前业务没有真实 Tabs、筛选、KPI、图表或分页;不得为补齐目录而新增非业务模块。
## 消息通知
- `AI 应用` 不在六个完整功能应用白名单内,本次只接入消息通知;不得加入 `shortTitle`、Subject/View 权限或审计材料中心能力。
- Navbar 通知入口直接使用 HeroUI `Badge.Anchor + Badge + Button + Tooltip`,36px Bell 位于主题按钮左侧;Sidebar 不增加通知入口。
- `/notifications` 使用 HeroUI Tabs、SearchField、Card、Chip、Button、Toast 和 HeroUI Pro EmptyState;通知分页使用 HeroUI compound Pagination。
- 徽标在未读数为 0 时隐藏,1–99 显示真实值,超过 99 显示 `99+`;按钮可访问名称始终保留真实未读数。
- 通知正文只作为 React 文本渲染,不执行 HTML。站内链接只去除自身 `/ai_chat` base,其他应用路径与外部 HTTP(S) 链接均新窗口打开。
...@@ -109,3 +109,29 @@ ...@@ -109,3 +109,29 @@
- 官方安装将 React 升至 19.2.8,而 Umi 的部分开发依赖仍声明/携带 React 18 运行图,需要在最终依赖树与浏览器 Context 检查中确认运行时唯一性。 - 官方安装将 React 升至 19.2.8,而 Umi 的部分开发依赖仍声明/携带 React 18 运行图,需要在最终依赖树与浏览器 Context 检查中确认运行时唯一性。
- 活动 UI 文件合计超过 7,000 行,大量视觉与业务逻辑同文件;迁移必须逐个保留回调,不能用整页演示代码替换。 - 活动 UI 文件合计超过 7,000 行,大量视觉与业务逻辑同文件;迁移必须逐个保留回调,不能用整页演示代码替换。
- 应用不是典型表格后台。Shell、登录、应用切换必须精确复刻基准;不存在的 Tabs、KPI、图表和列表业务不得伪造。 - 应用不是典型表格后台。Shell、登录、应用切换必须精确复刻基准;不存在的 Tabs、KPI、图表和列表业务不得伪造。
## 2026-08-27 消息通知审计
- 应用范围:`AI 应用` 不属于完整功能白名单,仅实施功能 4“消息通知”;功能 1、2、3、5 明确不适用。
- 路由 base/publicPath 为 `/ai_chat/`;新增独立应用内路由 `/notifications`,浏览器地址为 `/ai_chat/notifications`。
- 登录状态沿用 `localStorage.current_user`,Navbar 与通知页不读取、复制或合并其他应用的 `user.applications[]` 权限。
- 原 Shell 的页面操作由 `useNavbarActions` 注入;通知计数提升到 `AppShell`,登录用户变化、通知状态事件及 60 秒定时器触发刷新。
- Sidebar 保留原会话和用户数据流,不增加通知入口。Navbar 的通知入口位于页面操作与主题按钮之间。
- 通知接口沿用平台协议:`POST /notice/customer/query`、`PUT /notice/customer/read`、`GET /notice/customer/count`、`PUT /notice/customer/{action}/action/{method}`;目标项目请求层继续统一添加 `/api` 前缀。
- 查询固定每页 10 条,搜索 300ms 防抖并转义正则,未读筛选发送 `status: "unread"`;动态 action 只提交 `{notice_id}`。
- 现有目标项目无 Subject/View、record-workbench、RecordDetailDrawer、材料中心文件字段或通知模块;未新增相关能力,也未改变登录、会话和 AI 业务回调。
### 浏览器验收
目标通知页与运行中的“问题整改”通知页同时打开并按相同视口对照。1440x900 下关键坐标一致:Sidebar 240px、Navbar 64px、页面左右 gutter 48px、工具栏和列表宽度一致,“进入流程”按钮均为带图标的 100px。
| 视口 | 浅色 | 深色 | 结果 |
| --- | --- | --- | --- |
| 1440x900 | 通过 | 通过 | 桌面 Sidebar、Navbar、Tabs、筛选、列表、分页对齐 |
| 1280x720 | 通过 | 通过 | 无横向溢出,工具栏与卡片布局稳定 |
| 768x1024 | 通过 | 通过 | Sidebar offcanvas,32px gutter,无横向溢出 |
| 390x844 | 通过 | 通过 | Sidebar offcanvas,16px gutter,工具栏换行,无横向溢出 |
实测覆盖未读 0/1/99/100、单条已读、全部已读、消息/待办、全部/未读、搜索防抖与正则转义、分页、动态 action、站内外跳转。通知页验收会话控制台为 0 error / 0 warning;夹具只存在于浏览器内存,未写入源码或业务数据。
使用提供的 Andy 账号完成真实验证码登录,`/api/auth/login`、`/api/user/info`、通知查询与未读计数接口均返回 200;该账号当前通知列表为空。随后从 Sidebar 用户菜单真实退出,`current_user` 已清除并回到登录界面,控制台保持 0 error / 0 warning。
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined'; import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined'; import CloseOutlined from '@ant-design/icons/CloseOutlined';
import CommentOutlined from '@ant-design/icons/CommentOutlined'; import CommentOutlined from '@ant-design/icons/CommentOutlined';
import DeleteOutlined from '@ant-design/icons/DeleteOutlined'; import DeleteOutlined from '@ant-design/icons/DeleteOutlined';
import EditOutlined from '@ant-design/icons/EditOutlined'; import EditOutlined from '@ant-design/icons/EditOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined'; import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined'; import MoonOutlined from '@ant-design/icons/MoonOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined'; import MoreOutlined from '@ant-design/icons/MoreOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined'; import PlusOutlined from '@ant-design/icons/PlusOutlined';
import PushpinOutlined from '@ant-design/icons/PushpinOutlined'; import PushpinOutlined from '@ant-design/icons/PushpinOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined'; import SunOutlined from '@ant-design/icons/SunOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined'; import WarningOutlined from '@ant-design/icons/WarningOutlined';
...@@ -24,16 +27,19 @@ function withSize(Icon) { ...@@ -24,16 +27,19 @@ function withSize(Icon) {
} }
export const AppGrid = withSize(AppstoreOutlined); export const AppGrid = withSize(AppstoreOutlined);
export const Bell = withSize(BellOutlined);
export const Close = withSize(CloseOutlined); export const Close = withSize(CloseOutlined);
export const Comment = withSize(CommentOutlined); export const Comment = withSize(CommentOutlined);
export const Delete = withSize(DeleteOutlined); export const Delete = withSize(DeleteOutlined);
export const Edit = withSize(EditOutlined); export const Edit = withSize(EditOutlined);
export const OpenExternal = withSize(ExportOutlined);
export const ExclamationTriangleIcon = withSize(WarningOutlined); export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const LogOut = withSize(LogoutOutlined); export const LogOut = withSize(LogoutOutlined);
export const Moon = withSize(MoonOutlined); export const Moon = withSize(MoonOutlined);
export const More = withSize(MoreOutlined); export const More = withSize(MoreOutlined);
export const Plus = withSize(PlusOutlined); export const Plus = withSize(PlusOutlined);
export const Pin = withSize(PushpinOutlined); export const Pin = withSize(PushpinOutlined);
export const Search = withSize(SearchOutlined);
export const Sun = withSize(SunOutlined); export const Sun = withSize(SunOutlined);
export function SidebarPanel({ size = 16, style, ...props }) { export function SidebarPanel({ size = 16, style, ...props }) {
......
import { Pagination } from '@heroui/react';
export default function AppPagination({
isDisabled = false,
itemLabel = '条',
page,
pageSize,
total,
onPageChange,
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const pageWindowStart = Math.max(0, Math.min(page - 2, totalPages - 5));
const pageWindowEnd = total
? Math.min(totalPages, pageWindowStart + 5)
: pageWindowStart;
return (
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
<Pagination aria-label="分页" size="sm">
<Pagination.Summary>
共 {total} {itemLabel},第 {total ? page + 1 : 0} /{' '}
{total ? totalPages : 0} 页
</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) => {
const pageIndex = pageWindowStart + index;
return (
<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 || total === 0}
onPress={() => onPageChange(page + 1)}
>
<span>下一页</span>
<Pagination.NextIcon />
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination>
</div>
);
}
import { THEME_STORAGE_KEY } from '@/config/application'; import { THEME_STORAGE_KEY } from '@/config/application';
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from '@/features/notifications/notificationService';
import { Sidebar } from '@heroui-pro/react/sidebar'; import { Sidebar } from '@heroui-pro/react/sidebar';
import { Toast } from '@heroui/react'; import { Toast } from '@heroui/react';
import { createContext, useContext, useLayoutEffect, useState } from 'react'; import { history } from '@umijs/max';
import {
createContext,
useContext,
useEffect,
useLayoutEffect,
useState,
} from 'react';
import DashboardNavbar from './DashboardNavbar'; import DashboardNavbar from './DashboardNavbar';
import DashboardSidebar from './DashboardSidebar'; import DashboardSidebar from './DashboardSidebar';
...@@ -41,6 +52,7 @@ export default function AppShell({ ...@@ -41,6 +52,7 @@ export default function AppShell({
}) { }) {
const [theme, setTheme] = useState(getInitialTheme); const [theme, setTheme] = useState(getInitialTheme);
const [navbarActions, setNavbarActions] = useState(null); const [navbarActions, setNavbarActions] = useState(null);
const [notificationCount, setNotificationCount] = useState(0);
useLayoutEffect(() => { useLayoutEffect(() => {
const root = document.documentElement; const root = document.documentElement;
...@@ -56,6 +68,36 @@ export default function AppShell({ ...@@ -56,6 +68,36 @@ export default function AppShell({
} }
}, [theme]); }, [theme]);
useEffect(() => {
if (!sidebarProps.userInfo) {
setNotificationCount(0);
return undefined;
}
let cancelled = false;
const refreshNotificationCount = async () => {
try {
const count = await loadUnreadNotificationCount();
if (!cancelled) setNotificationCount(count);
} catch {
if (!cancelled) setNotificationCount(0);
}
};
refreshNotificationCount();
window.addEventListener(NOTIFICATION_STATE_EVENT, refreshNotificationCount);
const timer = window.setInterval(refreshNotificationCount, 60000);
return () => {
cancelled = true;
window.removeEventListener(
NOTIFICATION_STATE_EVENT,
refreshNotificationCount,
);
window.clearInterval(timer);
};
}, [sidebarProps.userInfo]);
return ( return (
<NavbarActionsContext.Provider value={setNavbarActions}> <NavbarActionsContext.Provider value={setNavbarActions}>
<Sidebar.Provider <Sidebar.Provider
...@@ -68,8 +110,10 @@ export default function AppShell({ ...@@ -68,8 +110,10 @@ export default function AppShell({
<Sidebar.Main className="app-main-glass rc-app-main"> <Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar <DashboardNavbar
actions={navbarActions} actions={navbarActions}
notificationCount={notificationCount}
theme={theme} theme={theme}
title={title} title={title}
onOpenNotifications={() => history.push('/notifications')}
onThemeChange={setTheme} onThemeChange={setTheme}
/> />
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto"> <div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
......
import { Navbar } from '@heroui-pro/react/navbar'; import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar'; import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react'; import { Badge, Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons'; import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
export default function DashboardNavbar({ export default function DashboardNavbar({
actions, actions,
notificationCount = 0,
onOpenNotifications,
onThemeChange, onThemeChange,
theme, theme,
title, title,
...@@ -12,6 +14,13 @@ export default function DashboardNavbar({ ...@@ -12,6 +14,13 @@ export default function DashboardNavbar({
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,
);
const notificationLabel = normalizedNotificationCount
? `通知消息,${normalizedNotificationCount}条未读通知`
: '通知消息';
return ( return (
<Navbar className="app-navbar rc-navbar" maxWidth="full"> <Navbar className="app-navbar rc-navbar" maxWidth="full">
...@@ -26,6 +35,32 @@ export default function DashboardNavbar({ ...@@ -26,6 +35,32 @@ export default function DashboardNavbar({
<Navbar.Spacer /> <Navbar.Spacer />
<div className="rc-navbar-actions"> <div className="rc-navbar-actions">
{actions} {actions}
<Tooltip delay={0}>
<Badge.Anchor>
<Button
isIconOnly
aria-label={notificationLabel}
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>
<Tooltip delay={0}> <Tooltip delay={0}>
<Button <Button
isIconOnly isIconOnly
......
export const APPLICATION_DISPLAY_NAME = 'AI 应用'; export const APPLICATION_DISPLAY_NAME = 'AI 应用';
export const APPLICATION_BASE_PATH = '/ai_chat/'; export const APPLICATION_BASE_PATH = '/ai_chat/';
export const THEME_STORAGE_KEY = 'xinyuan_color_theme'; export const THEME_STORAGE_KEY = 'xinyuan_color_theme';
export const ROUTE_TITLES = {
'/': APPLICATION_DISPLAY_NAME,
'/notifications': '通知消息',
};
import { APPLICATION_BASE_PATH } from '@/config/application';
import {
executeUserNoticeAction,
queryUserNotice,
readUserNotice,
unreadUserNoticeCount,
} from '@/services/DataService';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'ai-chat: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 unreadUserNoticeCount();
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 executeUserNoticeAction(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 basePath = APPLICATION_BASE_PATH.replace(/\/$/, '');
const escapedBasePath = basePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = content.match(
new RegExp(`https?://[^\\s<>"']+|${escapedBasePath}/[^\\s<>"']+`, 'i'),
);
return trimTarget(match?.[0]);
}
export function resolveNotificationTarget(target) {
const normalizedTarget = trimTarget(target);
if (!normalizedTarget) return null;
const basePath = APPLICATION_BASE_PATH.replace(/\/$/, '');
if (/^https?:\/\//i.test(normalizedTarget)) {
try {
const url = new URL(normalizedTarget);
if (
typeof window !== 'undefined' &&
url.origin === window.location.origin &&
(url.pathname === basePath || url.pathname.startsWith(`${basePath}/`))
) {
return resolveNotificationTarget(
`${url.pathname}${url.search}${url.hash}`,
);
}
return { href: url.toString(), external: true };
} catch {
return null;
}
}
if (normalizedTarget === basePath) {
return { href: '/', external: false };
}
if (normalizedTarget.startsWith(`${basePath}/`)) {
return {
href: normalizedTarget.slice(basePath.length) || '/',
external: false,
};
}
return normalizedTarget.startsWith('/')
? { href: normalizedTarget, external: true }
: null;
}
export function notifyNotificationStateChanged() {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(NOTIFICATION_STATE_EVENT));
}
}
import { Bell, Close, OpenExternal, Search } from '@/components/AppIcons';
import AppPagination from '@/components/AppPagination';
import AppShell from '@/components/AppShell';
import ApplicationSwitcherDialog from '@/components/ApplicationSwitcherDialog';
import LoginModal from '@/components/LoginModal';
import { ROUTE_TITLES } from '@/config/application';
import {
NOTIFICATION_PAGE_SIZE,
executeNotificationAction,
extractNotificationTarget,
getNotificationContent,
getNotificationSender,
isNotificationRead,
loadNotificationPage,
loadUnreadNotificationCount,
markAllNotificationsRead,
markNotificationRead,
resolveNotificationTarget,
} from '@/features/notifications/notificationService';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
Chip,
SearchField,
Spinner,
Tabs,
toast,
} from '@heroui/react';
import { history } from '@umijs/max';
import { useCallback, useEffect, useState } from 'react';
const CATEGORY_OPTIONS = [
{ key: 'message', label: '消息' },
{ key: 'todo', label: '待办' },
];
const READ_STATUS_OPTIONS = [
{ key: 'all', label: '全部' },
{ key: 'unread', label: '未读' },
];
function getStoredUser() {
try {
const currentUser = localStorage.getItem('current_user');
return currentUser ? JSON.parse(currentUser) : null;
} catch {
return null;
}
}
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">
<h2 className="break-words text-sm font-medium text-foreground 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 text-foreground">
{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>
);
}
function NotificationPageContent({ userInfo }) {
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="rc-page-stack">
<div className="rc-page-toolbar rc-page-toolbar-responsive">
<Tabs
className="w-fit max-w-full min-w-0"
selectedKey={category}
onSelectionChange={(key) => {
setCategory(String(key));
setPage(0);
}}
>
<Tabs.ListContainer className="w-fit max-w-full min-w-0">
<Tabs.List aria-label="通知分类" className="w-max min-w-max">
{CATEGORY_OPTIONS.map((option) => (
<Tabs.Tab 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>
<Search 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 ? (
<AppPagination
isDisabled={loading}
itemLabel="条通知"
page={page}
pageSize={NOTIFICATION_PAGE_SIZE}
total={total}
onPageChange={setPage}
/>
) : null}
</div>
);
}
export default function NotificationsPage() {
const [userInfo, setUserInfo] = useState(getStoredUser);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [applicationSwitcherOpen, setApplicationSwitcherOpen] = useState(false);
useEffect(() => {
document.title = `${ROUTE_TITLES['/notifications']} - AI 应用`;
}, []);
if (!userInfo) {
return (
<LoginModal
open
onError={() => {}}
onSuccess={(currentUser) => {
setUserInfo(currentUser);
window.setTimeout(() => toast.success('登录成功'), 0);
}}
/>
);
}
return (
<>
<AppShell
activeKey=""
conversations={[]}
open={sidebarOpen}
title={ROUTE_TITLES['/notifications']}
userInfo={userInfo}
onDeleteConversation={() => {}}
onLogout={() => {
localStorage.removeItem('current_user');
setUserInfo(null);
}}
onNewConversation={() => history.push('/')}
onOpenApplicationSwitcher={() => setApplicationSwitcherOpen(true)}
onOpenChange={setSidebarOpen}
onOpenConversation={() => history.push('/')}
onPinConversation={() => {}}
onRenameConversation={() => {}}
>
<NotificationPageContent userInfo={userInfo} />
</AppShell>
<ApplicationSwitcherDialog
open={applicationSwitcherOpen}
userInfo={userInfo}
onClose={() => setApplicationSwitcherOpen(false)}
/>
</>
);
}
...@@ -480,6 +480,38 @@ export function getApplications() { ...@@ -480,6 +480,38 @@ export function getApplications() {
}); });
} }
export function queryUserNotice(page, pageSize, filter = {}, status = null) {
return request('/api/notice/customer/query', {
method: 'post',
data: {
page,
page_size: pageSize,
filter,
status,
},
});
}
export function readUserNotice(name) {
return request('/api/notice/customer/read', {
method: 'put',
data: { name },
});
}
export function unreadUserNoticeCount() {
return request('/api/notice/customer/count', {
method: 'get',
});
}
export function executeUserNoticeAction(action, methodName, data) {
return request(`/api/notice/customer/${action}/action/${methodName}`, {
method: 'put',
data,
});
}
export function getRecord(name) { export function getRecord(name) {
return request(`/api/record/${name}`, { return request(`/api/record/${name}`, {
method: 'get', method: 'get',
......
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