1118 lines
28 KiB
Plaintext
1118 lines
28 KiB
Plaintext
<template>
|
||
<view class="messages-page">
|
||
<!-- 顶部过滤栏 -->
|
||
<view class="top-filter-bar">
|
||
<scroll-view scroll-x class="tabs-scroll" show-scrollbar="false">
|
||
<view class="tabs-wrapper">
|
||
<view
|
||
v-for="tab in messageTabs"
|
||
:key="tab.id"
|
||
:class="['tab-item', { active: activeTab === tab.id }]"
|
||
@click="switchTab(tab.id)"
|
||
>
|
||
<text class="tab-name">{{ tab.name }}</text>
|
||
<text v-if="getTabUnread(tab.id) > 0" class="tab-badge">{{ getTabUnread(tab.id) > 99 ? '99+' : getTabUnread(tab.id) }}</text>
|
||
<view v-if="activeTab === tab.id" class="tab-active-line"></view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
<view class="plus-btn" @click.stop="toggleActionMenu">
|
||
<text class="plus-icon">+</text>
|
||
</view>
|
||
|
||
<!-- 下拉菜单 -->
|
||
<view v-if="showActionMenu" class="action-menu" @click.stop>
|
||
<view class="menu-arrow"></view>
|
||
<view class="menu-item" @click.stop="onMarkAllRead">
|
||
<text class="menu-icon">✓</text>
|
||
<text class="menu-text">一键已读</text>
|
||
</view>
|
||
<view class="menu-item" @click.stop="goBatchDeletePage">
|
||
<text class="menu-icon">🗑</text>
|
||
<text class="menu-text">会话批量删除</text>
|
||
</view>
|
||
<view class="menu-item" @click.stop="onMessageSettings">
|
||
<text class="menu-icon">⚙</text>
|
||
<text class="menu-text">消息设置</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 遮罩层:点击关闭菜单 -->
|
||
<view v-if="showActionMenu" class="menu-overlay" @click="closeActionMenu"></view>
|
||
|
||
<!-- 消息列表 -->
|
||
<scroll-view
|
||
scroll-y
|
||
class="messages-content"
|
||
refresher-enabled
|
||
:refresher-triggered="refreshing"
|
||
@refresherrefresh="onRefresh"
|
||
:scroll-top="scrollTop"
|
||
>
|
||
<!-- 近期消息(一周内) -->
|
||
<view
|
||
v-for="message in recentMessages"
|
||
:key="message.id"
|
||
class="message-row"
|
||
@click="handleMessageClick(message)"
|
||
>
|
||
<view class="message-avatar-wrapper">
|
||
<image
|
||
v-if="message.avatar != ''"
|
||
class="message-avatar"
|
||
:src="message.avatar"
|
||
mode="aspectFill"
|
||
@error="message.avatar = ''"
|
||
/>
|
||
<view v-else class="message-icon-default" :style="{ backgroundColor: message.color }">
|
||
<text class="message-icon-text">{{ message.icon }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="message-body">
|
||
<view class="message-header-row">
|
||
<view class="message-title-area">
|
||
<text class="message-title">{{ message.title }}</text>
|
||
<text v-if="message.tagText != ''" class="message-tag">{{ message.tagText }}</text>
|
||
</view>
|
||
</view>
|
||
<text class="message-preview">{{ message.content }}</text>
|
||
</view>
|
||
<view class="message-meta">
|
||
<text class="message-time">{{ message.timeText }}</text>
|
||
<view v-if="!message.read && message.unreadCount > 0" class="unread-badge">{{ message.unreadCount > 99 ? '99+' : message.unreadCount }}</view>
|
||
<view v-else-if="!message.read" class="unread-dot-small"></view>
|
||
<view v-else class="unread-placeholder"></view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 折叠旧消息 -->
|
||
<view v-if="oldMessages.length > 0" class="folded-header" @click="toggleOldMessages">
|
||
<text class="folded-text">已折叠7天前的消息</text>
|
||
<text class="folded-action">{{ oldMessagesExpanded ? '收起' : '展开' }}</text>
|
||
</view>
|
||
|
||
<!-- 旧消息 -->
|
||
<view v-if="oldMessagesExpanded" class="old-section">
|
||
<view
|
||
v-for="message in oldMessages"
|
||
:key="message.id + '_old'"
|
||
class="message-row"
|
||
@click="handleMessageClick(message)"
|
||
>
|
||
<view class="message-avatar-wrapper">
|
||
<image
|
||
v-if="message.avatar != ''"
|
||
class="message-avatar"
|
||
:src="message.avatar"
|
||
mode="aspectFill"
|
||
@error="message.avatar = ''"
|
||
/>
|
||
<view v-else class="message-icon-default" :style="{ backgroundColor: message.color }">
|
||
<text class="message-icon-text">{{ message.icon }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="message-body">
|
||
<view class="message-header-row">
|
||
<view class="message-title-area">
|
||
<text class="message-title">{{ message.title }}</text>
|
||
<text v-if="message.tagText != ''" class="message-tag">{{ message.tagText }}</text>
|
||
</view>
|
||
</view>
|
||
<text class="message-preview">{{ message.content }}</text>
|
||
</view>
|
||
<view class="message-meta">
|
||
<text class="message-time">{{ message.timeText }}</text>
|
||
<view v-if="!message.read && message.unreadCount > 0" class="unread-badge">{{ message.unreadCount > 99 ? '99+' : message.unreadCount }}</view>
|
||
<view v-else-if="!message.read" class="unread-dot-small"></view>
|
||
<view v-else class="unread-placeholder"></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 空状态 -->
|
||
<view v-if="currentListEmpty" class="empty-messages">
|
||
<text class="empty-icon">💬</text>
|
||
<text class="empty-title">{{ activeTab === 'service' ? '暂无服务消息' : '暂无消息' }}</text>
|
||
<text class="empty-desc">暂时没有新消息</text>
|
||
</view>
|
||
|
||
<!-- 猜你喜欢 -->
|
||
<GuessYouLike title="猜你喜欢" :pageSize="8" />
|
||
|
||
<!-- 底部安全区域 -->
|
||
<view class="safe-area" style="height: 20px;"></view>
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import { ref, reactive, computed, onMounted } from 'vue'
|
||
import { onShow } from '@dcloudio/uni-app'
|
||
import { supabaseService, type Notification, type ChatRoom } from '@/utils/supabaseService.uts'
|
||
import GuessYouLike from '@/components/mall/GuessYouLike/GuessYouLike.uvue'
|
||
|
||
// 统一消息模型
|
||
type UnifiedMessageItem = {
|
||
id: string,
|
||
conversationKey: string,
|
||
sourceType: string,
|
||
category: string,
|
||
title: string,
|
||
content: string,
|
||
avatar: string,
|
||
icon: string,
|
||
color: string,
|
||
timeText: string,
|
||
timestamp: number,
|
||
read: boolean,
|
||
unreadCount: number,
|
||
isOfficial: boolean,
|
||
tagText: string,
|
||
merchantId: string,
|
||
orderNo: string,
|
||
routeType: string,
|
||
rawId: string
|
||
}
|
||
|
||
type MessageTab = {
|
||
id: string,
|
||
name: string
|
||
}
|
||
|
||
// 响应式数据
|
||
const activeTab = ref<string>('shopping')
|
||
const refreshing = ref<boolean>(false)
|
||
const loading = ref<boolean>(false)
|
||
const scrollTop = ref<number>(0)
|
||
const showActionMenu = ref<boolean>(false)
|
||
const oldMessagesExpanded = ref<boolean>(false)
|
||
const locallyDeletedIds = reactive<Array<string>>([])
|
||
const unifiedMessages = ref<Array<UnifiedMessageItem>>([])
|
||
|
||
// Tab 定义
|
||
const messageTabs = reactive<Array<MessageTab>>([
|
||
{ id: 'shopping', name: '购物' },
|
||
{ id: 'service', name: '服务' }
|
||
])
|
||
|
||
// 时间格式化:今天 HH:mm / 昨天 / 星期几 / YYYY/MM/DD
|
||
const formatMessageTime = (isoString: string): { timeText: string; timestamp: number } => {
|
||
if (isoString == '') {
|
||
return { timeText: '', timestamp: 0 }
|
||
}
|
||
let timestamp = 0
|
||
try {
|
||
timestamp = new Date(isoString).getTime()
|
||
} catch (e) {
|
||
return { timeText: isoString, timestamp: 0 }
|
||
}
|
||
if (timestamp <= 0) {
|
||
return { timeText: isoString, timestamp: 0 }
|
||
}
|
||
|
||
const nowDate = new Date()
|
||
const todayStart = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate()).getTime()
|
||
const yesterdayStart = todayStart - 24 * 60 * 60 * 1000
|
||
const weekAgo = todayStart - 7 * 24 * 60 * 60 * 1000
|
||
|
||
let timeText = ''
|
||
if (timestamp >= todayStart) {
|
||
const dt = new Date(timestamp)
|
||
const h = dt.getHours()
|
||
const min = dt.getMinutes()
|
||
const hh = h < 10 ? '0' + h : '' + h
|
||
const mm = min < 10 ? '0' + min : '' + min
|
||
timeText = hh + ':' + mm
|
||
} else if (timestamp >= yesterdayStart) {
|
||
timeText = '昨天'
|
||
} else if (timestamp >= weekAgo) {
|
||
const weekDays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||
timeText = weekDays[new Date(timestamp).getDay()]
|
||
} else {
|
||
const dt2 = new Date(timestamp)
|
||
const y = dt2.getFullYear()
|
||
const mo = dt2.getMonth() + 1
|
||
const day = dt2.getDate()
|
||
timeText = y + '/' + (mo < 10 ? '0' + mo : '' + mo) + '/' + (day < 10 ? '0' + day : '' + day)
|
||
}
|
||
|
||
return { timeText, timestamp }
|
||
}
|
||
|
||
// 从通知检测分类
|
||
const detectCategoryFromNote = (note: Notification): string => {
|
||
const type = note.type
|
||
const combined = note.title + ' ' + note.content
|
||
|
||
if (type == 'logistics') return 'logistics'
|
||
if (type == 'order') return 'notice'
|
||
if (type == 'promotion') return 'promo'
|
||
if (type == 'system') return 'notice'
|
||
|
||
const logisticsKeywords = ['物流', '配送', '快递', '已发货', '已签收']
|
||
for (let i = 0; i < logisticsKeywords.length; i++) {
|
||
if (combined.indexOf(logisticsKeywords[i]) >= 0) return 'logistics'
|
||
}
|
||
|
||
const tradeKeywords = ['支付', '订单', '退款', '交易', '待付款', '已取消']
|
||
for (let i = 0; i < tradeKeywords.length; i++) {
|
||
if (combined.indexOf(tradeKeywords[i]) >= 0) return 'notice'
|
||
}
|
||
|
||
const serviceKeywords = ['服务', '预约', '上门', '维修', '安装']
|
||
for (let i = 0; i < serviceKeywords.length; i++) {
|
||
if (combined.indexOf(serviceKeywords[i]) >= 0) return 'service'
|
||
}
|
||
|
||
if (type == 'promotion') return 'promo'
|
||
return 'notice'
|
||
}
|
||
|
||
// 排序:全部tab优先物流/交易+未读,其他按时间倒序
|
||
const sortMessages = (list: Array<UnifiedMessageItem>): Array<UnifiedMessageItem> => {
|
||
const arr: Array<UnifiedMessageItem> = []
|
||
for (let i = 0; i < list.length; i++) {
|
||
arr.push(list[i])
|
||
}
|
||
|
||
for (let i = 0; i < arr.length - 1; i++) {
|
||
for (let j = 0; j < arr.length - 1 - i; j++) {
|
||
const a = arr[j]
|
||
const b = arr[j + 1]
|
||
let shouldSwap = false
|
||
|
||
if (activeTab.value == 'all') {
|
||
const aIsPriority = (a.sourceType == 'logistics' || a.sourceType == 'trade')
|
||
const bIsPriority = (b.sourceType == 'logistics' || b.sourceType == 'trade')
|
||
if (aIsPriority != bIsPriority) {
|
||
shouldSwap = !aIsPriority && bIsPriority
|
||
} else {
|
||
const aUnread = a.unreadCount > 0
|
||
const bUnread = b.unreadCount > 0
|
||
if (aUnread != bUnread) {
|
||
shouldSwap = !aUnread && bUnread
|
||
} else {
|
||
shouldSwap = a.timestamp < b.timestamp
|
||
}
|
||
}
|
||
} else {
|
||
shouldSwap = a.timestamp < b.timestamp
|
||
}
|
||
|
||
if (shouldSwap) {
|
||
const temp = arr[j]
|
||
arr[j] = arr[j + 1]
|
||
arr[j + 1] = temp
|
||
}
|
||
}
|
||
}
|
||
return arr
|
||
}
|
||
|
||
// 判断消息是否属于 shopping
|
||
const isShoppingMessage = (msg: UnifiedMessageItem): boolean => {
|
||
return msg.sourceType == 'chat' || msg.category == 'product' || msg.category == 'notice' || msg.category == 'logistics' || msg.category == 'promo'
|
||
}
|
||
|
||
// 判断消息是否属于 service
|
||
const isServiceMessage = (msg: UnifiedMessageItem): boolean => {
|
||
return msg.category == 'service'
|
||
}
|
||
|
||
// 过滤+排序后的消息
|
||
const filteredMessages = computed<Array<UnifiedMessageItem>>(() => {
|
||
const tab = activeTab.value
|
||
const matched: Array<UnifiedMessageItem> = []
|
||
for (let i = 0; i < unifiedMessages.value.length; i++) {
|
||
const msg = unifiedMessages.value[i]
|
||
if (locallyDeletedIds.indexOf(msg.id) >= 0) continue
|
||
if (tab == 'shopping' && isShoppingMessage(msg)) {
|
||
matched.push(msg)
|
||
} else if (tab == 'service' && isServiceMessage(msg)) {
|
||
matched.push(msg)
|
||
}
|
||
}
|
||
return sortMessages(matched)
|
||
})
|
||
|
||
// 辅助:获取最近两个聊天的 id 列表
|
||
const getTopChatIds = (all: Array<UnifiedMessageItem>): Array<string> => {
|
||
const topChats: Array<UnifiedMessageItem> = []
|
||
for (let i = 0; i < all.length; i++) {
|
||
if (all[i].sourceType == 'chat') {
|
||
topChats.push(all[i])
|
||
}
|
||
}
|
||
const ids: Array<string> = []
|
||
for (let i = 0; i < topChats.length && i < 2; i++) {
|
||
ids.push(topChats[i].id)
|
||
}
|
||
return ids
|
||
}
|
||
|
||
// 近期消息:官方消息(物流/交易/系统)+ 最近两个聊天 + 一周内其他消息
|
||
const recentMessages = computed<Array<UnifiedMessageItem>>(() => {
|
||
const oneWeek = 7 * 24 * 60 * 60 * 1000
|
||
const now = new Date().getTime()
|
||
const all = filteredMessages.value
|
||
const topChatIds = getTopChatIds(all)
|
||
const result: Array<UnifiedMessageItem> = []
|
||
for (let i = 0; i < all.length; i++) {
|
||
const msg = all[i]
|
||
const isOfficialAlwaysShow = (msg.sourceType == 'logistics' || msg.sourceType == 'trade' || msg.sourceType == 'notification')
|
||
const isTopChat = topChatIds.indexOf(msg.id) >= 0
|
||
const isRecent = msg.timestamp > 0 && (now - msg.timestamp) <= oneWeek
|
||
if (isOfficialAlwaysShow || isTopChat || isRecent) {
|
||
result.push(msg)
|
||
}
|
||
}
|
||
return result
|
||
})
|
||
|
||
// 旧消息:一周前且非官方且非前两个聊天的消息
|
||
const oldMessages = computed<Array<UnifiedMessageItem>>(() => {
|
||
const oneWeek = 7 * 24 * 60 * 60 * 1000
|
||
const now = new Date().getTime()
|
||
const all = filteredMessages.value
|
||
const topChatIds = getTopChatIds(all)
|
||
const result: Array<UnifiedMessageItem> = []
|
||
for (let i = 0; i < all.length; i++) {
|
||
const msg = all[i]
|
||
const isOfficialAlwaysShow = (msg.sourceType == 'logistics' || msg.sourceType == 'trade' || msg.sourceType == 'notification')
|
||
const isTopChat = topChatIds.indexOf(msg.id) >= 0
|
||
const isRecent = msg.timestamp > 0 && (now - msg.timestamp) <= oneWeek
|
||
if (!isOfficialAlwaysShow && !isTopChat && !isRecent) {
|
||
result.push(msg)
|
||
}
|
||
}
|
||
return result
|
||
})
|
||
|
||
// 当前列表是否为空
|
||
const currentListEmpty = computed<boolean>(() => {
|
||
if (loading.value) return false
|
||
return recentMessages.value.length === 0 && oldMessages.value.length === 0
|
||
})
|
||
|
||
// Tab 未读数
|
||
const getTabUnread = (tabId: string): number => {
|
||
let count = 0
|
||
for (let i = 0; i < unifiedMessages.value.length; i++) {
|
||
const msg = unifiedMessages.value[i]
|
||
if (locallyDeletedIds.indexOf(msg.id) >= 0) continue
|
||
if (!msg.read) {
|
||
if (tabId == 'shopping' && isShoppingMessage(msg)) {
|
||
count = count + msg.unreadCount
|
||
} else if (tabId == 'service' && isServiceMessage(msg)) {
|
||
count = count + msg.unreadCount
|
||
}
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
// 加载消息
|
||
const loadMessages = async () => {
|
||
loading.value = true
|
||
try {
|
||
const dedupedMessages: Array<UnifiedMessageItem> = []
|
||
const keyIndexList: Array<{key: string, index: number}> = []
|
||
|
||
const addOrUpdateMessage = (item: UnifiedMessageItem) => {
|
||
let foundIndex = -1
|
||
for (let i = 0; i < keyIndexList.length; i++) {
|
||
if (keyIndexList[i].key == item.conversationKey) {
|
||
foundIndex = keyIndexList[i].index
|
||
break
|
||
}
|
||
}
|
||
if (foundIndex >= 0) {
|
||
if (item.timestamp > dedupedMessages[foundIndex].timestamp) {
|
||
dedupedMessages[foundIndex] = item
|
||
}
|
||
} else {
|
||
keyIndexList.push({ key: item.conversationKey, index: dedupedMessages.length })
|
||
dedupedMessages.push(item)
|
||
}
|
||
}
|
||
|
||
// 1. 获取通知
|
||
const notes = await supabaseService.getUserNotifications()
|
||
for (let i = 0; i < notes.length; i++) {
|
||
const note = notes[i]
|
||
const category = detectCategoryFromNote(note)
|
||
let sourceType = 'notification'
|
||
if (category == 'logistics') sourceType = 'logistics'
|
||
else if (category == 'notice' && note.type == 'order') sourceType = 'trade'
|
||
else if (category == 'promo') sourceType = 'promotion'
|
||
else if (category == 'service') sourceType = 'service'
|
||
|
||
let routeType = 'message-detail'
|
||
if (note.type == 'order') routeType = 'order-detail'
|
||
else if (note.type == 'promotion') routeType = 'coupons'
|
||
|
||
let orderNo = ''
|
||
if (note.extra_data != '') {
|
||
try {
|
||
const extraObj = JSON.parse(note.extra_data) as UTSJSONObject
|
||
const val = extraObj.get('order_id')
|
||
if (val != null && typeof val == 'string') {
|
||
orderNo = val
|
||
} else {
|
||
const val2 = extraObj.get('order_no')
|
||
if (val2 != null && typeof val2 == 'string') {
|
||
orderNo = val2
|
||
}
|
||
}
|
||
} catch (e) {
|
||
// ignore parse error
|
||
}
|
||
}
|
||
if (orderNo == '' && note.link_url != '') {
|
||
const idx = note.link_url.indexOf('id=')
|
||
if (idx >= 0) {
|
||
const rest = note.link_url.substring(idx + 3)
|
||
const endIdx = rest.indexOf('&')
|
||
orderNo = endIdx >= 0 ? rest.substring(0, endIdx) : rest
|
||
}
|
||
}
|
||
|
||
const timeInfo = formatMessageTime(note.created_at != null ? note.created_at : '')
|
||
|
||
const iconMap = new UTSJSONObject()
|
||
iconMap.set('logistics', '🚚')
|
||
iconMap.set('trade', '📦')
|
||
iconMap.set('promotion', '🎁')
|
||
iconMap.set('service', '🔧')
|
||
iconMap.set('notification', '📢')
|
||
|
||
const colorMap = new UTSJSONObject()
|
||
colorMap.set('logistics', '#07C160')
|
||
colorMap.set('trade', '#FF5000')
|
||
colorMap.set('promotion', '#FF9500')
|
||
colorMap.set('service', '#5856D6')
|
||
colorMap.set('notification', '#2196F3')
|
||
|
||
const iconVal = iconMap.get(sourceType)
|
||
const iconStr = (iconVal != null && typeof iconVal == 'string') ? (iconVal as string) : '📢'
|
||
const colorVal = colorMap.get(sourceType)
|
||
const colorStr = (colorVal != null && typeof colorVal == 'string') ? (colorVal as string) : '#2196F3'
|
||
|
||
const item: UnifiedMessageItem = {
|
||
id: note.id,
|
||
conversationKey: 'notification_' + note.type + '_' + note.id,
|
||
sourceType: sourceType,
|
||
category: category,
|
||
title: note.title,
|
||
content: note.content,
|
||
avatar: note.icon_url != null ? note.icon_url : '',
|
||
icon: iconStr,
|
||
color: colorStr,
|
||
timeText: timeInfo.timeText,
|
||
timestamp: timeInfo.timestamp,
|
||
read: note.is_read,
|
||
unreadCount: note.is_read ? 0 : 1,
|
||
isOfficial: (sourceType == 'logistics' || sourceType == 'trade' || sourceType == 'notification'),
|
||
tagText: (sourceType == 'logistics' || sourceType == 'trade' || sourceType == 'notification') ? '官方' : '',
|
||
merchantId: '',
|
||
orderNo: orderNo,
|
||
routeType: routeType,
|
||
rawId: note.id
|
||
}
|
||
addOrUpdateMessage(item)
|
||
}
|
||
|
||
// 2. 获取聊天会话
|
||
const rooms = await supabaseService.getChatRooms()
|
||
for (let i = 0; i < rooms.length; i++) {
|
||
const room = rooms[i]
|
||
const timeInfo = formatMessageTime(room.last_message_at != '' ? room.last_message_at : (room.updated_at != null ? room.updated_at : ''))
|
||
const item: UnifiedMessageItem = {
|
||
id: room.id,
|
||
conversationKey: 'chat_' + room.merchant_id,
|
||
sourceType: 'chat',
|
||
category: 'product',
|
||
title: room.shop_name,
|
||
content: room.last_message != null ? room.last_message : '暂无消息',
|
||
avatar: room.shop_logo != null ? room.shop_logo : '',
|
||
icon: '🏪',
|
||
color: '#FF9800',
|
||
timeText: timeInfo.timeText,
|
||
timestamp: timeInfo.timestamp,
|
||
read: room.unread_count === 0,
|
||
unreadCount: room.unread_count,
|
||
isOfficial: false,
|
||
tagText: '商家客服',
|
||
merchantId: room.merchant_id,
|
||
orderNo: '',
|
||
routeType: 'chat',
|
||
rawId: room.id
|
||
}
|
||
addOrUpdateMessage(item)
|
||
}
|
||
|
||
// 补充默认展示消息:物流、订单、两个聊天
|
||
let hasLogistics = false
|
||
let hasTrade = false
|
||
let chatCount = 0
|
||
for (let i = 0; i < dedupedMessages.length; i++) {
|
||
const msg = dedupedMessages[i]
|
||
if (msg.sourceType == 'logistics') hasLogistics = true
|
||
if (msg.sourceType == 'trade') hasTrade = true
|
||
if (msg.sourceType == 'chat') chatCount++
|
||
}
|
||
const now = new Date().getTime()
|
||
if (!hasLogistics) {
|
||
addOrUpdateMessage({
|
||
id: 'default_logistics_1',
|
||
conversationKey: 'default_logistics_1',
|
||
sourceType: 'logistics',
|
||
category: 'logistics',
|
||
title: '物流助手',
|
||
content: '您的订单已发货,预计3天内送达',
|
||
avatar: '',
|
||
icon: '🚚',
|
||
color: '#07C160',
|
||
timeText: '刚刚',
|
||
timestamp: now,
|
||
read: false,
|
||
unreadCount: 1,
|
||
isOfficial: true,
|
||
tagText: '官方',
|
||
merchantId: '',
|
||
orderNo: 'DEFAULT001',
|
||
routeType: 'message-detail',
|
||
rawId: 'default_logistics_1'
|
||
})
|
||
}
|
||
if (!hasTrade) {
|
||
addOrUpdateMessage({
|
||
id: 'default_trade_1',
|
||
conversationKey: 'default_trade_1',
|
||
sourceType: 'trade',
|
||
category: 'notice',
|
||
title: '交易通知',
|
||
content: '您的订单待付款,请及时支付',
|
||
avatar: '',
|
||
icon: '📦',
|
||
color: '#FF5000',
|
||
timeText: '刚刚',
|
||
timestamp: now,
|
||
read: false,
|
||
unreadCount: 1,
|
||
isOfficial: true,
|
||
tagText: '官方',
|
||
merchantId: '',
|
||
orderNo: 'DEFAULT002',
|
||
routeType: 'order-detail',
|
||
rawId: 'default_trade_1'
|
||
})
|
||
}
|
||
while (chatCount < 2) {
|
||
chatCount++
|
||
const isFirst = chatCount == 1
|
||
addOrUpdateMessage({
|
||
id: 'default_chat_' + chatCount,
|
||
conversationKey: 'chat_default_' + chatCount,
|
||
sourceType: 'chat',
|
||
category: 'product',
|
||
title: isFirst ? '平台客服' : '官方客服',
|
||
content: isFirst ? '您好,有什么可以帮您?' : '欢迎咨询,我们随时为您服务',
|
||
avatar: '/static/icons/customer-service.png',
|
||
icon: '🏪',
|
||
color: '#FF9800',
|
||
timeText: '刚刚',
|
||
timestamp: now,
|
||
read: !isFirst,
|
||
unreadCount: isFirst ? 1 : 0,
|
||
isOfficial: false,
|
||
tagText: '商家客服',
|
||
merchantId: 'default_' + chatCount,
|
||
orderNo: '',
|
||
routeType: 'chat',
|
||
rawId: 'default_chat_' + chatCount
|
||
})
|
||
}
|
||
|
||
// 赋值到 unifiedMessages(直接替换,避免 reactive 数组追加问题)
|
||
unifiedMessages.value = dedupedMessages
|
||
} catch (e) {
|
||
console.error('加载消息失败', e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 点击消息项
|
||
const handleMessageClick = (message: UnifiedMessageItem) => {
|
||
// 本地标记已读
|
||
message.read = true
|
||
message.unreadCount = 0
|
||
|
||
// 跳转与数据库同步
|
||
if (message.sourceType == 'chat') {
|
||
supabaseService.markChatRoomRead(message.merchantId)
|
||
uni.navigateTo({
|
||
url: '/pages/mall/consumer/chat?merchantId=' + message.merchantId + '&merchantName=' + encodeURIComponent(message.title)
|
||
})
|
||
} else if (message.routeType == 'order-detail') {
|
||
supabaseService.markUserNotificationRead(message.rawId)
|
||
uni.navigateTo({
|
||
url: '/pages/mall/consumer/order-detail?id=' + (message.orderNo != '' ? message.orderNo : message.rawId)
|
||
})
|
||
} else if (message.routeType == 'coupons') {
|
||
supabaseService.markUserNotificationRead(message.rawId)
|
||
uni.navigateTo({
|
||
url: '/pages/mall/consumer/coupons'
|
||
})
|
||
} else {
|
||
supabaseService.markUserNotificationRead(message.rawId)
|
||
uni.navigateTo({
|
||
url: '/pages/mall/consumer/message-detail?id=' + message.rawId + '&type=' + message.sourceType
|
||
})
|
||
}
|
||
}
|
||
|
||
// Tab 切换
|
||
const switchTab = (tabId: string) => {
|
||
activeTab.value = tabId
|
||
scrollTop.value = scrollTop.value === 0 ? 0.01 : 0
|
||
}
|
||
|
||
// + 菜单
|
||
const toggleActionMenu = () => {
|
||
console.log('[messages] toggleActionMenu before:', showActionMenu.value)
|
||
showActionMenu.value = !showActionMenu.value
|
||
console.log('[messages] toggleActionMenu after:', showActionMenu.value)
|
||
}
|
||
|
||
const closeActionMenu = () => {
|
||
showActionMenu.value = false
|
||
}
|
||
|
||
// 一键已读
|
||
const onMarkAllRead = async () => {
|
||
closeActionMenu()
|
||
console.log('[messages] onMarkAllRead')
|
||
|
||
const unreadList = unifiedMessages.value.filter((item: UnifiedMessageItem) => {
|
||
return !item.read || item.unreadCount > 0
|
||
})
|
||
if (unreadList.length === 0) {
|
||
uni.showToast({ title: '暂无未读消息', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
uni.showLoading({ title: '处理中...' })
|
||
try {
|
||
const ok = await supabaseService.markAllMessagesRead()
|
||
for (let i = 0; i < unifiedMessages.value.length; i++) {
|
||
unifiedMessages.value[i].read = true
|
||
unifiedMessages.value[i].unreadCount = 0
|
||
}
|
||
uni.showToast({ title: '已全部标为已读', icon: 'success' })
|
||
} catch (error) {
|
||
console.error('[messages] 一键已读失败:', error)
|
||
uni.showToast({ title: '一键已读失败,请稍后重试', icon: 'none' })
|
||
} finally {
|
||
uni.hideLoading()
|
||
}
|
||
}
|
||
|
||
// 消息设置
|
||
const onMessageSettings = () => {
|
||
closeActionMenu()
|
||
console.log('[messages] onMessageSettings')
|
||
uni.showToast({ title: '消息设置开发中', icon: 'none' })
|
||
}
|
||
|
||
// 跳转批量删除页面
|
||
const goBatchDeletePage = () => {
|
||
closeActionMenu()
|
||
console.log('[messages] goBatchDeletePage tab:', activeTab.value)
|
||
uni.navigateTo({
|
||
url: '/pages/mall/consumer/message-batch-delete?tab=' + activeTab.value
|
||
})
|
||
}
|
||
|
||
// 展开/收起旧消息
|
||
const toggleOldMessages = () => {
|
||
oldMessagesExpanded.value = !oldMessagesExpanded.value
|
||
}
|
||
|
||
// 下拉刷新
|
||
const onRefresh = () => {
|
||
refreshing.value = true
|
||
setTimeout(() => {
|
||
loadMessages()
|
||
refreshing.value = false
|
||
}, 800)
|
||
}
|
||
|
||
// 生命周期
|
||
onMounted(() => {
|
||
loadMessages()
|
||
})
|
||
|
||
onShow(() => {
|
||
loadMessages()
|
||
})
|
||
</script>
|
||
<style>
|
||
/* 页面基础 */
|
||
.messages-page {
|
||
width: 100%;
|
||
min-height: 100vh;
|
||
background-color: #fefefe;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
/* 顶部过滤栏 */
|
||
.top-filter-bar {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
background-color: #fefefe;
|
||
border-bottom: 1px solid #f2f2f2;
|
||
height: 44px;
|
||
padding: 0 12px;
|
||
position: relative;
|
||
z-index: 9000;
|
||
flex-shrink: 0;
|
||
overflow: visible;
|
||
}
|
||
|
||
.tabs-scroll {
|
||
flex: 1;
|
||
height: 100%;
|
||
}
|
||
|
||
.tabs-wrapper {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
height: 100%;
|
||
}
|
||
|
||
.tab-item {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100%;
|
||
padding: 0 10px;
|
||
position: relative;
|
||
}
|
||
|
||
.tab-name {
|
||
font-size: 14px;
|
||
color: #333;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.tab-item.active .tab-name {
|
||
color: #ff3b30;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tab-active-line {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: calc(50% - 10px);
|
||
width: 20px;
|
||
height: 2px;
|
||
background-color: #ff3b30;
|
||
border-radius: 1px;
|
||
}
|
||
|
||
.tab-badge {
|
||
background-color: #ff3b30;
|
||
color: #fff;
|
||
font-size: 10px;
|
||
padding: 0 4px;
|
||
border-radius: 8px;
|
||
min-width: 14px;
|
||
height: 14px;
|
||
line-height: 14px;
|
||
text-align: center;
|
||
font-weight: bold;
|
||
margin-left: 4px;
|
||
}
|
||
|
||
/* + 按钮 */
|
||
.plus-btn {
|
||
width: 40px;
|
||
height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-left: 4px;
|
||
flex-shrink: 0;
|
||
z-index: 9020;
|
||
}
|
||
|
||
.plus-icon {
|
||
font-size: 24px;
|
||
color: #222222;
|
||
line-height: 24px;
|
||
}
|
||
|
||
/* 下拉菜单 */
|
||
.action-menu {
|
||
position: absolute;
|
||
right: 8px;
|
||
top: 40px;
|
||
width: 168px;
|
||
background-color: #222222;
|
||
border-radius: 10px;
|
||
padding-top: 6px;
|
||
padding-bottom: 6px;
|
||
z-index: 9050;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||
}
|
||
|
||
.menu-arrow {
|
||
position: absolute;
|
||
right: 18px;
|
||
top: -6px;
|
||
width: 12px;
|
||
height: 12px;
|
||
background-color: #222222;
|
||
transform: rotate(45deg);
|
||
}
|
||
|
||
.menu-item {
|
||
height: 44px;
|
||
padding-left: 14px;
|
||
padding-right: 14px;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.menu-icon {
|
||
width: 24px;
|
||
font-size: 15px;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.menu-text {
|
||
font-size: 14px;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.menu-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0);
|
||
z-index: 8990;
|
||
}
|
||
|
||
/* 消息列表区 */
|
||
.messages-content {
|
||
flex: 1;
|
||
height: 0;
|
||
width: 100%;
|
||
background-color: #fefefe;
|
||
}
|
||
|
||
/* 消息行 */
|
||
.message-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
padding: 12px 16px;
|
||
background-color: #fff;
|
||
border-bottom: 1px solid #f5f5f5;
|
||
}
|
||
|
||
.message-row:active {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
/* 头像区域 */
|
||
.message-avatar-wrapper {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 8px;
|
||
position: relative;
|
||
flex-shrink: 0;
|
||
margin-right: 12px;
|
||
}
|
||
|
||
.message-avatar {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.message-icon-default {
|
||
width: 48px;
|
||
height: 48px;
|
||
border-radius: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.message-icon-text {
|
||
font-size: 24px;
|
||
}
|
||
|
||
/* 消息主体 */
|
||
.message-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.message-header-row {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.message-title-area {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.message-title {
|
||
font-size: 16px;
|
||
color: #1a1a1a;
|
||
font-weight: 500;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.message-tag {
|
||
font-size: 10px;
|
||
color: #ff3b30;
|
||
border: 1px solid #ff3b30;
|
||
padding: 0 4px;
|
||
border-radius: 2px;
|
||
margin-left: 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.message-preview {
|
||
font-size: 13px;
|
||
color: #8a8a8a;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* 右侧元信息 */
|
||
.message-meta {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
margin-left: 8px;
|
||
flex-shrink: 0;
|
||
min-width: 40px;
|
||
}
|
||
|
||
.message-time {
|
||
font-size: 12px;
|
||
color: #999;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.unread-badge {
|
||
background-color: #ff3b30;
|
||
color: #fff;
|
||
font-size: 10px;
|
||
padding: 0 5px;
|
||
height: 16px;
|
||
line-height: 16px;
|
||
border-radius: 999px;
|
||
min-width: 16px;
|
||
text-align: center;
|
||
font-weight: bold;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.unread-dot-small {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 4px;
|
||
background-color: #ff3b30;
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.unread-placeholder {
|
||
height: 16px;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* 折叠区 */
|
||
.folded-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 14px;
|
||
background-color: #fefefe;
|
||
}
|
||
|
||
.folded-text {
|
||
font-size: 13px;
|
||
color: #999;
|
||
}
|
||
|
||
.folded-action {
|
||
font-size: 13px;
|
||
color: #576b95;
|
||
margin-left: 8px;
|
||
}
|
||
|
||
.old-section {
|
||
background-color: #fff;
|
||
}
|
||
|
||
/* 空状态 */
|
||
.empty-messages {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding-top: 100px;
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 60px;
|
||
margin-bottom: 16px;
|
||
opacity: 0.3;
|
||
}
|
||
|
||
.empty-title {
|
||
font-size: 16px;
|
||
color: #333;
|
||
font-weight: bold;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.empty-desc {
|
||
font-size: 14px;
|
||
color: #999;
|
||
}
|
||
|
||
.safe-area {
|
||
width: 100%;
|
||
}
|
||
</style>
|