feat: 顶部通知铃铛与站内通知中心
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m7s

This commit is contained in:
luo
2026-07-24 17:12:29 +08:00
parent 4c6d871c35
commit d1ff4d5f6c
18 changed files with 1101 additions and 519 deletions

View 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需要与路由菜单契约确认。
- 未读数是否需要页面进入时定时刷新或仅在打开抽屉/完成操作时刷新,本期默认按接口调用时机刷新。
- 通知中心菜单权限和按钮权限编码需要后端权限表确认。

View 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 通知数据和无效的空操作按钮将被真实通知数据及接口行为替换。

View File

@@ -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

View 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、相关测试和构建。

View File

@@ -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'

View 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`)
}
}

View File

@@ -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">&#xe6c2;</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">&#xe6c2;</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')
}

View File

@@ -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 {

View File

@@ -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">&#xe8d7;</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">&#xe6c2;</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">&#xe8d7;</i>
<p>暂无通知</p>
</div>
</div>
<div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
<i class="iconfont-sys">&#xe8d7;</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">&#xe8d7;</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: '&#xe72e;',
iconColor: 'rgb(var(--art-warning))',
backgroundColor: 'rgb(var(--art-bg-warning))'
},
message: {
icon: '&#xe747;',
iconColor: 'rgb(var(--art-success))',
backgroundColor: 'rgb(var(--art-bg-success))'
},
collection: {
icon: '&#xe714;',
iconColor: 'rgb(var(--art-danger))',
backgroundColor: 'rgb(var(--art-bg-danger))'
},
user: {
icon: '&#xe608;',
iconColor: 'rgb(var(--art-info))',
backgroundColor: 'rgb(var(--art-bg-info))'
},
notice: {
icon: '&#xe6c2;',
iconColor: 'rgb(var(--art-primary))',
backgroundColor: 'rgb(var(--art-bg-primary))'
}
}
const getNoticeStyle = (type: string) => {
const defaultStyle = {
icon: '&#xe747;',
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>

View File

@@ -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;
}
}

View File

@@ -0,0 +1,5 @@
export const NOTIFICATION_PERMISSIONS = {
page: 'notifications:view',
read: 'notifications:read',
readAll: 'notifications:read_all'
} as const

View File

@@ -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',

View File

@@ -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', // 我的佣金

View 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
}
})

View File

@@ -119,3 +119,6 @@ export * from './orderPackageInvalidateTask'
// 批量订购任务相关
export * from './bulkPurchase'
// 站内通知相关
export * from './notification'

View 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
}

View 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
}

View 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>