feat(meeting): Task 13 完成 meeting_invite 通知卡片对接
## 后端 - MeetingService.InviteUsers 的 PushPayload.Extra 补齐设计 §10.1 要求字段: inviter_id / inviter_name / inviter_avatar / expired_at(Unix 秒, 与 Redis TTL MeetingInviteTokenTTL=600s 对齐) ## 前端 - constants/notify.js:supportsInlineAction 扩展支持 meeting_invite; 新增 NOTIFY_INLINE_ACTION_LABEL 映射(meeting_invite → 立即加入/稍后) + NOTIFY_INLINE_ACTION_DEFAULT 兜底 - components/notify/NotifyItem.vue: * 按 type 动态渲染按钮文案(actionLabel 计算属性) * isExpired 计算属性:meeting_invite 比对 extra.expired_at * 1000 * 过期态合并为单个 disabled 的"邀请已过期"按钮 * 新增 .notify-btn--expired 灰显样式 - pages/notify/index.vue: * handleAccept / handleReject / _navigateByNotify 增加 meeting_invite 分支 * 新增 _navigateToMeetingInvite 辅助:跳 /pages/meeting/preview?mode=join&code=xxx * 过期时 toast "邀请已过期"不跳转,"稍后"仅 markRead 不发接口 ## 文档 - docs/plans/2026-04-21-phase2e-2-implementation.plan.md:Task 13 标 ✅ + 详细产出 - docs/progress/CURRENT_STATUS.md:新增 Task 13 专节(关键技术点 / 验证记录 / 下一步) - .cursor/rules/project-context.mdc:2e-2 进度刷 Task 0-13 ✅ ## 验证 后端 curl 双角色端到端(testuser1 创会 → 邀请 testuser2 → 拉通知列表): - type=meeting_invite,extra 8 字段齐全 - expired_at = invited_at + 600,与 Redis TTL 对齐 Made-with: Cursor
This commit is contained in:
@@ -34,25 +34,35 @@
|
||||
</view>
|
||||
<text class="notify-content" v-if="notify.content">{{ notify.content }}</text>
|
||||
|
||||
<!-- 内联操作按钮:仅限群聊邀请 / 入群申请 -->
|
||||
<!-- 内联操作按钮:群聊邀请 / 入群申请 / 会议邀请 -->
|
||||
<view
|
||||
v-if="showActions"
|
||||
class="notify-actions"
|
||||
>
|
||||
<!-- 会议邀请过期时:合并为单个 disabled 提示按钮 -->
|
||||
<button
|
||||
class="notify-btn notify-btn--primary"
|
||||
:disabled="processing"
|
||||
@tap.stop="handleAccept"
|
||||
v-if="isExpired"
|
||||
class="notify-btn notify-btn--ghost notify-btn--expired"
|
||||
disabled
|
||||
>
|
||||
接受
|
||||
</button>
|
||||
<button
|
||||
class="notify-btn notify-btn--ghost"
|
||||
:disabled="processing"
|
||||
@tap.stop="handleReject"
|
||||
>
|
||||
拒绝
|
||||
邀请已过期
|
||||
</button>
|
||||
<template v-else>
|
||||
<button
|
||||
class="notify-btn notify-btn--primary"
|
||||
:disabled="processing"
|
||||
@tap.stop="handleAccept"
|
||||
>
|
||||
{{ actionLabel.accept }}
|
||||
</button>
|
||||
<button
|
||||
class="notify-btn notify-btn--ghost"
|
||||
:disabled="processing"
|
||||
@tap.stop="handleReject"
|
||||
>
|
||||
{{ actionLabel.reject }}
|
||||
</button>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -69,6 +79,9 @@ import {
|
||||
NOTIFY_DEFAULT_ICON,
|
||||
NOTIFY_TYPE_LABEL,
|
||||
NOTIFY_CATEGORY_COLOR,
|
||||
NOTIFY_TYPE_MEETING_INVITE,
|
||||
NOTIFY_INLINE_ACTION_LABEL,
|
||||
NOTIFY_INLINE_ACTION_DEFAULT,
|
||||
supportsInlineAction
|
||||
} from '@/constants/notify'
|
||||
|
||||
@@ -110,6 +123,34 @@ const showActions = computed(() => {
|
||||
return supportsInlineAction(props.notify.type)
|
||||
})
|
||||
|
||||
/** 内联按钮文案(按 type 映射;兜底为"接受/拒绝") */
|
||||
const actionLabel = computed(() => {
|
||||
return NOTIFY_INLINE_ACTION_LABEL[props.notify.type] || NOTIFY_INLINE_ACTION_DEFAULT
|
||||
})
|
||||
|
||||
/**
|
||||
* 会议邀请是否已过期
|
||||
* extra.expired_at 为 Unix 秒,与后端 Redis TTL 保持一致
|
||||
* 非 meeting_invite 或无 expired_at 时一律返回 false
|
||||
*/
|
||||
const isExpired = computed(() => {
|
||||
if (props.notify.type !== NOTIFY_TYPE_MEETING_INVITE) return false
|
||||
const extra = _parseExtra(props.notify.extra)
|
||||
if (!extra || !extra.expired_at) return false
|
||||
return Number(extra.expired_at) * 1000 < Date.now()
|
||||
})
|
||||
|
||||
/** 容错解析 extra:后端入库后以 JSON 字符串下发,也可能已是对象 */
|
||||
function _parseExtra(extra) {
|
||||
if (!extra) return null
|
||||
if (typeof extra === 'object') return extra
|
||||
try {
|
||||
return JSON.parse(extra)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleTap = () => {
|
||||
emit('item-tap', props.notify)
|
||||
}
|
||||
@@ -284,4 +325,9 @@ const formatTime = (str) => {
|
||||
background-color: #F1F5F9;
|
||||
color: #1E293B;
|
||||
}
|
||||
|
||||
/* 会议邀请过期态:合并成一个灰显按钮,占满整行 */
|
||||
.notify-btn--expired {
|
||||
color: #94A3B8;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -82,11 +82,29 @@ export const NOTIFY_CATEGORY_COLOR = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断该通知是否支持内联操作(接受/拒绝按钮)
|
||||
* 仅 group_invite 和 group_join_request 支持,对方待处理状态
|
||||
* 判断该通知是否支持内联操作按钮
|
||||
* - group_invite / group_join_request:接受 / 拒绝
|
||||
* - meeting_invite:立即加入 / 稍后(Phase 2e-2 Task 13 接入)
|
||||
* @param {string} type
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const supportsInlineAction = (type) => {
|
||||
return type === NOTIFY_TYPE_GROUP_INVITE || type === NOTIFY_TYPE_GROUP_JOIN_REQUEST
|
||||
return (
|
||||
type === NOTIFY_TYPE_GROUP_INVITE ||
|
||||
type === NOTIFY_TYPE_GROUP_JOIN_REQUEST ||
|
||||
type === NOTIFY_TYPE_MEETING_INVITE
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 内联按钮文案映射
|
||||
* 组件内根据 type 自动渲染,默认走"接受 / 拒绝"
|
||||
*/
|
||||
export const NOTIFY_INLINE_ACTION_LABEL = {
|
||||
[NOTIFY_TYPE_GROUP_INVITE]: { accept: '接受', reject: '拒绝' },
|
||||
[NOTIFY_TYPE_GROUP_JOIN_REQUEST]: { accept: '接受', reject: '拒绝' },
|
||||
[NOTIFY_TYPE_MEETING_INVITE]: { accept: '立即加入', reject: '稍后' }
|
||||
}
|
||||
|
||||
/** 默认按钮文案(兜底) */
|
||||
export const NOTIFY_INLINE_ACTION_DEFAULT = { accept: '接受', reject: '拒绝' }
|
||||
|
||||
@@ -109,7 +109,8 @@ import {
|
||||
NOTIFY_TYPE_FRIEND_REQUEST,
|
||||
NOTIFY_TYPE_GROUP_INVITE,
|
||||
NOTIFY_TYPE_GROUP_JOIN_REQUEST,
|
||||
NOTIFY_TYPE_GROUP_JOIN_APPROVED
|
||||
NOTIFY_TYPE_GROUP_JOIN_APPROVED,
|
||||
NOTIFY_TYPE_MEETING_INVITE
|
||||
} from '@/constants/notify'
|
||||
import NotifyItem from '@/components/notify/NotifyItem.vue'
|
||||
import groupApi from '@/api/group'
|
||||
@@ -225,7 +226,9 @@ const handleNotifyTap = async (notify) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 群邀请/入群申请 —— 内联“接受”
|
||||
* 内联"接受"按钮
|
||||
* - group_invite / group_join_request:沿用原逻辑
|
||||
* - meeting_invite:立即加入,跳预览页(mode=join&code=...)
|
||||
*/
|
||||
const handleAccept = async (notify) => {
|
||||
try {
|
||||
@@ -240,6 +243,11 @@ const handleAccept = async (notify) => {
|
||||
const extra = _parseExtra(notify.extra)
|
||||
uni.showToast({ title: '已接受邀请', icon: 'success' })
|
||||
_navigateToGroup(extra)
|
||||
} else if (notify.type === NOTIFY_TYPE_MEETING_INVITE) {
|
||||
const extra = _parseExtra(notify.extra)
|
||||
if (!_navigateToMeetingInvite(extra)) {
|
||||
uni.showToast({ title: '邀请信息已失效', icon: 'none' })
|
||||
}
|
||||
}
|
||||
await notifyStore.markRead(notify.id).catch(() => {})
|
||||
} catch (e) {
|
||||
@@ -249,7 +257,9 @@ const handleAccept = async (notify) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 群邀请/入群申请 —— 内联“拒绝”
|
||||
* 内联"拒绝/稍后"按钮
|
||||
* - group_invite / group_join_request:沿用原逻辑
|
||||
* - meeting_invite:仅标记已读,不弹 toast("稍后"是轻量操作)
|
||||
*/
|
||||
const handleReject = async (notify) => {
|
||||
try {
|
||||
@@ -290,6 +300,11 @@ const _navigateByNotify = (notify) => {
|
||||
case NOTIFY_TYPE_GROUP_JOIN_APPROVED:
|
||||
_navigateToGroup(extra)
|
||||
return
|
||||
case NOTIFY_TYPE_MEETING_INVITE:
|
||||
if (_navigateToMeetingInvite(extra)) return
|
||||
// 过期或 extra 缺失时给用户提示
|
||||
uni.showToast({ title: '邀请已过期或信息缺失', icon: 'none' })
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -311,6 +326,25 @@ const _navigateToGroup = (extra) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转到会议预览页(meeting_invite 卡片专用)
|
||||
*
|
||||
* 路由协议:/pages/meeting/preview?mode=join&code=xxx
|
||||
* - 预览页负责让用户确认设备、输入密码(若 has_password),再调 joinAndEnter
|
||||
* - invite_token 当前不在 URL 透传:Redis 写入 600s,Token 保留冗余兑换
|
||||
* 后端 RedeemInviteToken 目前在独立接口;MVP 阶段仅凭 room_code 加入即可
|
||||
*
|
||||
* @returns {boolean} 是否成功发起跳转(用于调用方判断是否需要 fallback 提示)
|
||||
*/
|
||||
const _navigateToMeetingInvite = (extra) => {
|
||||
if (!extra || !extra.room_code) return false
|
||||
if (extra.expired_at && Number(extra.expired_at) * 1000 < Date.now()) {
|
||||
return false
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/meeting/preview?mode=join&code=${encodeURIComponent(extra.room_code)}` })
|
||||
return true
|
||||
}
|
||||
|
||||
/** 解析 extra(后端以 JSON 字符串存储) */
|
||||
const _parseExtra = (extra) => {
|
||||
if (!extra) return null
|
||||
|
||||
Reference in New Issue
Block a user