## 后端 - 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
334 lines
7.9 KiB
Vue
334 lines
7.9 KiB
Vue
<!--
|
||
通知卡片组件
|
||
|
||
设计系统: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"
|
||
>
|
||
<!-- 会议邀请过期时:合并为单个 disabled 提示按钮 -->
|
||
<button
|
||
v-if="isExpired"
|
||
class="notify-btn notify-btn--ghost notify-btn--expired"
|
||
disabled
|
||
>
|
||
邀请已过期
|
||
</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>
|
||
</template>
|
||
|
||
<script setup>
|
||
/**
|
||
* 通知卡片
|
||
* 仅负责渲染和派发事件;具体业务动作由父页面实现
|
||
*/
|
||
import { computed, ref } from 'vue'
|
||
import {
|
||
NOTIFY_TYPE_ICON,
|
||
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'
|
||
|
||
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)
|
||
})
|
||
|
||
/** 内联按钮文案(按 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)
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/* 会议邀请过期态:合并成一个灰显按钮,占满整行 */
|
||
.notify-btn--expired {
|
||
color: #94A3B8;
|
||
}
|
||
</style>
|