Files
medical-mall/pages/mall/merchant/messages.uvue
2026-03-24 00:21:19 +08:00

287 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 商家端 - 消息中心页面 -->
<template>
<view class="messages-page">
<!-- #ifdef MP-WEIXIN -->
<!-- Tab 页无返回按鈕,展示顶部安全区 + 页面标题 -->
<view class="mp-tab-navbar">
<text class="mp-tab-title">消息</text>
</view>
<!-- #endif -->
<view class="header">
<text class="header-title">消息</text>
<text class="header-subtitle">与客户的聊天记录</text>
</view>
<scroll-view class="messages-list" direction="vertical" :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh">
<view v-if="loading && conversations.length === 0" class="ske-msg-list">
<view v-for="n in 5" :key="n" class="ske-conv-item">
<view class="ske-avatar-circle"></view>
<view class="ske-conv-info">
<view class="ske-conv-top">
<view class="ske-bar ske-w50 ske-h28"></view>
<view class="ske-bar ske-w20 ske-h22"></view>
</view>
<view class="ske-bar ske-w75 ske-h22 ske-mt10"></view>
</view>
</view>
</view>
<view v-else-if="conversations.length === 0" class="empty-container">
<text class="empty-icon">💬</text>
<text class="empty-text">暂无会话</text>
<text class="empty-hint">有客户消息时会在这里显示</text>
</view>
<view v-else class="conv-list">
<view v-for="conv in conversations" :key="conv.sessionId" class="conversation-card" @click="goToChat(conv)">
<view class="conv-avatar-wrap">
<image class="conv-avatar" :src="conv.avatar || '/static/images/default-avatar.png'" mode="aspectFill" />
<view v-if="conv.unread > 0" class="online-dot"></view>
</view>
<view class="conv-info">
<view class="conv-row">
<text class="conv-name">{{ conv.name }}</text>
<text class="conv-time">{{ conv.lastTime }}</text>
</view>
<view class="conv-bottom">
<text class="conv-preview">{{ conv.lastMessage }}</text>
<view v-if="conv.unread > 0" class="unread-badge">
<text class="unread-num">{{ conv.unread > 99 ? '99+' : conv.unread }}</text>
</view>
</view>
</view>
<text class="conv-arrow"></text>
</view>
</view>
<view class="safe-bottom"></view>
</scroll-view>
<!-- 商家端自定义 TabBar -->
<merchant-tab-bar :current="1"></merchant-tab-bar>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import MerchantTabBar from '@/components/merchant-tabbar/MerchantTabBar.uvue'
type MessageType = {
id: string
session_id: string
sender_id: string
receiver_id: string
content: string
msg_type: string
is_read: boolean
is_from_user: boolean
created_at: string
}
type ConversationType = {
sessionId: string
name: string
avatar: string
lastMessage: string
lastTime: string
lastTimeRaw: string
unread: number
userId: string
}
export default {
components: { MerchantTabBar },
data() {
return {
conversations: [] as ConversationType[],
loading: false,
refreshing: false,
merchantId: '',
isPageReady: false
}
},
onLoad() {
this.initMerchantId()
},
onShow() {
// 先从缓存恢复会话列表,消除白屏
try {
const raw = uni.getStorageSync('merchant_msg_cache')
if (raw != null && raw !== '') {
const arr = JSON.parse(raw as string) as ConversationType[]
if (arr != null && (arr as any[]).length > 0) {
this.conversations = arr
this.isPageReady = true
}
}
} catch(e) {}
this.loadMessages()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
} catch (e) {}
},
async loadMessages() {
this.loading = true
try {
const query = supa
.from('ml_chat_messages')
.select('*')
.or(`receiver_id.eq.${this.merchantId},sender_id.eq.${this.merchantId}`)
.order('created_at', { ascending: false })
.limit(100)
const response = await query.execute()
if (response.error != null || !response.data) {
this.conversations = []
return
}
const rawData = response.data as any[]
const sessionMap = new Map<string, ConversationType>()
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const isFromUser = item.getBoolean('is_from_user') || false
const senderId = item.getString('sender_id') || ''
const receiverId = item.getString('receiver_id') || ''
// is_from_user=true 表示消息来自用户sender_id是用户ID
// is_from_user=false 表示消息来自商家receiver_id是用户ID
const otherUserId = isFromUser ? senderId : receiverId
// 只用 otherUserId 分组,确保每个客户只有一个会话
if (otherUserId === '' || otherUserId === this.merchantId) continue
const isRead = item.getBoolean('is_read') || false
const content = item.getString('content') || ''
const createdAt = item.getString('created_at') || ''
const sessionId = item.getString('session_id') || otherUserId
if (!sessionMap.has(otherUserId)) {
sessionMap.set(otherUserId, {
sessionId: sessionId,
name: '客户',
avatar: '',
lastMessage: content,
lastTime: this.formatTime(createdAt),
lastTimeRaw: createdAt,
unread: 0,
userId: otherUserId
})
}
const conv = sessionMap.get(otherUserId)!
// 更新最后一条消息(按时间最新的)
if (createdAt > conv.lastTimeRaw) {
conv.lastMessage = content
conv.lastTime = this.formatTime(createdAt)
conv.lastTimeRaw = createdAt
}
// 未读消息:消息来自用户且未读
if (!isRead && isFromUser) {
conv.unread++
}
}
this.conversations = Array.from(sessionMap.values()).sort((a, b) => b.unread - a.unread)
// 保存缓存
try {
uni.setStorageSync('merchant_msg_cache', JSON.stringify(this.conversations))
} catch(e) {}
} catch (e) {
console.error('加载消息失败:', e)
} finally {
this.loading = false
this.refreshing = false
this.isPageReady = true
}
},
goToChat(conv: ConversationType) {
uni.navigateTo({
url: `/pages/mall/merchant/chat?user_id=${conv.userId}&session_id=${conv.sessionId}&title=${encodeURIComponent(conv.name)}`
})
},
onRefresh() {
this.refreshing = true
this.loadMessages()
},
formatTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const hours = Math.floor(diff / (1000 * 60 * 60))
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (hours < 1) return '刚刚'
if (hours < 24) return `${hours}小时前`
if (days === 1) return '昨天'
if (days < 7) return `${days}天前`
return `${date.getMonth() + 1}-${date.getDate()}`
}
}
}
</script>
<style>
.messages-page { background-color: #f5f7fa; min-height: 100vh; display: flex; flex-direction: column; }
.mp-tab-navbar { height: calc(88rpx + var(--status-bar-height)); padding-top: var(--status-bar-height); background-color: #ffffff; display: flex; flex-direction: row; align-items: center; justify-content: center; border-bottom-width: 1rpx; border-bottom-style: solid; border-bottom-color: #f0f0f0; }
.mp-tab-title { font-size: 34rpx; font-weight: bold; color: #333333; }
.header { background-color: #fff; padding-top: 60rpx; padding-bottom: 24rpx; padding-left: 30rpx; padding-right: 30rpx; border-bottom-width: 1rpx; border-bottom-style: solid; border-bottom-color: #eee; }
.header-title { font-size: 44rpx; font-weight: bold; color: #333; display: block; margin-bottom: 8rpx; }
.header-subtitle { font-size: 26rpx; color: #999; }
.messages-list { flex: 1; padding: 20rpx; }
.loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding-top: 100rpx; padding-bottom: 100rpx; }
.loading-icon { font-size: 60rpx; margin-bottom: 20rpx; }
.loading-text { font-size: 28rpx; color: #999; }
.empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding-top: 100rpx; padding-bottom: 100rpx; }
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
.empty-text { font-size: 32rpx; color: #666; margin-bottom: 10rpx; }
.empty-hint { font-size: 26rpx; color: #999; }
.conv-list { display: flex; flex-direction: column; }
.conversation-card { display: flex; flex-direction: row; align-items: center; background-color: #fff; border-radius: 20rpx; padding: 24rpx; margin-bottom: 16rpx; }
.conv-avatar-wrap { position: relative; margin-right: 20rpx; }
.conv-avatar { width: 100rpx; height: 100rpx; border-radius: 50rpx; background-color: #e0e0e0; }
.online-dot { position: absolute; bottom: 4rpx; right: 4rpx; width: 24rpx; height: 24rpx; background-color: #4CAF50; border-radius: 12rpx; border-width: 3rpx; border-style: solid; border-color: #fff; }
.conv-info { flex: 1; }
.conv-row { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 12rpx; }
.conv-name { font-size: 32rpx; color: #333; font-weight: bold; }
.conv-time { font-size: 24rpx; color: #999; }
.conv-bottom { display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.conv-preview { font-size: 28rpx; color: #666; flex: 1; margin-right: 16rpx; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.unread-badge { min-width: 36rpx; height: 36rpx; background-color: #FF3B30; border-radius: 18rpx; display: flex; align-items: center; justify-content: center; padding-left: 10rpx; padding-right: 10rpx; }
.unread-num { font-size: 22rpx; color: #fff; font-weight: bold; }
.conv-arrow { font-size: 40rpx; color: #ccc; margin-left: 10rpx; }
.safe-bottom { height: 160rpx; }
/* ===== 骨架屏 ===== */
@keyframes ske-pulse { 0% { opacity: 1; } 50% { opacity: 0.45; } 100% { opacity: 1; } }
.ske-msg-list { padding: 20rpx; }
.ske-conv-item { display: flex; flex-direction: row; align-items: center; padding-top: 24rpx; padding-bottom: 24rpx; border-bottom-width: 1rpx; border-bottom-style: solid; border-bottom-color: #f5f5f5; }
.ske-avatar-circle { width: 88rpx; height: 88rpx; border-radius: 44rpx; background-color: #e8e8e8; margin-right: 20rpx; flex-shrink: 0; animation: ske-pulse 1.4s ease-in-out infinite; }
.ske-conv-info { flex: 1; }
.ske-conv-top { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 10rpx; }
.ske-bar { border-radius: 8rpx; background-color: #e8e8e8; animation: ske-pulse 1.4s ease-in-out infinite; }
.ske-w20 { width: 20%; } .ske-w50 { width: 50%; } .ske-w75 { width: 75%; }
.ske-h22 { height: 22rpx; } .ske-h28 { height: 28rpx; } .ske-mt10 { margin-top: 10rpx; }
</style>