diff --git a/openspec/changes/add-notification-center/design.md b/openspec/changes/add-notification-center/design.md new file mode 100644 index 0000000..4c02f39 --- /dev/null +++ b/openspec/changes/add-notification-center/design.md @@ -0,0 +1,70 @@ +## Context + +系统已经存在 `ArtNotification` 顶部通知组件,但组件中的通知、消息和待办列表都是静态数据,顶部通知按钮处于注释状态。新的通知能力需要同时服务顶部快速查看和完整通知中心,并确保用户在任一入口执行已读操作后未读数与列表状态一致。 + +## Goals / Non-Goals + +- Goals: 提供余额预警、临期提醒、审批结果和系统告警统一的站内通知入口。 +- Goals: 在顶部快速查看最近 10 条通知,并提供进入通知中心的入口。 +- Goals: 支持通知分类、类型、严重级别和已读状态筛选。 +- Goals: 将通知点击导航限制在前端认可的业务目标内,禁止任意 URL 跳转。 +- Goals: 单条已读、全部已读、抽屉和通知中心共享同一份未读状态。 +- Non-Goals: 不实现 C 端通知页面。 +- Non-Goals: 不实现推送通道或服务端通知生成规则。 +- Non-Goals: 不让前端根据通知正文推断跳转地址。 + +## Decisions + +- Decision: 通知铃铛放在顶部全局导航的设置按钮和用户头像菜单附近。 +- Rationale: 该区域已承载全局设置和用户级入口,适合放置跨页面可访问的通知入口,也符合产品指定位置。 + +- Decision: 顶部抽屉只加载最近 10 条,完整通知中心使用独立路由 `/notifications`。 +- Rationale: 顶部入口保持轻量,筛选和完整历史查询放在独立页面,避免挤占全局导航空间。 + +- Decision: 未读数使用独立接口获取,单条通知和全部已读成功后立即更新本地共享状态,并以接口返回值为最终结果。 +- Rationale: 未读数是全局状态,不能依赖当前抽屉或列表的局部数量推导。 + +- Decision: 点击通知先调用单条已读接口,再调用目标接口,根据返回的受控 route name/route params 跳转。 +- Rationale: 已读状态必须在导航前落库,目标由后端业务引用解析,前端不执行通知携带的任意 URL。 + +- Decision: 未知 `ref_type` 或目标接口无可用目标时只展示通知正文和状态,不跳转。 +- Rationale: 保证安全,同时让无法关联页面的系统通知仍然可读。 + +- Decision: `unread-summary` 为顶部抽屉分类提供数据,`notifications` 为通知中心筛选列表提供数据。 +- Rationale: 顶部快速查看与完整列表的数据量和筛选职责不同,避免顶部加载完整历史数据。 + +## Data Contract + +- Notification item: id, title, content, category, type, severity, read status, created time and optional `ref_type`/reference ID. +- Unread count: non-negative integer; frontend formats it as `0`, `1`-`99` or `99+`. +- Unread summary: category counts and recent notification items, limited to the latest 10 items for the drawer. +- Notification list: paginated items plus total count, with category, type, severity and read-state filters. +- Target response: controlled internal route information, such as route name/path and route params; no arbitrary executable URL. + +## Risks / Trade-offs + +- Risk: 后端通知字段或枚举名称与文档不一致。 +- Mitigation: 在任务阶段先确认字段契约,类型层保留稳定的可选字段和未知值占位展示。 + +- Risk: 用户在多个标签页同时读通知,单页本地未读数短暂不一致。 +- Mitigation: 操作成功后刷新未读数和当前列表;跨标签实时同步不作为本期强制目标。 + +- Risk: 目标记录已删除或用户权限发生变化。 +- Mitigation: 目标接口返回不可跳转时只展示正文,并处理权限/不存在状态,不回退到任意 URL。 + +## Migration Plan + +1. 确认通知列表、摘要、未读数和目标接口字段及枚举。 +2. 新增通知 API service、类型和共享状态。 +3. 将顶部通知按钮放入设置/头像区域,接入未读数和最近通知抽屉。 +4. 新增 `/notifications` 路由及通知中心页面。 +5. 实现筛选、单条已读、全部已读和受控目标跳转。 +6. 删除或替换 `ArtNotification` 中的静态 mock 数据,并验证四类通知展示一致。 + +## Open Questions + +- 通知中心是否需要分页参数名称 `page/page_size`,以及默认每页数量需要后端确认。 +- `category`、`type`、`severity` 和 `read_status` 的枚举值及展示名称需要后端确认。 +- 目标接口返回 route name、path 还是 route key + params,需要与路由菜单契约确认。 +- 未读数是否需要页面进入时定时刷新或仅在打开抽屉/完成操作时刷新,本期默认按接口调用时机刷新。 +- 通知中心菜单权限和按钮权限编码需要后端权限表确认。 diff --git a/openspec/changes/add-notification-center/proposal.md b/openspec/changes/add-notification-center/proposal.md new file mode 100644 index 0000000..0d80157 --- /dev/null +++ b/openspec/changes/add-notification-center/proposal.md @@ -0,0 +1,46 @@ +# Change: 顶部通知铃铛与站内通知中心 + +## Why + +当前顶部通知组件仍使用硬编码 mock 数据,通知按钮也未启用,运营人员无法统一查看余额预警、临期提醒、审批结果和系统告警。需要建立真实的站内通知数据链路,并在全局导航和通知中心之间保持未读状态同步。 + +## What Changes + +- 在顶部全局导航的设置按钮与用户头像入口区域增加通知铃铛。 +- 铃铛展示未读数量,统一格式为 `0`、`1`~`99` 或 `99+`。 +- 点击铃铛展示最近 10 条通知抽屉,支持全部、审批、临期、同步/系统分类。 +- 抽屉提供进入 `/notifications` 通知中心的入口。 +- 新增通知中心页面,支持分类、通知类型、严重级别、已读状态筛选和全部已读。 +- 点击通知时先调用标记已读接口,再根据受控 `ref_type` 获取目标并跳转;未知目标只展示通知正文,不执行任意 URL 跳转。 +- 接入未读数、未读摘要、通知列表、单条已读和全部已读接口。 +- 替换现有 `ArtNotification` 硬编码 mock 数据实现,并复用现有顶部布局和设置/头像区域的视觉位置。 + +## Impact + +- Affected specs: + - `notification-center` +- Affected code: + - `src/components/core/layouts/art-header-bar/index.vue` + - `src/components/core/layouts/art-notification/index.vue` + - `src/components/core/layouts/art-notification/style.scss` + - `src/views/notifications/index.vue` + - `src/api/modules/notification.ts` + - `src/types/api/notification.ts` + - 路由、菜单和通知相关权限配置 +- API contracts: + - `GET /api/admin/notifications/unread-count` + - `GET /api/admin/notifications/unread-summary` + - `GET /api/admin/notifications` + - `PUT /api/admin/notifications/{id}/read` + - `PUT /api/admin/notifications/read-all` + - `GET /api/admin/notifications/{id}/target` +- Dependencies: + - 后端按当前用户返回通知数据、未读统计和受控跳转目标。 + - 后端明确通知分类、类型、严重级别、已读状态和 `ref_type` 枚举。 + - 目标接口不得返回可被前端直接执行的任意外部 URL。 +- Out of scope: + - C 端 `/api/c/v1/notifications` 接口和页面。 + - 前端创建、编辑或删除通知。 + - 浏览器推送、WebSocket 或轮询之外的实时推送机制。 +- Breaking changes: + - 现有 `ArtNotification` mock 通知数据和无效的空操作按钮将被真实通知数据及接口行为替换。 diff --git a/openspec/changes/add-notification-center/specs/notification-center/spec.md b/openspec/changes/add-notification-center/specs/notification-center/spec.md new file mode 100644 index 0000000..6cd8d5a --- /dev/null +++ b/openspec/changes/add-notification-center/specs/notification-center/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Global Notification Bell + +The admin frontend SHALL provide a global notification bell in the top navigation near the settings and user avatar entries. + +#### Scenario: Display unread count + +- **GIVEN** 用户已登录后台 +- **WHEN** 顶部导航加载未读通知数量 +- **THEN** 铃铛 MUST call `GET /api/admin/notifications/unread-count` +- **AND** 数量 MUST display as `0`, `1` through `99`, or `99+` + +#### Scenario: Open recent notification drawer + +- **WHEN** 用户点击顶部通知铃铛 +- **THEN** 页面 MUST display the latest 10 notifications from the unread summary contract +- **AND** 抽屉 MUST provide 全部、审批、临期、同步/系统分类 +- **AND** 抽屉 MUST provide an entry to `/notifications` + +### Requirement: Notification Center Filtering + +The admin frontend SHALL provide a `/notifications` notification center with server-backed filtering. + +#### Scenario: Filter notifications + +- **WHEN** 用户打开通知中心或调整筛选条件 +- **THEN** 页面 MUST support category, notification type, severity and read-state filters +- **AND** 页面 MUST load results from `GET /api/admin/notifications` +- **AND** 页面 MUST display notification title, content, category, severity, read state and created time + +#### Scenario: Mark all notifications read + +- **WHEN** 用户点击全部已读 +- **THEN** 页面 MUST call `PUT /api/admin/notifications/read-all` +- **AND** 页面 MUST refresh the unread count and visible notification read states after success + +### Requirement: Notification Read State Synchronization + +The notification drawer and notification center SHALL keep read state and unread count synchronized with the backend. + +#### Scenario: Mark a notification read + +- **WHEN** 用户点击一条未读通知 +- **THEN** 页面 MUST call `PUT /api/admin/notifications/{id}/read` before navigation +- **AND** 页面 MUST refresh or update the shared unread count after the read request succeeds + +#### Scenario: Preserve read state after refresh + +- **GIVEN** 用户已完成单条已读或全部已读操作 +- **WHEN** 用户关闭抽屉、切换页面或刷新通知中心 +- **THEN** 页面 MUST use the latest backend response rather than restoring stale local unread state + +### Requirement: Controlled Notification Navigation + +The admin frontend SHALL navigate from notifications only through controlled internal targets. + +#### Scenario: Navigate to a valid notification target + +- **GIVEN** 通知包含受支持的 `ref_type` 或业务引用 +- **WHEN** 单条已读成功后页面请求 `GET /api/admin/notifications/{id}/target` +- **THEN** 页面 MUST navigate only to a target recognized by the frontend route table +- **AND** 页面 MUST preserve the target route parameters returned by the backend + +#### Scenario: Handle an unknown target safely + +- **GIVEN** 通知的 `ref_type` 未知、目标已删除或目标无权限 +- **WHEN** 页面处理通知点击 +- **THEN** 页面 MUST not navigate to an arbitrary URL +- **AND** 页面 MUST still display the notification content and a stable unavailable-target state + +### Requirement: Consistent Notification Categories + +The notification drawer and notification center SHALL present balance warnings, expiry reminders, approval results and system alerts through the same notification data contract. + +#### Scenario: Display supported notification types + +- **GIVEN** 后端返回余额预警、临期提醒、审批结果或系统告警 +- **WHEN** 用户查看抽屉或通知中心 +- **THEN** 页面 MUST display each item using its backend category, type and severity +- **AND** 页面 MUST not replace the item with hardcoded mock notification content diff --git a/openspec/changes/add-notification-center/tasks.md b/openspec/changes/add-notification-center/tasks.md new file mode 100644 index 0000000..d49d4fe --- /dev/null +++ b/openspec/changes/add-notification-center/tasks.md @@ -0,0 +1,39 @@ +## 1. Contract And Types + +- [x] 1.1 按产品文档落地通知项、未读统计、未读摘要、分页列表和受控目标响应字段。 +- [x] 1.2 按产品文档落地分类、通知类型、严重级别、已读状态和 `ref_type` 兼容类型。 +- [x] 1.3 新增通知中心菜单权限 `notifications:view`、单条已读权限 `notifications:read`、全部已读权限 `notifications:read_all`。 +- [x] 1.4 新增 `notification.ts` API service,覆盖未读数、摘要、列表、单条已读、全部已读和目标接口。 +- [x] 1.5 新增通知实体、筛选参数、分页响应和目标响应类型。 + +## 2. Global Header And Drawer + +- [x] 2.1 在顶部设置按钮与用户头像入口区域增加通知铃铛。 +- [x] 2.2 实现未读数量格式化为 `0`、`1`~`99` 或 `99+`。 +- [x] 2.3 顶部抽屉加载最近 10 条通知,并按全部、审批、临期、同步/系统分类展示。 +- [x] 2.4 替换 `ArtNotification` 静态 mock 数据,接入真实摘要接口。 +- [x] 2.5 提供进入 `/notifications` 通知中心的入口,并保证桌面端和移动端布局可用。 + +## 3. Notification Center + +- [x] 3.1 新增 `/notifications` 路由和通知中心页面。 +- [x] 3.2 支持分类、通知类型、严重级别和已读状态筛选。 +- [x] 3.3 展示通知标题、正文、严重级别、分类、时间和已读状态。 +- [x] 3.4 实现全部已读,并在成功后同步未读数和列表状态。 +- [x] 3.5 处理空列表、加载失败、权限失败和未知枚举值的稳定展示。 + +## 4. Read And Controlled Navigation + +- [x] 4.1 点击通知时先调用 `PUT /api/admin/notifications/{id}/read`。 +- [x] 4.2 已读成功后调用 `GET /api/admin/notifications/{id}/target` 获取受控目标。 +- [x] 4.3 仅允许跳转到后端返回且前端路由表认可的内部目标。 +- [x] 4.4 未知 `ref_type`、目标不存在或无权限时只展示正文,不执行任意 URL 跳转。 +- [x] 4.5 抽屉和通知中心的已读操作后刷新共享未读数。 + +## 5. Verification + +- [x] 5.1 验证余额、临期、审批和系统通知在抽屉及通知中心展示一致。 +- [x] 5.2 验证未读数在单条已读、全部已读、抽屉关闭和页面刷新后与接口结果一致。 +- [x] 5.3 验证通知点击不会跳转到任意外部或未注册 URL。 +- [x] 5.4 验证未知目标通知仍可查看正文且不会阻塞其他通知操作。 +- [x] 5.5 运行类型检查、lint、相关测试和构建。 diff --git a/src/api/modules/index.ts b/src/api/modules/index.ts index b58e166..fb05044 100644 --- a/src/api/modules/index.ts +++ b/src/api/modules/index.ts @@ -35,6 +35,7 @@ export { SuperAdminService } from './superAdmin' export { ExportTaskService } from './exportTask' export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask' export { BulkPurchaseService } from './bulkPurchase' +export { NotificationService } from './notification' // TODO: 按需添加其他业务模块 // export { SettingService } from './setting' diff --git a/src/api/modules/notification.ts b/src/api/modules/notification.ts new file mode 100644 index 0000000..4f95965 --- /dev/null +++ b/src/api/modules/notification.ts @@ -0,0 +1,41 @@ +import { BaseService } from '../BaseService' +import type { + BaseResponse, + NotificationItem, + NotificationListResponse, + NotificationQueryParams, + NotificationSummary, + NotificationTarget +} from '@/types/api' + +export class NotificationService extends BaseService { + static getUnreadCount(): Promise< + BaseResponse + > { + return this.get>( + '/api/admin/notifications/unread-count' + ) + } + + static getUnreadSummary(): Promise> { + return this.get>('/api/admin/notifications/unread-summary') + } + + static getNotifications( + params?: NotificationQueryParams + ): Promise> { + return this.get>('/api/admin/notifications', params) + } + + static markRead(id: number | string): Promise> { + return this.put>(`/api/admin/notifications/${id}/read`, {}) + } + + static markAllRead(): Promise { + return this.put('/api/admin/notifications/read-all', {}) + } + + static getTarget(id: number | string): Promise> { + return this.get>(`/api/admin/notifications/${id}/target`) + } +} diff --git a/src/components/core/layouts/art-header-bar/index.vue b/src/components/core/layouts/art-header-bar/index.vue index a413676..ce9059f 100644 --- a/src/components/core/layouts/art-header-bar/index.vue +++ b/src/components/core/layouts/art-header-bar/index.vue @@ -65,12 +65,16 @@ - - - - - - +
+
+ + {{ formattedUnreadCount }} +
+
@@ -175,7 +179,7 @@ - + @@ -189,12 +193,17 @@ import { useI18n } from 'vue-i18n' import { mittBus } from '@/utils/sys' import { useMenuStore } from '@/store/modules/menu' + import { useNotificationStore } from '@/store/modules/notification' + import { useAuth } from '@/composables/useAuth' + import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification' import AppConfig from '@/config' const isWindows = navigator.userAgent.includes('Windows') const { locale } = useI18n() const settingStore = useSettingStore() const userStore = useUserStore() + const notificationStore = useNotificationStore() + const { hasAuth } = useAuth() const router = useRouter() const { @@ -214,7 +223,6 @@ const { menuList } = storeToRefs(useMenuStore()) const showNotice = ref(false) - const notice = ref(null) const userMenuPopover = ref() const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT) @@ -229,6 +237,11 @@ const { width } = useWindowSize() + const unreadCount = computed(() => notificationStore.unreadCount) + const formattedUnreadCount = computed(() => + unreadCount.value > 99 ? '99+' : String(unreadCount.value) + ) + const menuTopWidth = computed(() => { return width.value * 0.5 }) @@ -251,11 +264,9 @@ onMounted(() => { initLanguage() - document.addEventListener('click', bodyCloseNotice) - }) - - onUnmounted(() => { - document.removeEventListener('click', bodyCloseNotice) + if (hasAuth(NOTIFICATION_PERMISSIONS.page)) { + void notificationStore.refreshUnreadCount().catch(() => undefined) + } }) const { isFullscreen, toggle: toggleFullscreen } = useFullscreen() @@ -334,24 +345,6 @@ mittBus.emit('openSearchDialog') } - const bodyCloseNotice = (e: any) => { - let { className } = e.target - - if (showNotice.value) { - if (typeof className === 'object') { - showNotice.value = false - return - } - if (className.indexOf('notice-btn') === -1) { - showNotice.value = false - } - } - } - - const openChat = () => { - mittBus.emit('openChat') - } - const lockScreen = () => { mittBus.emit('openLockScreen') } diff --git a/src/components/core/layouts/art-header-bar/style.scss b/src/components/core/layouts/art-header-bar/style.scss index 5febcf3..fb75068 100644 --- a/src/components/core/layouts/art-header-bar/style.scss +++ b/src/components/core/layouts/art-header-bar/style.scss @@ -200,6 +200,10 @@ } } + &.notice-button { + position: relative; + } + &.chat-button:hover { i { animation: shake 0.5s ease-in-out; @@ -411,6 +415,21 @@ } } } + + .notice-count { + position: absolute; + top: 2px; + right: 0; + min-width: 16px; + height: 16px; + padding: 0 4px; + font-size: 10px; + line-height: 16px; + color: #fff; + text-align: center; + background: var(--el-color-danger); + border-radius: 10px; + } } @keyframes rotate180 { diff --git a/src/components/core/layouts/art-notification/index.vue b/src/components/core/layouts/art-notification/index.vue index c662c5f..78d43fc 100644 --- a/src/components/core/layouts/art-notification/index.vue +++ b/src/components/core/layouts/art-notification/index.vue @@ -1,276 +1,168 @@