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:
@@ -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(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
55
frontend/src/api/notify.js
Normal file
55
frontend/src/api/notify.js
Normal 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
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
功能:
|
||||
- 底部导航栏,4 个 Tab:消息 / 联系人 / 会议 / 我的
|
||||
- 选中态使用 filled 图标 + Primary 色,未选中态使用轮廓图标 + Muted 色
|
||||
- 消息 Tab 支持未读消息 badge,联系人 Tab 支持好友申请未读 badge
|
||||
- 消息 Tab / 联系人 Tab:数字 badge(getBadge)
|
||||
- 我的 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;
|
||||
|
||||
287
frontend/src/components/notify/NotifyItem.vue
Normal file
287
frontend/src/components/notify/NotifyItem.vue
Normal 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>
|
||||
92
frontend/src/constants/notify.js
Normal file
92
frontend/src/constants/notify.js
Normal 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
|
||||
}
|
||||
@@ -142,6 +142,13 @@
|
||||
"navigationBarTitleText": "会议"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/notify/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "通知中心",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
549
frontend/src/pages/notify/index.vue
Normal file
549
frontend/src/pages/notify/index.vue
Normal 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">‹</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">🔔</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>
|
||||
@@ -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">🔔</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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -286,17 +286,18 @@ export const useGroupStore = defineStore('group', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 新入群申请 → 提示通知 */
|
||||
const _onJoinRequest = (msg) => {
|
||||
if (!msg || !msg.data) return
|
||||
uni.showToast({ title: '收到新的入群申请', icon: 'none' })
|
||||
}
|
||||
/**
|
||||
* 新入群申请 → 不再 toast,由 notify store(notify.new 事件)统一负责提示和徽标
|
||||
* 保留空监听以便未来扩展(如高亮申请列表),避免重复弹窗
|
||||
*/
|
||||
const _onJoinRequest = () => {}
|
||||
|
||||
/** 入群申请通过 → 刷新会话列表 */
|
||||
/**
|
||||
* 入群申请通过 → 刷新会话列表(toast 由 notify store 统一处理)
|
||||
*/
|
||||
const _onJoinApproved = () => {
|
||||
const chatStore = useChatStore()
|
||||
chatStore.fetchConversations()
|
||||
uni.showToast({ title: '入群申请已通过', icon: 'none' })
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
352
frontend/src/store/notify.js
Normal file
352
frontend/src/store/notify.js
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user