feat: 顶部通知铃铛与站内通知中心
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m7s
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m7s
This commit is contained in:
70
openspec/changes/add-notification-center/design.md
Normal file
70
openspec/changes/add-notification-center/design.md
Normal file
@@ -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,需要与路由菜单契约确认。
|
||||
- 未读数是否需要页面进入时定时刷新或仅在打开抽屉/完成操作时刷新,本期默认按接口调用时机刷新。
|
||||
- 通知中心菜单权限和按钮权限编码需要后端权限表确认。
|
||||
46
openspec/changes/add-notification-center/proposal.md
Normal file
46
openspec/changes/add-notification-center/proposal.md
Normal file
@@ -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 通知数据和无效的空操作按钮将被真实通知数据及接口行为替换。
|
||||
@@ -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
|
||||
39
openspec/changes/add-notification-center/tasks.md
Normal file
39
openspec/changes/add-notification-center/tasks.md
Normal file
@@ -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、相关测试和构建。
|
||||
@@ -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'
|
||||
|
||||
41
src/api/modules/notification.ts
Normal file
41
src/api/modules/notification.ts
Normal file
@@ -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<number | { count?: number; unread_count?: number }>
|
||||
> {
|
||||
return this.get<BaseResponse<number | { count?: number; unread_count?: number }>>(
|
||||
'/api/admin/notifications/unread-count'
|
||||
)
|
||||
}
|
||||
|
||||
static getUnreadSummary(): Promise<BaseResponse<NotificationSummary>> {
|
||||
return this.get<BaseResponse<NotificationSummary>>('/api/admin/notifications/unread-summary')
|
||||
}
|
||||
|
||||
static getNotifications(
|
||||
params?: NotificationQueryParams
|
||||
): Promise<BaseResponse<NotificationListResponse>> {
|
||||
return this.get<BaseResponse<NotificationListResponse>>('/api/admin/notifications', params)
|
||||
}
|
||||
|
||||
static markRead(id: number | string): Promise<BaseResponse<NotificationItem>> {
|
||||
return this.put<BaseResponse<NotificationItem>>(`/api/admin/notifications/${id}/read`, {})
|
||||
}
|
||||
|
||||
static markAllRead(): Promise<BaseResponse> {
|
||||
return this.put<BaseResponse>('/api/admin/notifications/read-all', {})
|
||||
}
|
||||
|
||||
static getTarget(id: number | string): Promise<BaseResponse<NotificationTarget>> {
|
||||
return this.get<BaseResponse<NotificationTarget>>(`/api/admin/notifications/${id}/target`)
|
||||
}
|
||||
}
|
||||
@@ -65,12 +65,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- 通知 -->
|
||||
<!--<div class="btn-box notice-btn" @click="visibleNotice">-->
|
||||
<!-- <div class="btn notice-button">-->
|
||||
<!-- <i class="iconfont-sys notice-btn"></i>-->
|
||||
<!-- <span class="count notice-btn"></span>-->
|
||||
<!-- </div>-->
|
||||
<!--</div>-->
|
||||
<div
|
||||
v-if="hasAuth(NOTIFICATION_PERMISSIONS.page)"
|
||||
class="btn-box notice-btn"
|
||||
@click.stop="showNotice = !showNotice"
|
||||
>
|
||||
<div class="btn notice-button">
|
||||
<i class="iconfont-sys"></i>
|
||||
<span v-if="unreadCount > 0" class="notice-count">{{ formattedUnreadCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 聊天 -->
|
||||
<!--<div class="btn-box chat-btn" @click="openChat">-->
|
||||
<!-- <div class="btn chat-button">-->
|
||||
@@ -175,7 +179,7 @@
|
||||
</div>
|
||||
<ArtWorkTab />
|
||||
|
||||
<art-notification v-model:value="showNotice" ref="notice" />
|
||||
<art-notification v-model="showNotice" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,276 +1,168 @@
|
||||
<template>
|
||||
<div
|
||||
class="notice"
|
||||
v-show="visible"
|
||||
:style="{
|
||||
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
|
||||
opacity: show ? 1 : 0
|
||||
}"
|
||||
@click.stop=""
|
||||
>
|
||||
<div class="header">
|
||||
<span class="text">{{ $t('notice.title') }}</span>
|
||||
<span class="btn">{{ $t('notice.btnRead') }}</span>
|
||||
</div>
|
||||
<ul class="bar">
|
||||
<li
|
||||
v-for="(item, index) in barList"
|
||||
:key="index"
|
||||
:class="{ active: barActiveIndex === index }"
|
||||
@click="changeBar(index)"
|
||||
>
|
||||
{{ item.name }} ({{ item.num }})
|
||||
</li>
|
||||
</ul>
|
||||
<div class="content">
|
||||
<div class="scroll">
|
||||
<!-- 通知 -->
|
||||
<ul class="notice-list" v-show="barActiveIndex === 0">
|
||||
<li v-for="(item, index) in noticeList" :key="index">
|
||||
<div
|
||||
class="icon"
|
||||
:style="{ background: getNoticeStyle(item.type).backgroundColor + '!important' }"
|
||||
>
|
||||
<i
|
||||
class="iconfont-sys"
|
||||
:style="{ color: getNoticeStyle(item.type).iconColor + '!important' }"
|
||||
v-html="getNoticeStyle(item.type).icon"
|
||||
>
|
||||
</i>
|
||||
</div>
|
||||
<div class="text">
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p>{{ item.time }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 消息 -->
|
||||
<ul class="user-list" v-show="barActiveIndex === 1">
|
||||
<li v-for="(item, index) in msgList" :key="index">
|
||||
<div class="avatar">
|
||||
<span>{{ item.title.slice(0, 1) }}</span>
|
||||
</div>
|
||||
<div class="text">
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p>{{ item.time }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 待办 -->
|
||||
<ul class="base" v-show="barActiveIndex === 3">
|
||||
<li v-for="(item, index) in pendingList" :key="index">
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p>{{ item.time }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="visible" class="notice-overlay" @click="close">
|
||||
<div class="notice" @click.stop>
|
||||
<div class="header">
|
||||
<span class="text">通知</span>
|
||||
<button
|
||||
v-if="hasAuth(NOTIFICATION_PERMISSIONS.readAll)"
|
||||
class="read-all"
|
||||
type="button"
|
||||
@click="handleMarkAllRead"
|
||||
>
|
||||
全部已读
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="empty-tips" v-show="barActiveIndex === 0 && noticeList.length === 0">
|
||||
<i class="iconfont-sys"></i>
|
||||
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
|
||||
<div class="bar">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
:class="{ active: activeCategory === tab.value }"
|
||||
@click="activeCategory = tab.value"
|
||||
>
|
||||
{{ tab.label }}<span v-if="tab.count"> ({{ tab.count }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div v-loading="notificationStore.loading" class="scroll">
|
||||
<button
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
:class="['notification-item', { unread: !isRead(item) }]"
|
||||
@click="handleNotificationClick(item)"
|
||||
>
|
||||
<span :class="['icon', `severity-${item.severity || 'info'}`]">
|
||||
<i class="iconfont-sys"></i>
|
||||
</span>
|
||||
<span class="text">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<small>{{ item.content }}</small>
|
||||
<time>{{ item.created_at || '-' }}</time>
|
||||
</span>
|
||||
<span v-if="!isRead(item)" class="unread-dot" />
|
||||
</button>
|
||||
|
||||
<div v-if="!filteredItems.length" class="empty-tips">
|
||||
<i class="iconfont-sys"></i>
|
||||
<p>暂无通知</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
|
||||
<i class="iconfont-sys"></i>
|
||||
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
|
||||
</div>
|
||||
<div class="empty-tips" v-show="barActiveIndex === 2 && pendingList.length === 0">
|
||||
<i class="iconfont-sys"></i>
|
||||
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
|
||||
<div class="btn-wrapper">
|
||||
<ElButton class="view-all" @click="handleViewAll">进入通知中心</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-wrapper">
|
||||
<el-button class="view-all" @click="handleViewAll" v-ripple>
|
||||
{{ $t('notice.viewAll') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 100px"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { NotificationService } from '@/api/modules'
|
||||
import type { NotificationCategory, NotificationItem } from '@/types/api'
|
||||
import { useNotificationStore } from '@/store/modules/notification'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification'
|
||||
import { navigateNotificationTarget } from '@/utils/business/notificationNavigation'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
const router = useRouter()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { hasAuth } = useAuth()
|
||||
const activeCategory = ref<'all' | NotificationCategory>('all')
|
||||
const visible = computed(() => props.modelValue)
|
||||
|
||||
const categoryMatches = (item: NotificationItem, category: string) => {
|
||||
if (category === 'all') return true
|
||||
if (category === 'system') return item.category === 'sync' || item.category === 'system'
|
||||
if (category === 'expiry') return item.category === 'expiry' || item.category === 'expiring'
|
||||
return item.category === category
|
||||
}
|
||||
|
||||
const isRead = (item: NotificationItem) =>
|
||||
item.read === true || item.is_read === true || item.read_status === 'read'
|
||||
|
||||
const filteredItems = computed(() =>
|
||||
notificationStore.summary.items.filter((item) => categoryMatches(item, activeCategory.value))
|
||||
)
|
||||
|
||||
const tabs = computed(() => {
|
||||
const categories = notificationStore.summary.categories || {}
|
||||
return [
|
||||
{ value: 'all', label: '全部', count: notificationStore.unreadCount },
|
||||
{ value: 'approval', label: '审批', count: categories.approval || 0 },
|
||||
{ value: 'expiry', label: '临期', count: categories.expiry || categories.expiring || 0 },
|
||||
{
|
||||
value: 'system',
|
||||
label: '同步/系统',
|
||||
count: (categories.sync || 0) + (categories.system || 0)
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const close = () => emit('update:modelValue', false)
|
||||
|
||||
const handleViewAll = () => {
|
||||
close()
|
||||
void router.push('/notifications')
|
||||
}
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
try {
|
||||
const response = await notificationStore.markAllRead()
|
||||
if (response.code !== 0) ElMessage.error(response.msg || '全部已读失败')
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '全部已读失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (item: NotificationItem) => {
|
||||
if (!hasAuth(NOTIFICATION_PERMISSIONS.read)) {
|
||||
ElMessage.warning('您没有标记通知已读的权限')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const readResponse = await notificationStore.markRead(item.id)
|
||||
if (readResponse.code !== 0) {
|
||||
ElMessage.error(readResponse.msg || '标记已读失败')
|
||||
return
|
||||
}
|
||||
const targetResponse = await NotificationService.getTarget(item.id)
|
||||
if (targetResponse.code !== 0 || !targetResponse.data) {
|
||||
ElMessage.info(item.content || '该通知暂无可跳转目标')
|
||||
return
|
||||
}
|
||||
if (!navigateNotificationTarget(router, targetResponse.data)) {
|
||||
ElMessage.info(targetResponse.data.message || item.content || '该通知暂无可跳转目标')
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '处理通知失败')
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
() => {
|
||||
showNotice(props.value)
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
activeCategory.value = 'all'
|
||||
void notificationStore.refreshSummary().catch((error) => {
|
||||
ElMessage.error(error?.message || '获取通知失败')
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const show = ref(false)
|
||||
const visible = ref(false)
|
||||
const barActiveIndex = ref(0)
|
||||
const pendingList: any = []
|
||||
|
||||
const barList = ref([
|
||||
{
|
||||
name: computed(() => t('notice.bar[0]')),
|
||||
num: 1
|
||||
},
|
||||
{
|
||||
name: computed(() => t('notice.bar[1]')),
|
||||
num: 1
|
||||
},
|
||||
{
|
||||
name: computed(() => t('notice.bar[2]')),
|
||||
num: 0
|
||||
}
|
||||
])
|
||||
|
||||
const noticeList = [
|
||||
{
|
||||
title: '新增国际化',
|
||||
time: '2024-6-13 0:10',
|
||||
type: 'notice'
|
||||
},
|
||||
{
|
||||
title: '冷月呆呆给你发了一条消息',
|
||||
time: '2024-4-21 8:05',
|
||||
type: 'message'
|
||||
},
|
||||
{
|
||||
title: '小肥猪关注了你',
|
||||
time: '2020-3-17 21:12',
|
||||
type: 'collection'
|
||||
},
|
||||
{
|
||||
title: '新增使用文档',
|
||||
time: '2024-02-14 0:20',
|
||||
type: 'notice'
|
||||
},
|
||||
{
|
||||
title: '小肥猪给你发了一封邮件',
|
||||
time: '2024-1-20 0:15',
|
||||
type: 'email'
|
||||
},
|
||||
{
|
||||
title: '菜单mock本地真实数据',
|
||||
time: '2024-1-17 22:06',
|
||||
type: 'notice'
|
||||
}
|
||||
]
|
||||
const msgList: any = [
|
||||
{
|
||||
title: '池不胖 关注了你',
|
||||
time: '2021-2-26 23:50'
|
||||
},
|
||||
{
|
||||
title: '唐不苦 关注了你',
|
||||
time: '2021-2-21 8:05'
|
||||
},
|
||||
{
|
||||
title: '中小鱼 关注了你',
|
||||
time: '2020-1-17 21:12'
|
||||
},
|
||||
{
|
||||
title: '何小荷 关注了你',
|
||||
time: '2021-01-14 0:20'
|
||||
},
|
||||
{
|
||||
title: '誶誶淰 关注了你',
|
||||
time: '2020-12-20 0:15'
|
||||
},
|
||||
{
|
||||
title: '冷月呆呆 关注了你',
|
||||
time: '2020-12-17 22:06'
|
||||
}
|
||||
]
|
||||
|
||||
const changeBar = (index: number) => {
|
||||
barActiveIndex.value = index
|
||||
}
|
||||
|
||||
const getRandomColor = () => {
|
||||
const index = Math.floor(Math.random() * AppConfig.systemMainColor.length)
|
||||
return AppConfig.systemMainColor[index]
|
||||
}
|
||||
|
||||
const noticeStyleMap = {
|
||||
email: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-warning))',
|
||||
backgroundColor: 'rgb(var(--art-bg-warning))'
|
||||
},
|
||||
message: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-success))',
|
||||
backgroundColor: 'rgb(var(--art-bg-success))'
|
||||
},
|
||||
collection: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-danger))',
|
||||
backgroundColor: 'rgb(var(--art-bg-danger))'
|
||||
},
|
||||
user: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-info))',
|
||||
backgroundColor: 'rgb(var(--art-bg-info))'
|
||||
},
|
||||
notice: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-primary))',
|
||||
backgroundColor: 'rgb(var(--art-bg-primary))'
|
||||
}
|
||||
}
|
||||
|
||||
const getNoticeStyle = (type: string) => {
|
||||
const defaultStyle = {
|
||||
icon: '',
|
||||
iconColor: '#FFFFFF',
|
||||
backgroundColor: getRandomColor()
|
||||
}
|
||||
|
||||
const style = noticeStyleMap[type as keyof typeof noticeStyleMap] || defaultStyle
|
||||
|
||||
return {
|
||||
...style,
|
||||
backgroundColor: style.backgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
const showNotice = (open: boolean) => {
|
||||
if (open) {
|
||||
visible.value = open
|
||||
setTimeout(() => {
|
||||
show.value = open
|
||||
}, 5)
|
||||
} else {
|
||||
show.value = open
|
||||
setTimeout(() => {
|
||||
visible.value = open
|
||||
}, 350)
|
||||
}
|
||||
}
|
||||
|
||||
// 查看全部
|
||||
const handleViewAll = () => {
|
||||
switch (barActiveIndex.value) {
|
||||
case 0:
|
||||
handleNoticeAll()
|
||||
break
|
||||
case 1:
|
||||
handleMsgAll()
|
||||
break
|
||||
case 2:
|
||||
handlePendingAll()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoticeAll = () => {}
|
||||
const handleMsgAll = () => {}
|
||||
const handlePendingAll = () => {}
|
||||
onMounted(() => {
|
||||
void notificationStore.refreshUnreadCount().catch(() => undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,262 +1,179 @@
|
||||
@use '@styles/variables.scss' as *;
|
||||
@use '@styles/mixin.scss' as *;
|
||||
|
||||
.notice-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.notice {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
right: 20px;
|
||||
width: 360px;
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
right: 18px;
|
||||
width: 390px;
|
||||
max-width: calc(100vw - 24px);
|
||||
background: var(--art-main-bg-color);
|
||||
border: 1px solid var(--art-border-color);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 6px) !important;
|
||||
box-shadow:
|
||||
0 8px 26px -4px hsl(0deg 0% 8% / 15%),
|
||||
0 8px 9px -5px hsl(0deg 0% 8% / 6%);
|
||||
transition: all 0.2s;
|
||||
transform-origin: center top 0;
|
||||
will-change: top, left;
|
||||
border-radius: calc(var(--custom-radius) / 2 + 6px);
|
||||
box-shadow: 0 8px 26px -4px hsl(0deg 0% 8% / 15%);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 15px;
|
||||
margin-top: 15px;
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--art-gray-800);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
}
|
||||
.text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
padding: 0 15px;
|
||||
line-height: 50px;
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
|
||||
li {
|
||||
height: 48px;
|
||||
margin-right: 20px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: var(--art-gray-700);
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
|
||||
@include userSelect;
|
||||
|
||||
&:last-of-type {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--main-color) !important;
|
||||
border-bottom: 2px solid var(--main-color);
|
||||
}
|
||||
}
|
||||
.read-all {
|
||||
padding: 4px 8px;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: calc(100% - 95px);
|
||||
.bar {
|
||||
display: flex;
|
||||
padding: 0 12px;
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--art-border-color);
|
||||
|
||||
.scroll {
|
||||
height: calc(100% - 60px);
|
||||
overflow-y: scroll;
|
||||
button {
|
||||
flex: 0 0 auto;
|
||||
padding: 13px 8px 11px;
|
||||
margin-right: 12px;
|
||||
color: var(--art-gray-700);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 5px !important;
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
li {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-gray-100);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
text-align: center;
|
||||
border-radius: 8px;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
width: calc(100% - 45px);
|
||||
margin-left: 15px;
|
||||
|
||||
h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-list {
|
||||
li {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-gray-100);
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
width: calc(100% - 45px);
|
||||
margin-left: 15px;
|
||||
|
||||
h4 {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 22px;
|
||||
color: var(--art-gray-900);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.base {
|
||||
li {
|
||||
box-sizing: border-box;
|
||||
padding: 15px 20px;
|
||||
|
||||
&:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
color: var(--art-gray-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tips {
|
||||
position: relative;
|
||||
top: 100px;
|
||||
height: 100%;
|
||||
color: var(--art-gray-500);
|
||||
text-align: center;
|
||||
background: transparent !important;
|
||||
|
||||
i {
|
||||
font-size: 60px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 15px;
|
||||
font-size: 12px;
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btn-wrapper {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0 15px;
|
||||
|
||||
.view-all {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
&.active {
|
||||
color: var(--el-color-primary);
|
||||
border-bottom-color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.notice {
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--art-main-bg-color);
|
||||
.content {
|
||||
.scroll {
|
||||
height: 390px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: #222 !important;
|
||||
&.unread {
|
||||
background: color-mix(in srgb, var(--el-color-primary) 5%, transparent);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--el-color-info);
|
||||
background: var(--el-color-info-light-9);
|
||||
border-radius: 8px;
|
||||
|
||||
&.severity-warning {
|
||||
color: var(--el-color-warning);
|
||||
background: var(--el-color-warning-light-9);
|
||||
}
|
||||
|
||||
&.severity-error {
|
||||
color: var(--el-color-danger);
|
||||
background: var(--el-color-danger-light-9);
|
||||
}
|
||||
|
||||
&.severity-success {
|
||||
color: var(--el-color-success);
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
|
||||
strong,
|
||||
small,
|
||||
time {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
small,
|
||||
time {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.unread-dot {
|
||||
flex: 0 0 auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-top: 5px;
|
||||
background: var(--el-color-danger);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-tips {
|
||||
padding: 70px 20px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
|
||||
i {
|
||||
font-size: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-wrapper {
|
||||
padding: 12px 16px 16px;
|
||||
border-top: 1px solid var(--art-border-color);
|
||||
|
||||
.view-all {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $device-phone) {
|
||||
.notice {
|
||||
top: 65px;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
top: 62px;
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
5
src/config/constants/notification.ts
Normal file
5
src/config/constants/notification.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const NOTIFICATION_PERMISSIONS = {
|
||||
page: 'notifications:view',
|
||||
read: 'notifications:read',
|
||||
readAll: 'notifications:read_all'
|
||||
} as const
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RoutesAlias } from '../routesAlias'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
|
||||
import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification'
|
||||
|
||||
/**
|
||||
* 菜单列表、异步路由
|
||||
@@ -13,6 +14,28 @@ import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
|
||||
* 可以是 i18n 的 key,也可以是字符串,比如:'用户列表'
|
||||
*/
|
||||
export const asyncRoutes: AppRouteRecord[] = [
|
||||
// 站内通知
|
||||
{
|
||||
path: '/notifications',
|
||||
name: 'Notifications',
|
||||
component: RoutesAlias.Home,
|
||||
meta: {
|
||||
title: '通知中心'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'NotificationCenter',
|
||||
component: RoutesAlias.Notifications,
|
||||
meta: {
|
||||
title: '通知中心',
|
||||
permissions: [NOTIFICATION_PERMISSIONS.page],
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 仪表台
|
||||
{
|
||||
name: 'Dashboard',
|
||||
|
||||
@@ -76,6 +76,9 @@ export enum RoutesAlias {
|
||||
RefundManagement = '/finance/refund', // 退款管理
|
||||
RefundDetail = '/finance/refund/detail', // 退款详情
|
||||
|
||||
// 站内通知
|
||||
Notifications = '/notifications', // 通知中心
|
||||
|
||||
// 佣金管理
|
||||
WithdrawalApproval = '/commission-management/withdrawal-approval', // 提现审批
|
||||
MyCommission = '/commission-management/my-commission', // 我的佣金
|
||||
|
||||
90
src/store/modules/notification.ts
Normal file
90
src/store/modules/notification.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { NotificationService } from '@/api/modules/notification'
|
||||
import type { NotificationSummary } from '@/types/api'
|
||||
|
||||
const getCountValue = (data: number | { count?: number; unread_count?: number } | undefined) => {
|
||||
if (typeof data === 'number') return Math.max(0, data)
|
||||
return Math.max(0, data?.unread_count ?? data?.count ?? 0)
|
||||
}
|
||||
|
||||
export const useNotificationStore = defineStore('notificationStore', () => {
|
||||
const unreadCount = ref(0)
|
||||
const summary = ref<NotificationSummary>({ items: [], categories: {}, total_unread: 0 })
|
||||
const loading = ref(false)
|
||||
|
||||
const refreshUnreadCount = async () => {
|
||||
const response = await NotificationService.getUnreadCount()
|
||||
if (response.code === 0) {
|
||||
unreadCount.value = getCountValue(response.data)
|
||||
}
|
||||
return unreadCount.value
|
||||
}
|
||||
|
||||
const refreshSummary = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await NotificationService.getUnreadSummary()
|
||||
if (response.code === 0 && response.data) {
|
||||
summary.value = {
|
||||
...response.data,
|
||||
items: (response.data.items || []).slice(0, 10)
|
||||
}
|
||||
if (response.data.total_unread !== undefined) {
|
||||
unreadCount.value = Math.max(0, response.data.total_unread)
|
||||
}
|
||||
}
|
||||
await refreshUnreadCount()
|
||||
return summary.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const markRead = async (id: number | string) => {
|
||||
const response = await NotificationService.markRead(id)
|
||||
if (response.code === 0) {
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
summary.value = {
|
||||
...summary.value,
|
||||
items: summary.value.items.map((item) =>
|
||||
item.id === id ? { ...item, read: true, is_read: true, read_status: 'read' } : item
|
||||
)
|
||||
}
|
||||
await refreshUnreadCount()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const markAllRead = async () => {
|
||||
const response = await NotificationService.markAllRead()
|
||||
if (response.code === 0) {
|
||||
unreadCount.value = 0
|
||||
summary.value = {
|
||||
...summary.value,
|
||||
total_unread: 0,
|
||||
categories: Object.fromEntries(
|
||||
Object.keys(summary.value.categories || {}).map((key) => [key, 0])
|
||||
),
|
||||
items: summary.value.items.map((item) => ({
|
||||
...item,
|
||||
read: true,
|
||||
is_read: true,
|
||||
read_status: 'read'
|
||||
}))
|
||||
}
|
||||
await refreshUnreadCount()
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
summary,
|
||||
loading,
|
||||
refreshUnreadCount,
|
||||
refreshSummary,
|
||||
markRead,
|
||||
markAllRead
|
||||
}
|
||||
})
|
||||
@@ -119,3 +119,6 @@ export * from './orderPackageInvalidateTask'
|
||||
|
||||
// 批量订购任务相关
|
||||
export * from './bulkPurchase'
|
||||
|
||||
// 站内通知相关
|
||||
export * from './notification'
|
||||
|
||||
51
src/types/api/notification.ts
Normal file
51
src/types/api/notification.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type NotificationCategory = 'approval' | 'expiry' | 'sync' | 'system' | 'balance' | string
|
||||
export type NotificationReadStatus = 'read' | 'unread' | string
|
||||
|
||||
export interface NotificationItem {
|
||||
id: number | string
|
||||
title: string
|
||||
content: string
|
||||
category?: NotificationCategory | null
|
||||
type?: string | null
|
||||
severity?: string | null
|
||||
read?: boolean
|
||||
is_read?: boolean
|
||||
read_status?: NotificationReadStatus | null
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
ref_type?: string | null
|
||||
ref_id?: number | string | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface NotificationQueryParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
category?: string
|
||||
type?: string
|
||||
severity?: string
|
||||
read_status?: string
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
items: NotificationItem[]
|
||||
total: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface NotificationSummary {
|
||||
items: NotificationItem[]
|
||||
categories?: Record<string, number>
|
||||
total_unread?: number
|
||||
}
|
||||
|
||||
export interface NotificationTarget {
|
||||
available?: boolean
|
||||
route_name?: string | null
|
||||
route_path?: string | null
|
||||
route_params?: Record<string, string | number> | null
|
||||
query?: Record<string, string | number | boolean> | null
|
||||
message?: string | null
|
||||
}
|
||||
31
src/utils/business/notificationNavigation.ts
Normal file
31
src/utils/business/notificationNavigation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import type { NotificationTarget } from '@/types/api'
|
||||
|
||||
export const navigateNotificationTarget = (router: Router, target?: NotificationTarget | null) => {
|
||||
if (!target || target.available === false) return false
|
||||
const query = target.query
|
||||
? Object.fromEntries(Object.entries(target.query).map(([key, value]) => [key, String(value)]))
|
||||
: undefined
|
||||
|
||||
if (target.route_name) {
|
||||
if (!router.hasRoute(target.route_name)) return false
|
||||
void router.push({
|
||||
name: target.route_name,
|
||||
params: target.route_params || undefined,
|
||||
query
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const path = target.route_path || ''
|
||||
if (!path.startsWith('/') || path.startsWith('//') || /^https?:\/\//i.test(path)) return false
|
||||
|
||||
const resolved = router.resolve({
|
||||
path,
|
||||
query
|
||||
})
|
||||
if (!resolved.matched.length || resolved.name === 'Exception404') return false
|
||||
|
||||
void router.push(resolved)
|
||||
return true
|
||||
}
|
||||
277
src/views/notifications/index.vue
Normal file
277
src/views/notifications/index.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<ArtTableFullScreen>
|
||||
<div class="notification-center-page" id="table-full-screen">
|
||||
<ElCard shadow="never" class="art-table-card">
|
||||
<template #header>
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>通知中心</h2>
|
||||
<p>查看余额预警、临期提醒、审批结果和系统告警。</p>
|
||||
</div>
|
||||
<ElButton
|
||||
v-if="hasAuth(NOTIFICATION_PERMISSIONS.readAll)"
|
||||
:loading="markingAllRead"
|
||||
@click="handleMarkAllRead"
|
||||
>
|
||||
全部已读
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="filters">
|
||||
<ElSelect v-model="filters.category" clearable placeholder="通知分类" @change="reload">
|
||||
<ElOption label="余额预警" value="balance" />
|
||||
<ElOption label="临期提醒" value="expiry" />
|
||||
<ElOption label="审批" value="approval" />
|
||||
<ElOption label="同步/系统" value="system" />
|
||||
</ElSelect>
|
||||
<ElSelect v-model="filters.type" clearable placeholder="通知类型" @change="reload">
|
||||
<ElOption v-for="type in typeOptions" :key="type" :label="type" :value="type" />
|
||||
</ElSelect>
|
||||
<ElSelect v-model="filters.severity" clearable placeholder="严重级别" @change="reload">
|
||||
<ElOption label="提示" value="info" />
|
||||
<ElOption label="警告" value="warning" />
|
||||
<ElOption label="严重" value="error" />
|
||||
<ElOption label="成功" value="success" />
|
||||
</ElSelect>
|
||||
<ElSelect v-model="filters.read_status" clearable placeholder="已读状态" @change="reload">
|
||||
<ElOption label="未读" value="unread" />
|
||||
<ElOption label="已读" value="read" />
|
||||
</ElSelect>
|
||||
<ElButton @click="loadNotifications">刷新</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable v-loading="loading" :data="notifications" row-key="id" class="notification-table">
|
||||
<ElTableColumn label="状态" width="90">
|
||||
<template #default="scope">
|
||||
<span :class="['read-dot', { unread: !isRead(scope.row) }]" />
|
||||
{{ isRead(scope.row) ? '已读' : '未读' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="title" label="通知标题" min-width="200" show-overflow-tooltip />
|
||||
<ElTableColumn label="分类" width="120">
|
||||
<template #default="scope">{{ getCategoryLabel(scope.row.category) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="严重级别" width="110">
|
||||
<template #default="scope">
|
||||
<ElTag :type="getSeverityType(scope.row.severity)">
|
||||
{{ getSeverityLabel(scope.row.severity) }}
|
||||
</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="content" label="通知内容" min-width="300" show-overflow-tooltip />
|
||||
<ElTableColumn prop="created_at" label="时间" width="180" />
|
||||
<ElTableColumn label="操作" width="100" fixed="right">
|
||||
<template #default="scope">
|
||||
<ElButton
|
||||
v-if="hasAuth(NOTIFICATION_PERMISSIONS.read)"
|
||||
link
|
||||
type="primary"
|
||||
@click="handleNotificationClick(scope.row)"
|
||||
>
|
||||
查看
|
||||
</ElButton>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<ElPagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
@current-change="loadNotifications"
|
||||
@size-change="reload"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</ArtTableFullScreen>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { NotificationService } from '@/api/modules'
|
||||
import type { NotificationItem, NotificationQueryParams } from '@/types/api'
|
||||
import { useNotificationStore } from '@/store/modules/notification'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification'
|
||||
import { navigateNotificationTarget } from '@/utils/business/notificationNavigation'
|
||||
|
||||
defineOptions({ name: 'NotificationCenter' })
|
||||
|
||||
const router = useRouter()
|
||||
const notificationStore = useNotificationStore()
|
||||
const { hasAuth } = useAuth()
|
||||
const notifications = ref<NotificationItem[]>([])
|
||||
const loading = ref(false)
|
||||
const markingAllRead = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const typeOptions = ref<string[]>([])
|
||||
const filters = reactive<NotificationQueryParams>({
|
||||
category: undefined,
|
||||
type: undefined,
|
||||
severity: undefined,
|
||||
read_status: undefined
|
||||
})
|
||||
|
||||
const isRead = (item: NotificationItem) =>
|
||||
item.read === true || item.is_read === true || item.read_status === 'read'
|
||||
|
||||
const getCategoryLabel = (category?: string | null) =>
|
||||
({
|
||||
balance: '余额预警',
|
||||
expiry: '临期提醒',
|
||||
expiring: '临期提醒',
|
||||
approval: '审批',
|
||||
sync: '同步/系统',
|
||||
system: '同步/系统'
|
||||
})[category || ''] ||
|
||||
category ||
|
||||
'-'
|
||||
|
||||
const getSeverityLabel = (severity?: string | null) =>
|
||||
({ info: '提示', warning: '警告', error: '严重', success: '成功' })[severity || ''] ||
|
||||
severity ||
|
||||
'-'
|
||||
|
||||
const getSeverityType = (severity?: string | null) => {
|
||||
if (severity === 'error') return 'danger'
|
||||
if (severity === 'warning') return 'warning'
|
||||
if (severity === 'success') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const loadNotifications = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await NotificationService.getNotifications({
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
...filters
|
||||
})
|
||||
if (response.code !== 0 || !response.data) {
|
||||
ElMessage.error(response.msg || '获取通知列表失败')
|
||||
return
|
||||
}
|
||||
notifications.value = response.data.items || []
|
||||
total.value = response.data.total || 0
|
||||
typeOptions.value = Array.from(
|
||||
new Set(
|
||||
notifications.value
|
||||
.map((item) => item.type)
|
||||
.filter((type): type is string => Boolean(type))
|
||||
)
|
||||
)
|
||||
await notificationStore.refreshUnreadCount()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '获取通知列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const reload = () => {
|
||||
page.value = 1
|
||||
void loadNotifications()
|
||||
}
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
markingAllRead.value = true
|
||||
try {
|
||||
const response = await notificationStore.markAllRead()
|
||||
if (response.code !== 0) ElMessage.error(response.msg || '全部已读失败')
|
||||
else await loadNotifications()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '全部已读失败')
|
||||
} finally {
|
||||
markingAllRead.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleNotificationClick = async (item: NotificationItem) => {
|
||||
try {
|
||||
const readResponse = await notificationStore.markRead(item.id)
|
||||
if (readResponse.code !== 0) {
|
||||
ElMessage.error(readResponse.msg || '标记已读失败')
|
||||
return
|
||||
}
|
||||
const targetResponse = await NotificationService.getTarget(item.id)
|
||||
if (targetResponse.code !== 0 || !targetResponse.data) {
|
||||
ElMessage.info(item.content || '该通知暂无可跳转目标')
|
||||
await loadNotifications()
|
||||
return
|
||||
}
|
||||
if (!navigateNotificationTarget(router, targetResponse.data)) {
|
||||
ElMessage.info(targetResponse.data.message || item.content || '该通知暂无可跳转目标')
|
||||
}
|
||||
await loadNotifications()
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '处理通知失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadNotifications()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.notification-center-page {
|
||||
.page-header,
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
justify-content: space-between;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.el-select {
|
||||
width: 160px;
|
||||
}
|
||||
}
|
||||
|
||||
.read-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
background: var(--el-border-color);
|
||||
border-radius: 50%;
|
||||
|
||||
&.unread {
|
||||
background: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user