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

feat: add notification center

parent 1da38cbc
......@@ -40,6 +40,11 @@ export default defineConfig({
// redirect: '/chat',
component: './Home',
},
{
name: '通知消息',
path: '/notifications',
component: './Notifications',
},
// {
// name: '聊天',
// path: '/chat',
......
......@@ -78,3 +78,25 @@ Umi route /ai_chat/
## 验收边界
业务保护优先于制造测试状态。当前账号没有真实会话,且没有提供审计知识库 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 @@
- [x] HeroUI Pro 指定子路径 ESM 导入检查通过
- [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` 注入稳
- 页面、业务模块和共享 JSX 的私有视觉 utility 扫描为零;共享外观仅位于权威主题、批准 Tokens 和共享 Shell 设计系统层。
- Ant Design、Ant Design Pro、Ant Design X、Ant X Markdown、antd-style、Headless UI 和 Catalyst UI Kit 活动引用为零;后续开发必须持续保持为零。
- 当前业务没有真实 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 @@
- 官方安装将 React 升至 19.2.8,而 Umi 的部分开发依赖仍声明/携带 React 18 运行图,需要在最终依赖树与浏览器 Context 检查中确认运行时唯一性。
- 活动 UI 文件合计超过 7,000 行,大量视觉与业务逻辑同文件;迁移必须逐个保留回调,不能用整页演示代码替换。
- 应用不是典型表格后台。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 BellOutlined from '@ant-design/icons/BellOutlined';
import CloseOutlined from '@ant-design/icons/CloseOutlined';
import CommentOutlined from '@ant-design/icons/CommentOutlined';
import DeleteOutlined from '@ant-design/icons/DeleteOutlined';
import EditOutlined from '@ant-design/icons/EditOutlined';
import ExportOutlined from '@ant-design/icons/ExportOutlined';
import LogoutOutlined from '@ant-design/icons/LogoutOutlined';
import MoonOutlined from '@ant-design/icons/MoonOutlined';
import MoreOutlined from '@ant-design/icons/MoreOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
import PushpinOutlined from '@ant-design/icons/PushpinOutlined';
import SearchOutlined from '@ant-design/icons/SearchOutlined';
import SunOutlined from '@ant-design/icons/SunOutlined';
import WarningOutlined from '@ant-design/icons/WarningOutlined';
......@@ -24,16 +27,19 @@ function withSize(Icon) {
}
export const AppGrid = withSize(AppstoreOutlined);
export const Bell = withSize(BellOutlined);
export const Close = withSize(CloseOutlined);
export const Comment = withSize(CommentOutlined);
export const Delete = withSize(DeleteOutlined);
export const Edit = withSize(EditOutlined);
export const OpenExternal = withSize(ExportOutlined);
export const ExclamationTriangleIcon = withSize(WarningOutlined);
export const LogOut = withSize(LogoutOutlined);
export const Moon = withSize(MoonOutlined);
export const More = withSize(MoreOutlined);
export const Plus = withSize(PlusOutlined);
export const Pin = withSize(PushpinOutlined);
export const Search = withSize(SearchOutlined);
export const Sun = withSize(SunOutlined);
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 {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from '@/features/notifications/notificationService';
import { Sidebar } from '@heroui-pro/react/sidebar';
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 DashboardSidebar from './DashboardSidebar';
......@@ -41,6 +52,7 @@ export default function AppShell({
}) {
const [theme, setTheme] = useState(getInitialTheme);
const [navbarActions, setNavbarActions] = useState(null);
const [notificationCount, setNotificationCount] = useState(0);
useLayoutEffect(() => {
const root = document.documentElement;
......@@ -56,6 +68,36 @@ export default function AppShell({
}
}, [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 (
<NavbarActionsContext.Provider value={setNavbarActions}>
<Sidebar.Provider
......@@ -68,8 +110,10 @@ export default function AppShell({
<Sidebar.Main className="app-main-glass rc-app-main">
<DashboardNavbar
actions={navbarActions}
notificationCount={notificationCount}
theme={theme}
title={title}
onOpenNotifications={() => history.push('/notifications')}
onThemeChange={setTheme}
/>
<div className="app-shell-content-scroll min-h-0 flex-1 overflow-y-auto">
......
import { Navbar } from '@heroui-pro/react/navbar';
import { Sidebar, useSidebar } from '@heroui-pro/react/sidebar';
import { Button, Tooltip } from '@heroui/react';
import { Moon, SidebarPanel, Sun } from './AppIcons';
import { Badge, Button, Tooltip } from '@heroui/react';
import { Bell, Moon, SidebarPanel, Sun } from './AppIcons';
export default function DashboardNavbar({
actions,
notificationCount = 0,
onOpenNotifications,
onThemeChange,
theme,
title,
......@@ -12,6 +14,13 @@ export default function DashboardNavbar({
const { isMobile, isOpen } = useSidebar();
const isDark = theme === 'dark';
const themeLabel = isDark ? '切换至浅色模式' : '切换至深色模式';
const normalizedNotificationCount = Math.max(
0,
Number(notificationCount) || 0,
);
const notificationLabel = normalizedNotificationCount
? `通知消息,${normalizedNotificationCount}条未读通知`
: '通知消息';
return (
<Navbar className="app-navbar rc-navbar" maxWidth="full">
......@@ -26,6 +35,32 @@ export default function DashboardNavbar({
<Navbar.Spacer />
<div className="rc-navbar-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}>
<Button
isIconOnly
......
export const APPLICATION_DISPLAY_NAME = 'AI 应用';
export const APPLICATION_BASE_PATH = '/ai_chat/';
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));
}
}
This diff is collapsed.
......@@ -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) {
return request(`/api/record/${name}`, {
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