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

@@ -11,6 +11,7 @@ import { useWebSocketStore } from '@/store/websocket'
import { useChatStore } from '@/store/chat'
import { useContactStore } from '@/store/contact'
import { useGroupStore } from '@/store/group'
import { useNotifyStore } from '@/store/notify'
export default {
onLaunch() {
@@ -37,10 +38,13 @@ export default {
const chatStore = useChatStore()
const contactStore = useContactStore()
const groupStore = useGroupStore()
const notifyStore = useNotifyStore()
chatStore.initWsListeners()
contactStore.initWsListeners()
groupStore.initWsListeners()
notifyStore.initWsListeners()
contactStore.fetchPendingRequests().catch(() => {})
notifyStore.fetchUnreadCount().catch(() => {})
}
}
}

View File

@@ -0,0 +1,55 @@
/**
* 通知中心 API
*
* 对应后端路由:/api/v1/notifications/*
* 接口文档见docs/api/frontend/notify.md
*/
import { get, put } from '@/utils/request'
/**
* 获取通知列表(游标分页)
* @param {Object} [params]
* @param {string} [params.category] - 分类过滤all|friend|group|meeting|system
* @param {boolean} [params.is_read] - 已读状态true/false不传代表全部
* @param {number} [params.before_id] - 游标,返回 id 小于该值的通知
* @param {number} [params.limit] - 页大小(默认 20最大 100
*/
const getNotifications = (params = {}) => {
return get('/api/v1/notifications', params)
}
/** 获取未读数统计(总数 + 分类统计) */
const getUnreadCount = () => {
return get('/api/v1/notifications/unread-count')
}
/**
* 标记单条通知已读
* @param {number} id - 通知 ID
*/
const markRead = (id) => {
return put(`/api/v1/notifications/${id}/read`)
}
/**
* 批量标记已读(可按分类过滤)
*
* 说明:后端通过 query 参数接收 category`PUT /api/v1/notifications/read-all?category=friend`
* 为保持契约一致,此处将 category 拼接到 URL避免放入 request body 导致后端读不到。
*
* @param {string} [category] - 分类过滤friend|group|meeting|system不传代表全部
*/
const markAllRead = (category) => {
const url = category
? `/api/v1/notifications/read-all?category=${encodeURIComponent(category)}`
: '/api/v1/notifications/read-all'
return put(url)
}
export default {
getNotifications,
getUnreadCount,
markRead,
markAllRead
}

View File

@@ -8,7 +8,9 @@
功能
- 底部导航栏4 Tab消息 / 联系人 / 会议 / 我的
- 选中态使用 filled 图标 + Primary 未选中态使用轮廓图标 + Muted
- 消息 Tab 支持未读消息 badge联系人 Tab 支持好友申请未读 badge
- 消息 Tab / 联系人 Tab数字 badgegetBadge
- 我的 Tab聚合小红点hasDot当前来源 notifyStore.unreadTotal
未来可扩展为资料待完善安全提醒新版本可用等聚合指示器
- 使用 switchTab 跳转
-->
<template>
@@ -26,7 +28,9 @@
size="24"
:color="currentIndex === index ? '#2563EB' : '#94A3B8'"
/>
<!-- 优先显示数字 badge消息/联系人否则若有未读红点则显示小红点我的 -->
<text v-if="getBadge(index) > 0" class="tab-badge">{{ getBadge(index) > 99 ? '99+' : getBadge(index) }}</text>
<text v-else-if="hasDot(index)" class="tab-dot" />
</view>
<text class="tab-label">{{ item.label }}</text>
</view>
@@ -48,6 +52,7 @@
*/
import { useChatStore } from '@/store/chat'
import { useContactStore } from '@/store/contact'
import { useNotifyStore } from '@/store/notify'
export default {
name: 'CustomTabBar',
@@ -84,7 +89,7 @@ export default {
uni.switchTab({ url: this.tabs[index].path })
},
/**
* 获取指定 Tab 的 badge 数
* 获取指定 Tab 的数字 badge 数(消息/联系人 Tab 使用)
* @param {number} index - Tab 索引
* @returns {number} badge 数量0 表示不显示)
*/
@@ -98,6 +103,33 @@ export default {
return contactStore.pendingCount
}
return 0
},
/**
* 判断指定 Tab 是否需要显示小红点(聚合指示器)
*
* 当前实现:
* - index=3我的聚合 notifyStore.unreadTotal > 0
*
* 未来扩展示例(仅示意,不是 TODO
* - 资料待完善profileStore.hasProfileReminder
* - 安全提醒securityStore.hasSecurityAlert
* - 新版本提示appStore.hasNewVersion
* 追加新来源时,仅需在 index=3 分支里 `|| 新来源`,外层调用方无需感知
*
* 与 getBadge 语义区分:
* - getBadge 返回数字0 表示不显示)
* - hasDot 返回布尔true 表示仅显示纯红点)
* 模板先判断 getBadge数字优先无数字时再判断 hasDot
*
* @param {number} index - Tab 索引
* @returns {boolean} 是否显示红点
*/
hasDot(index) {
if (index === 3) {
const notifyStore = useNotifyStore()
return notifyStore.unreadTotal > 0
}
return false
}
}
}
@@ -157,6 +189,18 @@ export default {
text-align: center;
}
/* 聚合未读小红点(用于"我的" Tab纯红色圆点不显示数字 */
.tab-dot {
position: absolute;
top: -4rpx;
right: -8rpx;
width: 16rpx;
height: 16rpx;
background-color: #EF4444;
border-radius: 50%;
border: 2rpx solid #FFFFFF;
}
.tab-label {
font-size: 22rpx;
color: #94A3B8;

View File

@@ -0,0 +1,287 @@
<!--
通知卡片组件
设计系统design-system/echochat/MASTER.md
色板Primary #2563EB / BG #F8FAFC / Text #1E293B / Muted #94A3B8
ui-ux-pro-max 规范触摸目标 88rpx / 可点击 cursor-pointer / 新旧状态差异化
功能
- type 渲染图标和文案
- 未读加红点标识
- group_invite / group_join_request 支持内联接受/拒绝按钮
- 点击卡片按 target_type + target_id 进行 deep-link 跳转
-->
<template>
<view
class="notify-item"
:class="{ 'notify-item--unread': !notify.is_read }"
@tap="handleTap"
>
<!-- 图标 -->
<view
class="notify-icon"
:style="{ backgroundColor: iconBg }"
>
<text class="notify-icon-text">{{ icon }}</text>
<view v-if="!notify.is_read" class="notify-dot"></view>
</view>
<!-- 内容 -->
<view class="notify-body">
<view class="notify-header">
<text class="notify-title">{{ displayTitle }}</text>
<text class="notify-time">{{ formatTime(notify.created_at) }}</text>
</view>
<text class="notify-content" v-if="notify.content">{{ notify.content }}</text>
<!-- 内联操作按钮仅限群聊邀请 / 入群申请 -->
<view
v-if="showActions"
class="notify-actions"
>
<button
class="notify-btn notify-btn--primary"
:disabled="processing"
@tap.stop="handleAccept"
>
接受
</button>
<button
class="notify-btn notify-btn--ghost"
:disabled="processing"
@tap.stop="handleReject"
>
拒绝
</button>
</view>
</view>
</view>
</template>
<script setup>
/**
* 通知卡片
* 仅负责渲染和派发事件;具体业务动作由父页面实现
*/
import { computed, ref } from 'vue'
import {
NOTIFY_TYPE_ICON,
NOTIFY_DEFAULT_ICON,
NOTIFY_TYPE_LABEL,
NOTIFY_CATEGORY_COLOR,
supportsInlineAction
} from '@/constants/notify'
const props = defineProps({
/** 通知对象,包含 id / type / category / title / content / is_read / created_at / extra 等 */
notify: {
type: Object,
required: true
}
})
// 注意uni-app 的 `tap` 是原生 DOM 事件名,若将自定义 emit 事件名也取作 `tap`
// 则原生 tap 冒泡会与自定义 emit 在父组件处形成双重触发,且原生事件会把 Event 对象
// 作为参数,导致父组件收到的 notify 变成 Event 对象,随后读取 notify.id 为 undefined。
// 为彻底规避冲突,这里统一使用非 DOM 原生事件名。
const emit = defineEmits(['item-tap', 'item-accept', 'item-reject'])
/** 按钮处理中状态(防止重复点击) */
const processing = ref(false)
/** 显示标题:优先 notify.title其次按 type 取默认文案 */
const displayTitle = computed(() => {
return props.notify.title || NOTIFY_TYPE_LABEL[props.notify.type] || '通知'
})
/** 图标 */
const icon = computed(() => {
return NOTIFY_TYPE_ICON[props.notify.type] || NOTIFY_DEFAULT_ICON
})
/** 图标背景色(按分类) */
const iconBg = computed(() => {
return NOTIFY_CATEGORY_COLOR[props.notify.category] || '#F1F5F9'
})
/** 是否展示内联操作按钮(仅未读的 invite/join_request 通知) */
const showActions = computed(() => {
if (props.notify.is_read) return false
return supportsInlineAction(props.notify.type)
})
const handleTap = () => {
emit('item-tap', props.notify)
}
const handleAccept = async () => {
if (processing.value) return
processing.value = true
try {
await emit('item-accept', props.notify)
} finally {
processing.value = false
}
}
const handleReject = async () => {
if (processing.value) return
processing.value = true
try {
await emit('item-reject', props.notify)
} finally {
processing.value = false
}
}
/**
* 将 ISO 时间字符串格式化为相对时间
* - 1 分钟内:刚刚
* - 1 小时内X 分钟前
* - 24 小时内X 小时前
* - 否则MM-DD HH:mm
*/
const formatTime = (str) => {
if (!str) return ''
const t = new Date(str.replace(' ', 'T'))
if (isNaN(t.getTime())) return str
const now = Date.now()
const diff = Math.max(0, now - t.getTime())
const min = Math.floor(diff / 60000)
if (min < 1) return '刚刚'
if (min < 60) return `${min} 分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour} 小时前`
const pad = (n) => String(n).padStart(2, '0')
return `${pad(t.getMonth() + 1)}-${pad(t.getDate())} ${pad(t.getHours())}:${pad(t.getMinutes())}`
}
</script>
<style scoped>
.notify-item {
position: relative;
display: flex;
gap: 20rpx;
padding: 28rpx 32rpx;
background-color: #FFFFFF;
border-bottom: 1rpx solid #F1F5F9;
cursor: pointer;
transition: background-color 200ms ease;
}
.notify-item:active {
background-color: #F8FAFC;
}
.notify-item--unread {
background-color: #F8FAFC;
}
.notify-icon {
position: relative;
width: 80rpx;
height: 80rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.notify-icon-text {
font-size: 36rpx;
line-height: 1;
}
.notify-dot {
position: absolute;
top: -2rpx;
right: -2rpx;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background-color: #EF4444;
border: 2rpx solid #FFFFFF;
}
.notify-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.notify-header {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16rpx;
}
.notify-title {
font-size: 30rpx;
font-weight: 600;
color: #1E293B;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notify-time {
font-size: 22rpx;
color: #94A3B8;
flex-shrink: 0;
}
.notify-content {
font-size: 26rpx;
color: #64748B;
line-height: 1.5;
/* 限制两行显示 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 内联操作按钮 */
.notify-actions {
display: flex;
gap: 16rpx;
margin-top: 12rpx;
}
.notify-btn {
flex: 1;
height: 64rpx;
line-height: 64rpx;
border-radius: 12rpx;
font-size: 26rpx;
font-weight: 500;
padding: 0;
margin: 0;
border: none;
cursor: pointer;
transition: opacity 200ms ease;
}
.notify-btn::after {
border: none;
}
.notify-btn[disabled] {
opacity: 0.5;
}
.notify-btn--primary {
background-color: #2563EB;
color: #FFFFFF;
}
.notify-btn--ghost {
background-color: #F1F5F9;
color: #1E293B;
}
</style>

View File

@@ -0,0 +1,92 @@
/**
* 通知模块前端常量
*
* 与后端 backend/go-service/app/constants/notify.go 一致
* 新增/修改通知类型时必须同步维护两端常量
*/
// 通知类型(与后端 NotifyType* 保持一致)
export const NOTIFY_TYPE_FRIEND_REQUEST = 'friend_request'
export const NOTIFY_TYPE_FRIEND_ACCEPTED = 'friend_accepted'
export const NOTIFY_TYPE_FRIEND_REJECTED = 'friend_rejected'
export const NOTIFY_TYPE_GROUP_INVITE = 'group_invite'
export const NOTIFY_TYPE_GROUP_JOIN_REQUEST = 'group_join_request'
export const NOTIFY_TYPE_GROUP_JOIN_APPROVED = 'group_join_approved'
export const NOTIFY_TYPE_GROUP_JOIN_REJECTED = 'group_join_rejected'
export const NOTIFY_TYPE_GROUP_KICKED = 'group_kicked'
export const NOTIFY_TYPE_GROUP_ROLE_CHANGED = 'group_role_changed'
export const NOTIFY_TYPE_SYSTEM_BROADCAST = 'system_broadcast'
export const NOTIFY_TYPE_MEETING_INVITE = 'meeting_invite'
export const NOTIFY_TYPE_MEETING_REMINDER = 'meeting_reminder'
// 通知分类(前端 5 Tab
export const NOTIFY_CATEGORY_ALL = 'all'
export const NOTIFY_CATEGORY_FRIEND = 'friend'
export const NOTIFY_CATEGORY_GROUP = 'group'
export const NOTIFY_CATEGORY_MEETING = 'meeting'
export const NOTIFY_CATEGORY_SYSTEM = 'system'
/** Tab 列表(顺序即 UI 展示顺序) */
export const NOTIFY_CATEGORY_TABS = [
{ key: NOTIFY_CATEGORY_ALL, label: '全部' },
{ key: NOTIFY_CATEGORY_FRIEND, label: '好友' },
{ key: NOTIFY_CATEGORY_GROUP, label: '群聊' },
{ key: NOTIFY_CATEGORY_MEETING, label: '会议' },
{ key: NOTIFY_CATEGORY_SYSTEM, label: '系统' }
]
/** 通知类型中文映射(默认标题兜底) */
export const NOTIFY_TYPE_LABEL = {
[NOTIFY_TYPE_FRIEND_REQUEST]: '好友申请',
[NOTIFY_TYPE_FRIEND_ACCEPTED]: '好友申请通过',
[NOTIFY_TYPE_FRIEND_REJECTED]: '好友申请被拒',
[NOTIFY_TYPE_GROUP_INVITE]: '群聊邀请',
[NOTIFY_TYPE_GROUP_JOIN_REQUEST]: '入群申请',
[NOTIFY_TYPE_GROUP_JOIN_APPROVED]: '入群申请通过',
[NOTIFY_TYPE_GROUP_JOIN_REJECTED]: '入群申请被拒',
[NOTIFY_TYPE_GROUP_KICKED]: '移出群聊',
[NOTIFY_TYPE_GROUP_ROLE_CHANGED]: '群角色变更',
[NOTIFY_TYPE_SYSTEM_BROADCAST]: '系统通知',
[NOTIFY_TYPE_MEETING_INVITE]: '会议邀请',
[NOTIFY_TYPE_MEETING_REMINDER]: '会议提醒'
}
/** 通知图标emoji 占位;后续可替换为 SVG */
export const NOTIFY_TYPE_ICON = {
[NOTIFY_TYPE_FRIEND_REQUEST]: '\uD83D\uDC65', // 👥
[NOTIFY_TYPE_FRIEND_ACCEPTED]: '\u2705', // ✅
[NOTIFY_TYPE_FRIEND_REJECTED]: '\u274C', // ❌
[NOTIFY_TYPE_GROUP_INVITE]: '\uD83C\uDF89', // 🎉
[NOTIFY_TYPE_GROUP_JOIN_REQUEST]: '\uD83D\uDCE5', // 📥
[NOTIFY_TYPE_GROUP_JOIN_APPROVED]: '\u2705', // ✅
[NOTIFY_TYPE_GROUP_JOIN_REJECTED]: '\u274C', // ❌
[NOTIFY_TYPE_GROUP_KICKED]: '\uD83D\uDEAA', // 🚪
[NOTIFY_TYPE_GROUP_ROLE_CHANGED]: '\uD83D\uDD11', // 🔑
[NOTIFY_TYPE_SYSTEM_BROADCAST]: '\uD83D\uDCE2', // 📢
[NOTIFY_TYPE_MEETING_INVITE]: '\uD83D\uDCC5', // 📅
[NOTIFY_TYPE_MEETING_REMINDER]: '\u23F0' // ⏰
}
/** 默认图标 */
export const NOTIFY_DEFAULT_ICON = '\uD83D\uDD14' // 🔔
/** 图标背景颜色(按分类) */
export const NOTIFY_CATEGORY_COLOR = {
[NOTIFY_CATEGORY_FRIEND]: '#DBEAFE', // 蓝浅
[NOTIFY_CATEGORY_GROUP]: '#DCFCE7', // 绿浅
[NOTIFY_CATEGORY_MEETING]: '#FEF3C7', // 黄浅
[NOTIFY_CATEGORY_SYSTEM]: '#E0E7FF' // 紫浅
}
/**
* 判断该通知是否支持内联操作(接受/拒绝按钮)
* 仅 group_invite 和 group_join_request 支持,对方待处理状态
* @param {string} type
* @returns {boolean}
*/
export const supportsInlineAction = (type) => {
return type === NOTIFY_TYPE_GROUP_INVITE || type === NOTIFY_TYPE_GROUP_JOIN_REQUEST
}

View File

@@ -142,6 +142,13 @@
"navigationBarTitleText": "会议"
}
},
{
"path": "pages/notify/index",
"style": {
"navigationBarTitleText": "通知中心",
"navigationStyle": "custom"
}
},
{
"path": "pages/profile/index",
"style": {

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;

View File

@@ -152,9 +152,8 @@ export const useContactStore = defineStore('contact', () => {
if (_wsInitialized) return
_wsInitialized = true
wsService.on('notify.friend.request', () => {
fetchPendingRequests()
})
// 好友申请、接受、拒绝的实时通知统一由 notify store 接管notify.new 事件)
// 本 store 仅保留好友列表变更监听,避免重复弹窗和数据刷新
wsService.on('contact.request.accepted', () => {
fetchFriends()

View File

@@ -286,17 +286,18 @@ export const useGroupStore = defineStore('group', () => {
}
}
/** 新入群申请 → 提示通知 */
const _onJoinRequest = (msg) => {
if (!msg || !msg.data) return
uni.showToast({ title: '收到新的入群申请', icon: 'none' })
}
/**
* 新入群申请 → 不再 toast由 notify storenotify.new 事件)统一负责提示和徽标
* 保留空监听以便未来扩展(如高亮申请列表),避免重复弹窗
*/
const _onJoinRequest = () => {}
/** 入群申请通过 → 刷新会话列表 */
/**
* 入群申请通过 → 刷新会话列表toast 由 notify store 统一处理)
*/
const _onJoinApproved = () => {
const chatStore = useChatStore()
chatStore.fetchConversations()
uni.showToast({ title: '入群申请已通过', icon: 'none' })
}
return {

View File

@@ -0,0 +1,352 @@
/**
* 通知中心 Store
*
* 统一管理:
* - 通知列表(按分类 Tab 分页缓存,游标分页)
* - 未读数统计(总数 + 分类统计)
* - WebSocket 实时事件notify.new新通知/ notify.unread.total断线补偿
*
* 对应后端 API/api/v1/notifications/*
* 对应 WS 事件notify.new、notify.unread.total
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import notifyApi from '@/api/notify'
import wsService from '@/services/websocket'
/** 前端 5 个 Tab 分类常量 */
export const NOTIFY_CATEGORY_ALL = 'all'
export const NOTIFY_CATEGORY_FRIEND = 'friend'
export const NOTIFY_CATEGORY_GROUP = 'group'
export const NOTIFY_CATEGORY_MEETING = 'meeting'
export const NOTIFY_CATEGORY_SYSTEM = 'system'
const CATEGORY_LIST = [
NOTIFY_CATEGORY_ALL,
NOTIFY_CATEGORY_FRIEND,
NOTIFY_CATEGORY_GROUP,
NOTIFY_CATEGORY_MEETING,
NOTIFY_CATEGORY_SYSTEM
]
const DEFAULT_PAGE_SIZE = 20
/**
* 创建空的分类分页状态
*/
const createCategoryState = () => ({
list: [],
hasMore: true,
loading: false
})
export const useNotifyStore = defineStore('notify', () => {
/** 按分类存储通知列表Map 结构 category -> { list, hasMore, loading } */
const byCategory = ref({
[NOTIFY_CATEGORY_ALL]: createCategoryState(),
[NOTIFY_CATEGORY_FRIEND]: createCategoryState(),
[NOTIFY_CATEGORY_GROUP]: createCategoryState(),
[NOTIFY_CATEGORY_MEETING]: createCategoryState(),
[NOTIFY_CATEGORY_SYSTEM]: createCategoryState()
})
/** 未读总数 */
const unreadTotal = ref(0)
/** 未读分类统计 { friend, group, meeting, system } */
const unreadByCategory = ref({
[NOTIFY_CATEGORY_FRIEND]: 0,
[NOTIFY_CATEGORY_GROUP]: 0,
[NOTIFY_CATEGORY_MEETING]: 0,
[NOTIFY_CATEGORY_SYSTEM]: 0
})
/** 当前激活的 Tab页面用于驱动切换 */
const activeCategory = ref(NOTIFY_CATEGORY_ALL)
/** Tab 角标:未读总数 */
const badgeTotal = computed(() => unreadTotal.value)
/** 获取指定分类的状态(安全读取) */
const getCategoryState = (category) => {
const key = CATEGORY_LIST.includes(category) ? category : NOTIFY_CATEGORY_ALL
if (!byCategory.value[key]) {
byCategory.value[key] = createCategoryState()
}
return byCategory.value[key]
}
// ==================== Actions ====================
/**
* 拉取未读数统计
* 场景页面初始化、WS 断线补偿 notify.unread.total 事件、明确需要刷新时
*/
const fetchUnreadCount = async () => {
try {
const res = await notifyApi.getUnreadCount()
const data = res.data || {}
unreadTotal.value = data.total || 0
const byCat = data.by_category || {}
unreadByCategory.value = {
[NOTIFY_CATEGORY_FRIEND]: byCat[NOTIFY_CATEGORY_FRIEND] || 0,
[NOTIFY_CATEGORY_GROUP]: byCat[NOTIFY_CATEGORY_GROUP] || 0,
[NOTIFY_CATEGORY_MEETING]: byCat[NOTIFY_CATEGORY_MEETING] || 0,
[NOTIFY_CATEGORY_SYSTEM]: byCat[NOTIFY_CATEGORY_SYSTEM] || 0
}
} catch (e) {
console.warn('[notify] 获取未读数失败', e)
}
}
/**
* 加载指定分类的通知列表(首屏刷新)
* @param {string} category - 分类
* @param {Object} [opts]
* @param {boolean} [opts.onlyUnread] - 是否仅看未读
*/
const fetchList = async (category = NOTIFY_CATEGORY_ALL, opts = {}) => {
const state = getCategoryState(category)
state.loading = true
try {
const params = {
category,
limit: DEFAULT_PAGE_SIZE
}
if (opts.onlyUnread === true) params.is_read = false
const res = await notifyApi.getNotifications(params)
const data = res.data || {}
state.list = data.list || []
state.hasMore = data.has_more === true
} finally {
state.loading = false
}
return state.list
}
/**
* 加载下一页(游标分页,基于当前列表最后一条的 ID
* @param {string} category
* @param {Object} [opts]
* @param {boolean} [opts.onlyUnread]
*/
const loadMore = async (category = NOTIFY_CATEGORY_ALL, opts = {}) => {
const state = getCategoryState(category)
if (!state.hasMore || state.loading) return []
const last = state.list[state.list.length - 1]
if (!last) return fetchList(category, opts)
state.loading = true
try {
const params = {
category,
limit: DEFAULT_PAGE_SIZE,
before_id: last.id
}
if (opts.onlyUnread === true) params.is_read = false
const res = await notifyApi.getNotifications(params)
const data = res.data || {}
const newList = data.list || []
state.list = state.list.concat(newList)
state.hasMore = data.has_more === true
return newList
} finally {
state.loading = false
}
}
/**
* 标记单条已读
* 后端成功后本地更新列表状态 + 扣减对应分类未读数
* @param {number} id - 通知 ID
*/
const markRead = async (id) => {
const targetCategory = _findCategoryById(id)
const target = _findNotifyById(id)
// 先快照原始未读状态,避免 _patchAll 改写 target.is_read 后再判断失效
const wasUnread = !!target && !target.is_read
if (target && target.is_read) return
await notifyApi.markRead(id)
// 更新所有缓存列表中对应通知的状态
_patchAll(id, { is_read: true, read_at: _nowString() })
if (wasUnread) {
if (targetCategory && unreadByCategory.value[targetCategory] > 0) {
unreadByCategory.value[targetCategory]--
}
if (unreadTotal.value > 0) unreadTotal.value--
}
}
/**
* 标记全部(或某分类)已读
* @param {string} [category] - 分类friend/group/meeting/system不传则全部
*/
const markAllRead = async (category) => {
await notifyApi.markAllRead(category)
// 本地更新所有缓存列表
Object.keys(byCategory.value).forEach(cat => {
if (!category || cat === category || cat === NOTIFY_CATEGORY_ALL) {
byCategory.value[cat].list.forEach(n => {
if (!category || n.category === category) {
if (!n.is_read) {
n.is_read = true
n.read_at = _nowString()
}
}
})
}
})
// 重新拉取未读数,保证后端权威
await fetchUnreadCount()
}
/**
* 切换激活 Tab
* @param {string} category
*/
const setActiveCategory = (category) => {
if (CATEGORY_LIST.includes(category)) {
activeCategory.value = category
}
}
/**
* 重置 store登出调用
*/
const reset = () => {
Object.keys(byCategory.value).forEach(cat => {
byCategory.value[cat] = createCategoryState()
})
unreadTotal.value = 0
unreadByCategory.value = {
[NOTIFY_CATEGORY_FRIEND]: 0,
[NOTIFY_CATEGORY_GROUP]: 0,
[NOTIFY_CATEGORY_MEETING]: 0,
[NOTIFY_CATEGORY_SYSTEM]: 0
}
activeCategory.value = NOTIFY_CATEGORY_ALL
}
// ==================== WebSocket 事件 ====================
/** 防止 WebSocket 事件监听重复注册 */
let _wsInitialized = false
/**
* 处理 notify.new 事件:新通知到达
* 行为:
* 1) 插入到对应分类列表和 all 分类列表顶部
* 2) 未读总数 +1、对应分类 +1
*/
const _onNotifyNew = (msg) => {
if (!msg || !msg.data) return
const notify = msg.data
const targetCat = notify.category
if (targetCat && byCategory.value[targetCat]) {
byCategory.value[targetCat].list.unshift(notify)
}
byCategory.value[NOTIFY_CATEGORY_ALL].list.unshift(notify)
if (!notify.is_read) {
unreadTotal.value++
if (targetCat && targetCat in unreadByCategory.value) {
unreadByCategory.value[targetCat]++
}
}
}
/**
* 处理 notify.unread.total 事件:断线补偿
* 行为:以权威推送值覆盖本地缓存的未读数
*/
const _onUnreadTotal = (msg) => {
if (!msg || !msg.data) return
const data = msg.data
if (typeof data.total === 'number') unreadTotal.value = data.total
if (data.by_category) {
unreadByCategory.value = {
[NOTIFY_CATEGORY_FRIEND]: data.by_category[NOTIFY_CATEGORY_FRIEND] || 0,
[NOTIFY_CATEGORY_GROUP]: data.by_category[NOTIFY_CATEGORY_GROUP] || 0,
[NOTIFY_CATEGORY_MEETING]: data.by_category[NOTIFY_CATEGORY_MEETING] || 0,
[NOTIFY_CATEGORY_SYSTEM]: data.by_category[NOTIFY_CATEGORY_SYSTEM] || 0
}
}
}
/** 初始化 WebSocket 事件监听(幂等,多次调用只注册一次) */
const initWsListeners = () => {
if (_wsInitialized) return
_wsInitialized = true
wsService.on('notify.new', _onNotifyNew)
wsService.on('notify.unread.total', _onUnreadTotal)
}
// ==================== 内部工具 ====================
/** 在所有缓存列表中查找指定 ID 的通知 */
const _findNotifyById = (id) => {
for (const cat of CATEGORY_LIST) {
const state = byCategory.value[cat]
if (!state) continue
const found = state.list.find(n => n.id === id)
if (found) return found
}
return null
}
/** 推断某条通知所属的分类(根据缓存查找) */
const _findCategoryById = (id) => {
for (const cat of CATEGORY_LIST) {
if (cat === NOTIFY_CATEGORY_ALL) continue
const state = byCategory.value[cat]
if (!state) continue
if (state.list.some(n => n.id === id)) return cat
}
// 兜底:从 all 列表查找 category 字段
const inAll = byCategory.value[NOTIFY_CATEGORY_ALL].list.find(n => n.id === id)
return inAll ? inAll.category : null
}
/** 在所有分类列表中 patch 对应 ID 的通知字段 */
const _patchAll = (id, patch) => {
CATEGORY_LIST.forEach(cat => {
const state = byCategory.value[cat]
if (!state) return
const target = state.list.find(n => n.id === id)
if (target) Object.assign(target, patch)
})
}
const _nowString = () => {
const d = new Date()
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
return {
// state
byCategory,
unreadTotal,
unreadByCategory,
activeCategory,
// computed
badgeTotal,
// actions
getCategoryState,
fetchUnreadCount,
fetchList,
loadMore,
markRead,
markAllRead,
setActiveCategory,
reset,
initWsListeners
}
})

View File

@@ -126,6 +126,11 @@ export const useUserStore = defineStore('user', () => {
const { useChatStore } = await import('@/store/chat')
useChatStore().resetVoicePlayedState()
} catch (_) { /* ignore */ }
// 清空通知中心缓存,避免账号切换后看到前一个用户的通知
try {
const { useNotifyStore } = await import('@/store/notify')
useNotifyStore().reset()
} catch (_) { /* ignore */ }
token.value = ''
userInfo.value = null
clearAll()