Commit 06cc0518 authored by Andy-bubu's avatar Andy-bubu

feat: add notification center

parent 045f8d34
...@@ -9,6 +9,7 @@ const ROUTE_PATHS = { ...@@ -9,6 +9,7 @@ const ROUTE_PATHS = {
login: '/login', login: '/login',
resourceStat: '/resource/stat', resourceStat: '/resource/stat',
resourceBooking: '/resource/booking', resourceBooking: '/resource/booking',
notifications: '/notifications',
} as const; } as const;
export default defineConfig({ export default defineConfig({
...@@ -48,6 +49,10 @@ export default defineConfig({ ...@@ -48,6 +49,10 @@ export default defineConfig({
path: ROUTE_PATHS.resourceBooking, path: ROUTE_PATHS.resourceBooking,
component: './project_manager_xy/resource-booking/index', component: './project_manager_xy/resource-booking/index',
}, },
{
path: ROUTE_PATHS.notifications,
component: './project_manager_xy/notifications/NotificationListPage',
},
], ],
}, },
], ],
......
# UI Architecture # UI Architecture
Updated: 2026-08-25 Updated: 2026-08-26
## Framework Boundary ## Framework Boundary
...@@ -58,6 +58,18 @@ The removed portable/app-token layers must not be reintroduced. ...@@ -58,6 +58,18 @@ The removed portable/app-token layers must not be reintroduced.
- Shared UI owns layout, accessibility and HeroUI composition only. - Shared UI owns layout, accessibility and HeroUI composition only.
- Routes keep the Umi base `/audit_material_manager/` and existing auth wrappers. - Routes keep the Umi base `/audit_material_manager/` and existing auth wrappers.
The notification controller owns the existing platform notice calls, query
filters, read/action mutations and target normalization. The Shell owns only
the unread count and Bell entry. A custom DOM event refreshes the count after a
successful mutation; no notification entry is added to Sidebar. Login and 401
redirects normalize same-origin URLs back to an application-relative route so
notification deep links cannot duplicate the Umi base.
Cross-application scope is keyed by the real unified display name, not by a
source directory or material `source_app` identifier. This application is
`审计材料中心`, so the current rollout is notification-only; no Subject View,
shortTitle or embedded business-file workflow belongs in this repository.
Application switching keeps `/application/info` plus the current user's Application switching keeps `/application/info` plus the current user's
applications as the access-controlled data source. `SiteContext` supplies applications as the access-controlled data source. `SiteContext` supplies
`GET /info -> site.group` for presentation grouping only; it never grants `GET /info -> site.group` for presentation grouping only; it never grants
......
# UI Migration Checklist # UI Migration Checklist
Updated: 2026-08-25 Updated: 2026-08-26
Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`. Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`.
...@@ -8,7 +8,7 @@ Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`. ...@@ -8,7 +8,7 @@ Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`.
| ----------------------------- | ----------- | ----------------------------------------------------------------------------------------------------- | | ----------------------------- | ----------- | ----------------------------------------------------------------------------------------------------- |
| Repository onboarding | done | Remote verified, fast-forwarded, dedicated branch created | | Repository onboarding | done | Remote verified, fast-forwarded, dedicated branch created |
| Business/API reading | done | Record, Subject and audit-material flow documents reviewed | | Business/API reading | done | Record, Subject and audit-material flow documents reviewed |
| Baseline startup | done | Umi running at `http://localhost:8000/audit_material_manager/` with the real base path | | Baseline startup | done | Umi running at `http://localhost:8001/audit_material_manager/` with the real base path |
| Baseline login QA | superseded | Earlier viewport evidence predates the corrected problem-rectification login parity pass | | Baseline login QA | superseded | Earlier viewport evidence predates the corrected problem-rectification login parity pass |
| Authoritative theme | done | Exact baseline `theme.css`, Xinyuan assets, Inter and app-only extension Tokens installed | | Authoritative theme | done | Exact baseline `theme.css`, Xinyuan assets, Inter and app-only extension Tokens installed |
| Login | done | Baseline structure, assets and approved 12px label / 24px field rhythm synchronized | | Login | done | Baseline structure, assets and approved 12px label / 24px field rhythm synchronized |
...@@ -28,6 +28,25 @@ Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`. ...@@ -28,6 +28,25 @@ Status legend: `done`, `in progress`, `superseded`, `blocked`, `pending`.
| Private visual CSS | done | Duplicate portable/app-token layers removed; approved shared shell/login baseline retained | | Private visual CSS | done | Duplicate portable/app-token layers removed; approved shared shell/login baseline retained |
| Production build | done | `npm run build` completed after core migration | | Production build | done | `npm run build` completed after core migration |
| Browser acceptance | in progress | Four switcher viewports, dark theme, desktop/mobile sidebar and user menu pass; Autocomplete library warnings remain | | Browser acceptance | in progress | Four switcher viewports, dark theme, desktop/mobile sidebar and user menu pass; Autocomplete library warnings remain |
| Cross-app scope classification | done | Real app name is 审计材料中心; notification-only rollout, full-feature items intentionally excluded |
| Notification route | done | Independent `/notifications` page with message/todo, all/unread, search and fixed pagination |
| Notification Navbar entry | done | HeroUI Badge.Anchor/Badge/Button/Tooltip, 36px Bell left of theme action, no Sidebar entry |
| Notification mutations | done | Existing query/count/read/action APIs; single/all read and `{notice_id}` dynamic actions preserved |
| Notification link safety | done | Plain text content, base-aware same-app links, external new-window links and normalized login redirect |
| Notification browser acceptance | done | Four required viewports in both themes; badge boundaries, actions, links, overflow and isolated console pass |
## 2026-08-26 Notification Rollout
- Audited the real application identity, routes, Subject/file protocol, user
authorization structure, notification APIs, Shell data flow and callbacks.
- Classified this app as notification-only because `审计材料中心` is outside
the six-name full-feature whitelist. No shortTitle, Subject View permission
or embedded material-field behavior was introduced.
- Added the independent route and synchronized the Problem Rectification
notification structure using native HeroUI and HeroUI Pro compounds.
- Browser-only data verified unread 0/1/99/100, safe notification text,
regex-escaped search, single/all read and base-aware route behavior without
creating repository fixtures or backend notices.
## 2026-08-25 Application Switcher Correction ## 2026-08-25 Application Switcher Correction
......
# XinYuan Design System # XinYuan Design System
Updated: 2026-08-25 Updated: 2026-08-26
## Authority ## Authority
...@@ -89,3 +89,16 @@ DataGrid. Cards are reserved for discrete business objects and analytical tools. ...@@ -89,3 +89,16 @@ DataGrid. Cards are reserved for discrete business objects and analytical tools.
All `am_material` business fields stay under `metadata`. User references store All `am_material` business fields stay under `metadata`. User references store
IDs only. Dates are submitted as ISO 8601. `source_system` and IDs only. Dates are submitted as ISO 8601. `source_system` and
`archive_category` options always come from Subject `settings.options`. `archive_category` options always come from Subject `settings.options`.
## Notification Center
- The Bell is a 36px HeroUI icon Button immediately left of the theme action,
composed with `Tooltip`, `Badge.Anchor` and `Badge`.
- Zero unread hides the Badge, 1-99 shows the real count, and values above 99
display `99+` while retaining the real accessible count.
- `/notifications` uses message/todo and all/unread HeroUI Tabs, HeroUI
SearchField and Cards, Pro EmptyState, and the shared compound Pagination.
- Notification content is always rendered as text. Internal links strip only
`/audit_material_manager`; other application and external links open in a new
browsing context.
- Notifications are a Navbar concern and do not appear in Sidebar navigation.
# XinYuan UI Audit # XinYuan UI Audit
Updated: 2026-08-25 Updated: 2026-08-26
## Repository Baseline ## Repository Baseline
...@@ -35,6 +35,7 @@ The authoritative references are `src/services/record-api.md`, ...@@ -35,6 +35,7 @@ The authoritative references are `src/services/record-api.md`,
| `/login` | Login and captcha | Baseline JSX and approved shared CSS contract synchronized | | `/login` | Login and captcha | Baseline JSX and approved shared CSS contract synchronized |
| `/resource/stat` | Material dashboard and charts | Pro KPI/Charts/DataGrid; 24px L1 Cards, toned KPI icon wells and three peer L1 compliance Cards | | `/resource/stat` | Material dashboard and charts | Pro KPI/Charts/DataGrid; 24px L1 Cards, toned KPI icon wells and three peer L1 compliance Cards |
| `/resource/booking` | Material archive, filters, table, upload and dialogs | HeroUI controls/Modal plus Pro DataGrid, ActionBar and DropZone | | `/resource/booking` | Material archive, filters, table, upload and dialogs | HeroUI controls/Modal plus Pro DataGrid, ActionBar and DropZone |
| `/notifications` | Message/todo notification center | HeroUI Tabs/SearchField/Card with Pro EmptyState and shared Pagination |
| Global shell | Sidebar, navbar, theme, user menu | Pro AppLayout, Sidebar and Navbar with offcanvas/mobile navigation | | Global shell | Sidebar, navbar, theme, user menu | Pro AppLayout, Sidebar and Navbar with offcanvas/mobile navigation |
| Application switcher | Cross-application modal | HeroUI Modal with `/info -> site.group` sections, counts, loading, error, empty and fallback states | | Application switcher | Cross-application modal | HeroUI Modal with `/info -> site.group` sections, counts, loading, error, empty and fallback states |
...@@ -69,6 +70,22 @@ decorative empty-state page were removed after production references reached ...@@ -69,6 +70,22 @@ decorative empty-state page were removed after production references reached
zero. The 2026-08-25 audit found and corrected the remaining application zero. The 2026-08-25 audit found and corrected the remaining application
switcher grouping gap without changing its application source or navigation. switcher grouping gap without changing its application source or navigation.
## Cross-Application Feature Scope Audit
- The authoritative unified application name is `审计材料中心`, from
`src/config/application.js`, `.umirc.ts` and the active business-flow document.
- The `project_manager_xy` directory and `source_app` value are legacy/template
identifiers; they do not rename this application to `审计项目管理`.
- `审计材料中心` is not one of the six full-feature applications in the approved
cross-application whitelist. This repository therefore implements notification
center only. Grid/detail `shortTitle`, Subject View authorization and business
detail material-field changes are intentionally out of scope.
- Existing Subject definitions, `am_material` file protocol, record callbacks,
route permissions and shell business data remain unchanged.
- Existing user authorization reads `user.applications`; the Andy acceptance
account currently returns an empty applications array. Notifications use the
platform customer-notice endpoints and do not infer application permissions.
## Theme Audit ## Theme Audit
- `src/design-system/theme.css` is byte-identical to the approved baseline. - `src/design-system/theme.css` is byte-identical to the approved baseline.
...@@ -112,6 +129,19 @@ switcher grouping gap without changing its application source or navigation. ...@@ -112,6 +129,19 @@ switcher grouping gap without changing its application source or navigation.
3.2.2 Autocomplete Popover internal Dialog-title warning for each mounted 3.2.2 Autocomplete Popover internal Dialog-title warning for each mounted
Autocomplete. The public compound API does not expose that internal Dialog. Autocomplete. The public compound API does not expose that internal Dialog.
## Notification Browser Evidence
- The notification center was compared with the current Problem Rectification
implementation. Light and dark passes cover 1440x900, 1280x720, 768x1024
and 390x844. Its Navbar placement, two Tabs groups, search, all-read action,
item layout, pagination and empty state match the baseline with no horizontal
overflow.
- Real authenticated requests return the platform notice envelope and zero
notices for Andy. Browser-only interception verified badge values 0, 1, 99
and 100 (`hidden`, `1`, `99`, `99+`), escaped search input, plain-text HTML,
one/all read request bodies, dynamic actions, internal/external links and zero
console errors or warnings in the isolated notification acceptance session.
## Risks ## Risks
1. Full material-route approval remains in progress until the upstream HeroUI 1. Full material-route approval remains in progress until the upstream HeroUI
......
import BankOutlined from '@ant-design/icons/BankOutlined'; import BankOutlined from '@ant-design/icons/BankOutlined';
import BarChartOutlined from '@ant-design/icons/BarChartOutlined'; import BarChartOutlined from '@ant-design/icons/BarChartOutlined';
import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined'; import AppstoreOutlined from '@ant-design/icons/AppstoreOutlined';
import BellOutlined from '@ant-design/icons/BellOutlined';
import CalendarOutlined from '@ant-design/icons/CalendarOutlined'; import CalendarOutlined from '@ant-design/icons/CalendarOutlined';
import CheckOutlined from '@ant-design/icons/CheckOutlined'; import CheckOutlined from '@ant-design/icons/CheckOutlined';
import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined'; import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined';
...@@ -59,6 +60,7 @@ export const Bank = withSize(BankOutlined); ...@@ -59,6 +60,7 @@ export const Bank = withSize(BankOutlined);
export const Archive = withSize(InboxOutlined); export const Archive = withSize(InboxOutlined);
export const AppGrid = withSize(AppstoreOutlined); export const AppGrid = withSize(AppstoreOutlined);
export const BarChart = withSize(BarChartOutlined); export const BarChart = withSize(BarChartOutlined);
export const Bell = withSize(BellOutlined);
export const Calendar = withSize(CalendarOutlined); export const Calendar = withSize(CalendarOutlined);
export const Check = withSize(CheckOutlined); export const Check = withSize(CheckOutlined);
export const ChevronDown = withSize(DownOutlined); export const ChevronDown = withSize(DownOutlined);
......
export const APPLICATION_DISPLAY_NAME = '审计材料中心'; export const APPLICATION_DISPLAY_NAME = '审计材料中心';
export const APPLICATION_BASE_PATH = '/audit_material_manager'; export const APPLICATION_BASE_PATH = '/audit_material_manager';
export const ROUTE_TITLES = Object.freeze({
'/resource/stat': '工作台',
'/resource/booking': '材料归档',
'/notifications': '通知消息',
});
import authService from '@/services/authService'; import authService from '@/services/authService';
import { APPLICATION_DISPLAY_NAME } from '@/config/application'; import {
APPLICATION_BASE_PATH,
APPLICATION_DISPLAY_NAME,
} from '@/config/application';
import { getCaptcha, userLogin } from '@/services/DataService'; import { getCaptcha, userLogin } from '@/services/DataService';
import { SiteContext } from '@/wrapper/Site'; import { SiteContext } from '@/wrapper/Site';
import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react'; import { Alert, Button, Card, Input, Label, TextField } from '@heroui/react';
...@@ -17,6 +20,31 @@ function buildPassword(password, siteKey) { ...@@ -17,6 +20,31 @@ function buildPassword(password, siteKey) {
return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password; return siteKey ? CryptoJS.AES.encrypt(password, siteKey).toString() : password;
} }
function normalizeRedirect(value) {
if (!value) return '/';
try {
const target = new URL(value, window.location.origin);
if (target.origin !== window.location.origin) return '/';
const applicationBasePath = APPLICATION_BASE_PATH.replace(/\/+$/, '');
const targetPath = `${target.pathname}${target.search}${target.hash}`;
if (
applicationBasePath &&
(target.pathname === applicationBasePath ||
target.pathname.startsWith(`${applicationBasePath}/`))
) {
return targetPath.slice(applicationBasePath.length) || '/';
}
return targetPath;
} catch {
return '/';
}
}
export default function LoginPage() { export default function LoginPage() {
const site = useContext(SiteContext); const site = useContext(SiteContext);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
...@@ -28,7 +56,7 @@ export default function LoginPage() { ...@@ -28,7 +56,7 @@ export default function LoginPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const redirect = searchParams.get('redirect') || '/'; const redirect = normalizeRedirect(searchParams.get('redirect'));
const refreshCaptcha = async () => { const refreshCaptcha = async () => {
setCaptchaLoading(true); setCaptchaLoading(true);
......
...@@ -3,9 +3,14 @@ import xinyuanLogoWhite from "@/assets/xy-logo-white.png"; ...@@ -3,9 +3,14 @@ import xinyuanLogoWhite from "@/assets/xy-logo-white.png";
import { import {
APPLICATION_BASE_PATH, APPLICATION_BASE_PATH,
APPLICATION_DISPLAY_NAME, APPLICATION_DISPLAY_NAME,
ROUTE_TITLES,
} from "@/config/application"; } from "@/config/application";
import ApplicationSwitcherDialog from "@/pages/project_manager_xy/ApplicationSwitcherDialog"; import ApplicationSwitcherDialog from "@/pages/project_manager_xy/ApplicationSwitcherDialog";
import { SidebarPanel } from "@/components/AppIcons"; import { Bell, SidebarPanel } from "@/components/AppIcons";
import {
loadUnreadNotificationCount,
NOTIFICATION_STATE_EVENT,
} from "@/pages/project_manager_xy/notifications/notificationService";
import { UserContext } from "@/wrapper/Auth"; import { UserContext } from "@/wrapper/Auth";
import { SiteContext } from "@/wrapper/Site"; import { SiteContext } from "@/wrapper/Site";
import { import {
...@@ -16,7 +21,7 @@ import { ...@@ -16,7 +21,7 @@ import {
MoonOutlined, MoonOutlined,
SunOutlined, SunOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Avatar, Button, Dropdown, Label, Tooltip } from "@heroui/react"; import { Avatar, Badge, Button, Dropdown, Label, Tooltip } from "@heroui/react";
import { AppLayout } from "@heroui-pro/react/app-layout"; import { AppLayout } from "@heroui-pro/react/app-layout";
import { Navbar } from "@heroui-pro/react/navbar"; import { Navbar } from "@heroui-pro/react/navbar";
import { Sheet } from "@heroui-pro/react/sheet"; import { Sheet } from "@heroui-pro/react/sheet";
...@@ -240,12 +245,40 @@ export default function ProjectManagerLayout() { ...@@ -240,12 +245,40 @@ export default function ProjectManagerLayout() {
const [theme, setTheme] = useState(() => getPreferredTheme()); const [theme, setTheme] = useState(() => getPreferredTheme());
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false); const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [unreadNotificationCount, setUnreadNotificationCount] = useState(0);
useEffect(() => { useEffect(() => {
applyTheme(theme); applyTheme(theme);
window.localStorage?.setItem?.(THEME_STORAGE_KEY, theme); window.localStorage?.setItem?.(THEME_STORAGE_KEY, theme);
}, [theme]); }, [theme]);
useEffect(() => {
let cancelled = false;
const refreshUnreadCount = async () => {
try {
const count = await loadUnreadNotificationCount();
if (!cancelled) {
setUnreadNotificationCount(count);
}
} catch (error) {
if (!cancelled) {
setUnreadNotificationCount(0);
}
}
};
refreshUnreadCount();
window.addEventListener(NOTIFICATION_STATE_EVENT, refreshUnreadCount);
const timer = window.setInterval(refreshUnreadCount, 60000);
return () => {
cancelled = true;
window.removeEventListener(NOTIFICATION_STATE_EVENT, refreshUnreadCount);
window.clearInterval(timer);
};
}, [location.pathname]);
const appPathname = useMemo(() => { const appPathname = useMemo(() => {
if (location.pathname === APPLICATION_BASE_PATH) { if (location.pathname === APPLICATION_BASE_PATH) {
return "/"; return "/";
...@@ -259,6 +292,13 @@ export default function ProjectManagerLayout() { ...@@ -259,6 +292,13 @@ export default function ProjectManagerLayout() {
}, [location.pathname]); }, [location.pathname]);
const currentRoute = useMemo(() => { const currentRoute = useMemo(() => {
if (ROUTE_TITLES[appPathname]) {
return {
name: ROUTE_TITLES[appPathname],
path: appPathname,
};
}
for (const group of menuGroups) { for (const group of menuGroups) {
const item = group.items.find((entry) => entry.path === appPathname); const item = group.items.find((entry) => entry.path === appPathname);
if (item) { if (item) {
...@@ -269,6 +309,10 @@ export default function ProjectManagerLayout() { ...@@ -269,6 +309,10 @@ export default function ProjectManagerLayout() {
return menuGroups[0].items[0]; return menuGroups[0].items[0];
}, [appPathname]); }, [appPathname]);
useEffect(() => {
document.title = `${currentRoute.name} - ${APPLICATION_DISPLAY_NAME}`;
}, [currentRoute.name]);
const nextTheme = theme === "dark" ? "light" : "dark"; const nextTheme = theme === "dark" ? "light" : "dark";
const sidebarNavigation = ( const sidebarNavigation = (
<SidebarContent <SidebarContent
...@@ -304,6 +348,32 @@ export default function ProjectManagerLayout() { ...@@ -304,6 +348,32 @@ export default function ProjectManagerLayout() {
<h1 className="app-navbar-page-title rc-navbar-title">{currentRoute.name}</h1> <h1 className="app-navbar-page-title rc-navbar-title">{currentRoute.name}</h1>
<Navbar.Spacer /> <Navbar.Spacer />
<div className="rc-navbar-actions"> <div className="rc-navbar-actions">
<Tooltip delay={0}>
<Badge.Anchor>
<Button
isIconOnly
aria-label={`通知消息,${unreadNotificationCount}条未读`}
className="app-navbar-icon-button rc-navbar-icon-button"
size="sm"
variant="ghost"
onPress={() => history.push("/notifications")}
>
<Bell size={16} />
</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}> <Tooltip delay={0}>
<Button <Button
isIconOnly isIconOnly
......
...@@ -24,6 +24,7 @@ ...@@ -24,6 +24,7 @@
- 左侧菜单:`工作台` - 左侧菜单:`工作台`
- 左侧菜单:`材料归档` - 左侧菜单:`材料归档`
- 左侧菜单顶部:`应用切换` 入口,打开应用切换弹框 - 左侧菜单顶部:`应用切换` 入口,打开应用切换弹框
- 顶部导航栏:`通知消息` 铃铛入口,进入独立通知中心
推荐职责划分: 推荐职责划分:
...@@ -47,6 +48,13 @@ ...@@ -47,6 +48,13 @@
- 应用列表加载失败时,回退展示当前账号上的应用信息,并显示失败提示 - 应用列表加载失败时,回退展示当前账号上的应用信息,并显示失败提示
- 应用卡片与加载骨架在窄屏使用单列,从 `sm` 断点起固定使用两列,与“问题整改”应用保持一致 - 应用卡片与加载骨架在窄屏使用单列,从 `sm` 断点起固定使用两列,与“问题整改”应用保持一致
- 点击应用卡片后进入对应应用工作区 - 点击应用卡片后进入对应应用工作区
- `通知消息`
- 铃铛位于主题按钮左侧,未读为 0 时不显示角标,1-99 显示真实数值,超过 99 显示 `99+`
- 进入 `/notifications` 后可切换消息/待办、全部/未读,搜索通知内容并按固定 10 条分页
- 支持单条已读、全部已读和后端下发的动态待办操作
- 关联本应用的链接保留 `/audit_material_manager` base,进入站内路由时仅去除自身 base;其它应用或外部链接在新窗口打开
- 通知正文只按纯文本展示,不执行通知中携带的 HTML
- 侧栏不重复提供通知入口
--- ---
......
import { Bell, Close, MagnifyingGlassIcon, OpenExternal } from '@/components/AppIcons';
import { DataTablePagination } from '@/components/DataTable';
import { UserContext } from '@/wrapper/Auth';
import { EmptyState } from '@heroui-pro/react/empty-state';
import {
Alert,
Avatar,
Button,
Card,
Chip,
SearchField,
Spinner,
Tabs,
toast,
} from '@heroui/react';
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 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>
);
}
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(async () => {
await 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} 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="rc-page-toolbar">
<div className="w-full min-w-0 sm:w-80 lg:w-96">
<SearchField
aria-label="搜索通知内容"
fullWidth
value={searchText}
variant="primary"
onChange={setSearchText}
>
<SearchField.Group>
<SearchField.SearchIcon>
<MagnifyingGlassIcon size={16} />
</SearchField.SearchIcon>
<SearchField.Input placeholder="搜索通知内容" />
<SearchField.ClearButton aria-label="清空通知搜索">
<Close size={12} />
</SearchField.ClearButton>
</SearchField.Group>
</SearchField>
</div>
<Tabs
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>
<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 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 ? (
<DataTablePagination
isDisabled={loading}
itemLabel="条通知"
page={page}
pageSize={NOTIFICATION_PAGE_SIZE}
total={total}
onPageChange={setPage}
/>
) : null}
</div>
);
}
import {
exacteActionFunction,
queryUserNotice,
readUserNotice,
unReadNoticeCount,
} from '@/services/DataService';
import { APPLICATION_BASE_PATH } from '@/config/application';
export const NOTIFICATION_PAGE_SIZE = 10;
export const NOTIFICATION_STATE_EVENT = 'audit-material-center: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,
totalPages: Number(response?.total_pages ?? 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 match = content.match(/https?:\/\/[^\s<>"']+|\/[A-Za-z0-9_-]+\/[^\s<>"']+/);
return trimTarget(match?.[0]);
}
export function resolveNotificationTarget(target) {
const normalizedTarget = trimTarget(target);
if (!normalizedTarget) return null;
if (/^https?:\/\//i.test(normalizedTarget)) {
try {
const url = new URL(normalizedTarget);
if (
typeof window !== 'undefined' &&
url.origin === window.location.origin &&
url.pathname.startsWith(`${APPLICATION_BASE_PATH}/`)
) {
return resolveNotificationTarget(`${url.pathname}${url.search}${url.hash}`);
}
return { href: url.toString(), external: true };
} catch (error) {
return null;
}
}
if (!normalizedTarget.startsWith(`${APPLICATION_BASE_PATH}/`)) {
return normalizedTarget.startsWith('/')
? { href: normalizedTarget, external: true }
: null;
}
return {
href: normalizedTarget.slice(APPLICATION_BASE_PATH.length) || '/',
external: false,
};
}
export function notifyNotificationStateChanged() {
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(NOTIFICATION_STATE_EVENT));
}
}
import { extend } from 'umi-request'; import { extend } from 'umi-request';
import progressMiddleware from 'umi-request-progress'; import progressMiddleware from 'umi-request-progress';
import { APPLICATION_BASE_PATH } from '@/config/application';
import authService from '@/services/authService'; import authService from '@/services/authService';
// 使用 .umirc.ts 中定义的全局变量 // 使用 .umirc.ts 中定义的全局变量
const baseUrl = STREAMS_API_URL; const baseUrl = STREAMS_API_URL;
const routerBase = '/audit_material_manager'; const applicationBasePath = APPLICATION_BASE_PATH.replace(/\/+$/, '');
function getCurrentApplicationRoute() {
const browserPath = `${location.pathname}${location.search}${location.hash}`;
if (
applicationBasePath &&
(location.pathname === applicationBasePath ||
location.pathname.startsWith(`${applicationBasePath}/`))
) {
return browserPath.slice(applicationBasePath.length) || '/';
}
return browserPath;
}
function redirectToLogin() {
const redirect = getCurrentApplicationRoute();
if (redirect.split(/[?#]/, 1)[0] === '/login') return;
location.assign(
`${applicationBasePath}/login?redirect=${encodeURIComponent(redirect)}`,
);
}
function createRequestWithPrefix(prefix = baseUrl) { function createRequestWithPrefix(prefix = baseUrl) {
const requestInstance = extend({ const requestInstance = extend({
...@@ -40,10 +65,7 @@ function createRequestWithPrefix(prefix = baseUrl) { ...@@ -40,10 +65,7 @@ function createRequestWithPrefix(prefix = baseUrl) {
console.warn('[request] authentication required (401)'); console.warn('[request] authentication required (401)');
// 跳转到登录页(可选,根据业务需求调整) redirectToLogin();
location.href = `${routerBase}/login?redirect=${encodeURIComponent(
location.href,
)}`;
} else if (response?.status === 403) { } else if (response?.status === 403) {
authService.clearUser(); authService.clearUser();
console.warn('[request] forbidden (403)'); console.warn('[request] forbidden (403)');
......
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