feat: Phase 2e-1 统一通知中心 + 我的 TabBar 聚合未读红点

后端(notify 模块)
- 新增 notify 模块:DAO/Service/Pusher 接口/Controller/Router/CleanupTask
- 数据库 DDL:notify_notifications 表 + 3 索引(user+created/user+is_read/user+category)
- 11 种 type 枚举(好友/群聊 9 种 + meeting_* 2 种预留)+ 4 种 category
- 跨模块集成:contact 3 处 Pusher(friend_request/accepted/rejected)
- 跨模块集成:group 6 处 Pusher(invite/join_request/approved/rejected/kicked/role_changed)
- WS handler 断线补偿:连接建立即推送 notify.unread.total
- 5 REST API(4 用户 + 1 管理员广播)+ 2 WS 事件(notify.new / notify.unread.total)
- 30 天已读通知定时清理(未读永久保留)
- Provider/Wire 依赖注入(NotifyPusher、NotifyConnectHook、UserInfoResolver 接口)

前端
- 新增 notify 模块:API/Pinia Store(5 分类分页缓存 + 未读数 + WS 事件)/NotifyItem/通知中心主页
- profile 入口:铃铛 badge + 菜单项 badge + 数字显示
- App.vue/login 初始化 notifyStore WS 监听;logout 调用 notifyStore.reset() 清缓存
- 清理 contact.js/group.js 中散落 toast 与冗余 notify.friend.request/group.join.request 处理
- CustomTabBar 新增 hasDot() 聚合指示器:我的 Tab 显示纯红点(无数字),
  当前聚合 notifyStore.unreadTotal,未来可扩展「资料待完善/安全提醒/新版本」等

文档
- 新增 Phase 2e 整体路线图 docs/plans/2026-04-20-phase2e-design.md
- 新增 Phase 2e-1 专用设计 docs/plans/2026-04-20-phase2e-1-design.md(§6.4 TabBar 聚合红点)
- 新增 Phase 2e-1 实施计划 docs/plans/2026-04-20-phase2e-1-implementation.plan.md
- 新增 E2E 验证报告 test-report-phase2e-1-notification.md(含 Playwright MCP 2 个现场 Bug 修复记录)
- 更新 docs/progress/CURRENT_STATUS.md、docs/api/README.md、docs/api/frontend/notify.md
- 更新 .cursor/rules/project-context.mdc、docs/plans/2026-02-27-echochat-system-design.md

其他
- .gitignore 排除 .playwright-mcp/ MCP 临时快照

架构决策
- 单端 WS 连接:沿用现有 ws.Hub,多端已读同步推迟到 Phase 2f/二期
- 跨模块依赖:contact/group → notify 严格单向(接口注入模式)
- 降级策略:Pusher 先入库后推送;WS 失败不回滚入库;入库失败仅 Warn 不影响业务

Playwright MCP 回归(4 类场景全通)
- 实时推送(admin 广播 → 1s 内前端自动插入 + 角标 +1)
- Deep-link 跳转(好友申请通知 → contact/request 页)
- 批量清零(全部已读按钮)
- TabBar 聚合红点(有未读亮/全部已读灭)与 notifyStore.unreadTotal 三层同步

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-04-21 10:21:01 +08:00
parent ccfceefdaf
commit f1853f125d
42 changed files with 4493 additions and 97 deletions

View File

@@ -93,6 +93,7 @@ import { useUserStore } from '@/store/user'
import { useWebSocketStore } from '@/store/websocket'
import { useChatStore } from '@/store/chat'
import { useContactStore } from '@/store/contact'
import { useNotifyStore } from '@/store/notify'
export default {
name: 'LoginPage',
@@ -159,6 +160,9 @@ export default {
wsStore.connect()
useChatStore().initWsListeners()
useContactStore().initWsListeners()
const notifyStore = useNotifyStore()
notifyStore.initWsListeners()
notifyStore.fetchUnreadCount().catch(() => {})
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 800)
} catch (e) {

View File

@@ -0,0 +1,549 @@
<!--
通知中心主页
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Text #1E293B / Muted #94A3B8 / Danger #EF4444
功能
- 顶部导航返回 + 标题 + 全部已读按钮
- 5 个分类 Tab全部 / 好友 / 群聊 / 会议 / 系统未读数角标
- 下拉/上拉刷新游标分页
- 点击通知 标记已读 + deep-link 跳转
- 群邀请/入群申请通知支持内联接受/拒绝
- 空状态骨架屏实时 WS 事件接入
-->
<template>
<view class="page-wrapper">
<!-- 顶部栏 -->
<view class="header">
<view class="back-btn" @tap="goBack">
<text class="back-icon">&lsaquo;</text>
</view>
<text class="header-title">通知中心</text>
<view class="mark-all-btn" @tap="handleMarkAll">
<text class="mark-all-text">全部已读</text>
</view>
</view>
<!-- 分类 Tab -->
<scroll-view
scroll-x
class="tab-scroll"
:show-scrollbar="false"
>
<view class="tab-list">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab-item"
:class="{ 'tab-item--active': activeCategory === tab.key }"
@tap="switchTab(tab.key)"
>
<text class="tab-label">{{ tab.label }}</text>
<view v-if="tabBadge(tab.key) > 0" class="tab-badge">
{{ tabBadge(tab.key) > 99 ? '99+' : tabBadge(tab.key) }}
</view>
</view>
</view>
</scroll-view>
<!-- 骨架屏 -->
<view v-if="isInitLoading" class="skeleton-list">
<view v-for="i in 4" :key="i" class="skeleton-item">
<view class="skeleton-icon"></view>
<view class="skeleton-body">
<view class="skeleton-line skeleton-line--title"></view>
<view class="skeleton-line skeleton-line--content"></view>
</view>
</view>
</view>
<!-- 列表 -->
<scroll-view
v-else
scroll-y
class="notify-list"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="onLoadMore"
>
<view v-if="currentList.length === 0" class="empty-state">
<text class="empty-icon">&#128276;</text>
<text class="empty-title">暂无通知</text>
<text class="empty-desc">你暂时没有新的消息通知</text>
</view>
<NotifyItem
v-for="item in currentList"
:key="item.id"
:notify="item"
@item-tap="handleNotifyTap"
@item-accept="handleAccept"
@item-reject="handleReject"
/>
<view v-if="currentList.length > 0" class="footer-hint">
<text v-if="currentState.loading">加载中...</text>
<text v-else-if="!currentState.hasMore">已经到底啦</text>
</view>
</scroll-view>
</view>
</template>
<script setup>
/**
* 通知中心页面
* 状态集中在 useNotifyStore 中;本组件只负责 UI 编排与事件绑定
*/
import { ref, computed, onMounted } from 'vue'
import { onShow, onUnload } from '@dcloudio/uni-app'
import { useNotifyStore } from '@/store/notify'
import {
NOTIFY_CATEGORY_TABS,
NOTIFY_CATEGORY_ALL,
NOTIFY_CATEGORY_FRIEND,
NOTIFY_CATEGORY_GROUP,
NOTIFY_CATEGORY_MEETING,
NOTIFY_CATEGORY_SYSTEM,
NOTIFY_TYPE_FRIEND_REQUEST,
NOTIFY_TYPE_GROUP_INVITE,
NOTIFY_TYPE_GROUP_JOIN_REQUEST,
NOTIFY_TYPE_GROUP_JOIN_APPROVED
} from '@/constants/notify'
import NotifyItem from '@/components/notify/NotifyItem.vue'
import groupApi from '@/api/group'
const notifyStore = useNotifyStore()
const tabs = NOTIFY_CATEGORY_TABS
const refreshing = ref(false)
const isInitLoading = ref(true)
const activeCategory = computed(() => notifyStore.activeCategory)
const currentState = computed(() => notifyStore.getCategoryState(activeCategory.value))
const currentList = computed(() => currentState.value.list)
/** 计算 Tab 徽标(友 / 群 / 会议 / 系统 基于 unreadByCategory全部 基于 unreadTotal */
const tabBadge = (key) => {
if (key === NOTIFY_CATEGORY_ALL) return notifyStore.unreadTotal
return notifyStore.unreadByCategory[key] || 0
}
/** 初始化:注册 WS + 拉未读数 + 首屏列表 */
onMounted(async () => {
notifyStore.initWsListeners()
try {
await Promise.all([
notifyStore.fetchUnreadCount(),
notifyStore.fetchList(activeCategory.value)
])
} catch (e) {
console.error('[notify] 初始化失败', e)
} finally {
isInitLoading.value = false
}
})
/** 每次页面重新显示:刷新当前 Tab + 未读数(保证从其他页面返回时数据最新) */
onShow(() => {
if (isInitLoading.value) return
notifyStore.fetchUnreadCount().catch(() => {})
notifyStore.fetchList(activeCategory.value).catch(() => {})
})
onUnload(() => {
// 不重置 store保留缓存以便下次进入直接看到
})
/** 切换 Tab若缓存为空则拉取 */
const switchTab = async (key) => {
if (activeCategory.value === key) return
notifyStore.setActiveCategory(key)
const state = notifyStore.getCategoryState(key)
if (state.list.length === 0) {
try {
await notifyStore.fetchList(key)
} catch (e) {
console.error('[notify] 切 Tab 加载失败', e)
}
}
}
/** 下拉刷新当前 Tab */
const onRefresh = async () => {
refreshing.value = true
try {
await Promise.all([
notifyStore.fetchUnreadCount(),
notifyStore.fetchList(activeCategory.value)
])
} catch (e) {
console.error('[notify] 刷新失败', e)
} finally {
refreshing.value = false
}
}
/** 触底加载更多 */
const onLoadMore = async () => {
try {
await notifyStore.loadMore(activeCategory.value)
} catch (e) {
console.error('[notify] 加载更多失败', e)
}
}
/** 标记全部已读(当前 Tab 为分类则只清该类) */
const handleMarkAll = async () => {
const category = activeCategory.value === NOTIFY_CATEGORY_ALL ? undefined : activeCategory.value
try {
await notifyStore.markAllRead(category)
uni.showToast({ title: '已全部标记为已读', icon: 'none' })
} catch (e) {
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
}
}
/**
* 点击通知:标记已读 + deep-link 跳转
* 根据 target_type + target_id 或 type 语义决定跳转路径
*/
const handleNotifyTap = async (notify) => {
if (!notify || !notify.id) {
console.warn('[notify] handleNotifyTap 收到无效 payload', notify)
return
}
if (!notify.is_read) {
try {
await notifyStore.markRead(notify.id)
} catch (e) {
console.warn('[notify] 标记已读失败', e)
}
}
_navigateByNotify(notify)
}
/**
* 群邀请/入群申请 —— 内联“接受”
*/
const handleAccept = async (notify) => {
try {
if (notify.type === NOTIFY_TYPE_GROUP_JOIN_REQUEST) {
const extra = _parseExtra(notify.extra)
if (extra && extra.group_id && extra.request_id) {
await groupApi.reviewJoinRequest(extra.group_id, extra.request_id, 'approve')
uni.showToast({ title: '已批准入群申请', icon: 'success' })
}
} else if (notify.type === NOTIFY_TYPE_GROUP_INVITE) {
// 群邀请当前后端为直接加入,接受即跳转到群聊会话
const extra = _parseExtra(notify.extra)
uni.showToast({ title: '已接受邀请', icon: 'success' })
_navigateToGroup(extra)
}
await notifyStore.markRead(notify.id).catch(() => {})
} catch (e) {
console.error('[notify] 接受失败', e)
uni.showToast({ title: e && e.message ? e.message : '操作失败', icon: 'none' })
}
}
/**
* 群邀请/入群申请 —— 内联“拒绝”
*/
const handleReject = async (notify) => {
try {
if (notify.type === NOTIFY_TYPE_GROUP_JOIN_REQUEST) {
const extra = _parseExtra(notify.extra)
if (extra && extra.group_id && extra.request_id) {
await groupApi.reviewJoinRequest(extra.group_id, extra.request_id, 'reject')
uni.showToast({ title: '已拒绝入群申请', icon: 'success' })
}
} else if (notify.type === NOTIFY_TYPE_GROUP_INVITE) {
const extra = _parseExtra(notify.extra)
if (extra && extra.group_id) {
await groupApi.leaveGroup(extra.group_id)
uni.showToast({ title: '已退出群聊', icon: 'success' })
}
}
await notifyStore.markRead(notify.id).catch(() => {})
} catch (e) {
console.error('[notify] 拒绝失败', e)
uni.showToast({ title: e && e.message ? e.message : '操作失败', icon: 'none' })
}
}
/** 根据通知的业务对象进行跳转 */
const _navigateByNotify = (notify) => {
const extra = _parseExtra(notify.extra)
switch (notify.type) {
case NOTIFY_TYPE_FRIEND_REQUEST:
uni.navigateTo({ url: '/pages/contact/request' })
return
case NOTIFY_TYPE_GROUP_JOIN_REQUEST:
if (extra && extra.group_id) {
uni.navigateTo({ url: `/pages/group/join-requests?groupId=${extra.group_id}` })
return
}
break
case NOTIFY_TYPE_GROUP_INVITE:
case NOTIFY_TYPE_GROUP_JOIN_APPROVED:
_navigateToGroup(extra)
return
default:
break
}
// 回退:基于 target_type
if (notify.target_type === 'group' && notify.target_id) {
_navigateToGroup({ group_id: notify.target_id, conversation_id: extra && extra.conversation_id })
} else if (notify.target_type === 'user' && notify.target_id) {
uni.navigateTo({ url: `/pages/contact/detail?userId=${notify.target_id}` })
}
}
/** 跳转到群聊会话(优先使用 conversation_id否则群设置页 */
const _navigateToGroup = (extra) => {
if (!extra) return
if (extra.conversation_id) {
uni.navigateTo({ url: `/pages/group/conversation?conversationId=${extra.conversation_id}` })
} else if (extra.group_id) {
uni.navigateTo({ url: `/pages/group/settings?groupId=${extra.group_id}` })
}
}
/** 解析 extra后端以 JSON 字符串存储) */
const _parseExtra = (extra) => {
if (!extra) return null
if (typeof extra === 'object') return extra
try {
return JSON.parse(extra)
} catch (e) {
return null
}
}
/**
* 返回上一页
* - 正常路径:从 profile/其他页 → 通知中心 → navigateBack 到上一页
* - 兜底路径:直接 URL 访问通知中心导致页面栈为空时,降级到 tabBar 页
* 注意profile 与 chat 都是 tabBar 页,必须使用 switchTab不能用 navigateTo/reLaunch
* reLaunch 在 H5 下跳 tabBar 页面可能导致 tabBar UI 不渲染,故改用 switchTab。
*/
const goBack = () => {
uni.navigateBack({
delta: 1,
fail: () => {
uni.switchTab({
url: '/pages/profile/index',
fail: () => {
uni.switchTab({ url: '/pages/chat/index' })
}
})
}
})
}
</script>
<style scoped>
.page-wrapper {
min-height: 100vh;
background-color: #F8FAFC;
}
/* ---- 顶部栏 ---- */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
padding-top: calc(var(--status-bar-height, 44px) + 20rpx);
background-color: #FFFFFF;
box-shadow: 0 1rpx 0 rgba(0, 0, 0, 0.04);
}
.back-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
cursor: pointer;
}
.back-btn:active {
background-color: #F1F5F9;
}
.back-icon {
font-size: 48rpx;
color: #1E293B;
line-height: 1;
}
.header-title {
font-size: 34rpx;
font-weight: 700;
color: #1E293B;
}
.mark-all-btn {
padding: 14rpx 20rpx;
border-radius: 16rpx;
cursor: pointer;
}
.mark-all-btn:active {
background-color: #F1F5F9;
}
.mark-all-text {
font-size: 26rpx;
color: #2563EB;
font-weight: 500;
}
/* ---- Tab ---- */
.tab-scroll {
background-color: #FFFFFF;
white-space: nowrap;
border-bottom: 1rpx solid #F1F5F9;
}
.tab-list {
display: inline-flex;
padding: 8rpx 24rpx;
gap: 8rpx;
}
.tab-item {
position: relative;
display: inline-flex;
align-items: center;
padding: 18rpx 28rpx;
gap: 8rpx;
border-radius: 100rpx;
cursor: pointer;
transition: background-color 200ms ease;
}
.tab-item--active {
background-color: #EFF6FF;
}
.tab-label {
font-size: 28rpx;
color: #64748B;
}
.tab-item--active .tab-label {
color: #2563EB;
font-weight: 600;
}
.tab-badge {
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
border-radius: 16rpx;
background-color: #EF4444;
color: #FFFFFF;
font-size: 20rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
/* ---- 列表 ---- */
.notify-list {
height: calc(100vh - 200rpx);
}
/* ---- 骨架屏 ---- */
.skeleton-list {
padding: 20rpx 0;
}
.skeleton-item {
display: flex;
gap: 20rpx;
padding: 28rpx 32rpx;
background-color: #FFFFFF;
border-bottom: 1rpx solid #F1F5F9;
}
.skeleton-icon {
width: 80rpx;
height: 80rpx;
border-radius: 16rpx;
background-color: #E2E8F0;
animation: pulse 1.4s ease-in-out infinite;
}
.skeleton-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
justify-content: center;
}
.skeleton-line {
height: 24rpx;
border-radius: 12rpx;
background-color: #E2E8F0;
animation: pulse 1.4s ease-in-out infinite;
}
.skeleton-line--title {
width: 40%;
}
.skeleton-line--content {
width: 80%;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ---- 空状态 ---- */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 160rpx 40rpx;
gap: 16rpx;
}
.empty-icon {
font-size: 96rpx;
opacity: 0.6;
}
.empty-title {
font-size: 30rpx;
color: #475569;
font-weight: 600;
}
.empty-desc {
font-size: 26rpx;
color: #94A3B8;
text-align: center;
}
/* ---- 底部提示 ---- */
.footer-hint {
padding: 32rpx 0;
text-align: center;
}
.footer-hint text {
font-size: 24rpx;
color: #94A3B8;
}
</style>

View File

@@ -15,6 +15,17 @@
-->
<template>
<view class="page-wrapper">
<!-- 顶部栏铃铛入口 + 徽标 -->
<view class="top-bar">
<text class="top-title">我的</text>
<view class="bell-btn" @tap="goNotifyCenter">
<text class="bell-icon">&#128276;</text>
<view v-if="notifyStore.unreadTotal > 0" class="bell-badge">
{{ notifyStore.unreadTotal > 99 ? '99+' : notifyStore.unreadTotal }}
</view>
</view>
</view>
<!-- 用户信息头部 -->
<view class="profile-header">
<view class="avatar-box">
@@ -26,6 +37,16 @@
<!-- 功能菜单 -->
<view class="menu-card">
<view class="menu-item" @tap="goNotifyCenter">
<text class="menu-label">通知中心</text>
<view class="menu-right">
<view v-if="notifyStore.unreadTotal > 0" class="menu-badge">
{{ notifyStore.unreadTotal > 99 ? '99+' : notifyStore.unreadTotal }}
</view>
<text class="menu-arrow"></text>
</view>
</view>
<view class="menu-divider"></view>
<view class="menu-item" @tap="goEditProfile">
<text class="menu-label">编辑资料</text>
<text class="menu-arrow"></text>
@@ -56,6 +77,7 @@
* 退出登录时调用 store.logout(),清除 Redis Token + 本地缓存
*/
import { useUserStore } from '@/store/user'
import { useNotifyStore } from '@/store/notify'
import CustomTabBar from '@/components/CustomTabBar.vue'
export default {
@@ -67,13 +89,28 @@ export default {
const store = useUserStore()
return store.userInfo || {}
},
/** 通知 Store用于读取未读数并驱动铃铛徽标 */
notifyStore() {
return useNotifyStore()
},
/** 头像占位字母(取昵称或用户名首字符) */
avatarLetter() {
const name = this.userInfo.nickname || this.userInfo.username || '?'
return name.charAt(0).toUpperCase()
}
},
onShow() {
// 每次进入「我的」页面时刷新未读数,保证徽标实时
const store = useNotifyStore()
store.initWsListeners()
store.fetchUnreadCount().catch(() => {})
},
methods: {
/** 跳转到通知中心页 */
goNotifyCenter() {
uni.navigateTo({ url: '/pages/notify/index' })
},
/** 编辑资料(后续实现,当前提示开发中) */
goEditProfile() {
uni.showToast({ title: '功能开发中', icon: 'none' })
@@ -114,6 +151,82 @@ export default {
padding-bottom: 120rpx;
}
/* ---- 顶部栏(铃铛入口) ---- */
.top-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 32rpx;
background-color: #FFFFFF;
}
.top-title {
font-size: 36rpx;
font-weight: 700;
color: #1E293B;
}
.bell-btn {
position: relative;
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: #F1F5F9;
cursor: pointer;
transition: background-color 200ms ease;
}
.bell-btn:active {
background-color: #E2E8F0;
}
.bell-icon {
font-size: 36rpx;
color: #1E293B;
line-height: 1;
}
.bell-badge {
position: absolute;
top: 4rpx;
right: 4rpx;
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
border-radius: 16rpx;
background-color: #EF4444;
color: #FFFFFF;
font-size: 20rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid #FFFFFF;
}
.menu-right {
display: flex;
align-items: center;
gap: 12rpx;
}
.menu-badge {
min-width: 36rpx;
height: 36rpx;
padding: 0 10rpx;
border-radius: 18rpx;
background-color: #EF4444;
color: #FFFFFF;
font-size: 22rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
}
/* ---- 用户信息头部 ---- */
.profile-header {
display: flex;