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:
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>
|
||||
Reference in New Issue
Block a user