Files
medical-mall/pages/mall/merchant/chat.uvue
2026-03-20 17:30:30 +08:00

529 lines
17 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="chat-page">
<!-- 聊天头部 -->
<view class="chat-header" :style="{ paddingTop: navPaddingTop }">
<view class="header-back" @click="goBack">
<text class="back-icon"></text>
</view>
<view class="header-info">
<view class="header-info-text-wrapper">
<text class="chat-title">{{ chatTitle }}</text>
<text class="chat-status">在线</text>
</view>
</view>
<view class="header-actions">
<view class="action-icon" @click="showMoreActions">
<text class="action-icon-text">⋯</text>
</view>
</view>
</view>
<!-- 聊天内容 -->
<scroll-view
scroll-y="true"
class="chat-content"
:scroll-into-view="scrollToView"
:scroll-with-animation="true"
:show-scrollbar="false"
>
<view class="chat-messages">
<!-- 系统消息 -->
<view class="message-item system">
<text class="system-text">已接入客户对话</text>
</view>
<!-- 消息列表 -->
<view
v-for="message in chatMessages"
:key="message.id"
:class="['message-item', message.is_from_user ? 'received' : 'sent']"
:id="message.viewId"
>
<!-- 客户消息 -->
<view v-if="message.is_from_user" class="message-wrapper">
<image
class="avatar"
:src="userAvatar"
mode="aspectFill"
/>
<view class="message-content-wrapper">
<text class="sender-name">{{ chatTitle }}</text>
<view class="message-bubble received-bubble" :class="{ 'image-bubble': message.msg_type === 'image' || message.content.includes('/chat_images/') }">
<text v-if="!(message.msg_type === 'image' || message.content.includes('/chat_images/'))" class="message-text">{{ message.content }}</text>
<image v-else mode="widthFix" class="message-image" :src="message.content" @click="previewImage(message.content)" />
<text class="message-time">{{ message.time }}</text>
</view>
</view>
</view>
<!-- 我的消息 -->
<view v-else class="message-wrapper me">
<view class="message-content-wrapper">
<view class="message-bubble me" :class="{ 'image-bubble': message.msg_type === 'image' || message.content.includes('/chat_images/') }">
<text v-if="!(message.msg_type === 'image' || message.content.includes('/chat_images/'))" class="message-text">{{ message.content }}</text>
<image v-else mode="widthFix" class="message-image" :src="message.content" @click="previewImage(message.content)" />
<text class="message-time">{{ message.time }}</text>
</view>
</view>
<image
class="avatar me"
:src="shopAvatar"
mode="aspectFill"
/>
</view>
</view>
</view>
</scroll-view>
<!-- 聊天输入区 -->
<view class="chat-input">
<view class="input-tools">
<text class="tool-icon" @click="showEmojiPicker">😊</text>
<text class="tool-icon" @click="showImagePicker">📷</text>
</view>
<view class="input-wrapper">
<input
class="message-input"
v-model="inputText"
placeholder="请输入消息..."
:focus="inputFocus"
@confirm="sendMessage"
confirm-type="send"
/>
<button
class="send-button"
:class="{ active: inputText.trim() != '' }"
@click="sendMessage"
>
发送
</button>
</view>
</view>
<!-- 表情选择器 -->
<scroll-view v-if="showEmoji" class="emoji-picker" direction="vertical">
<view class="emoji-category">
<text
v-for="emoji in emojiList"
:key="emoji"
class="emoji-item"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</text>
</view>
</scroll-view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
import type { AkSupaRealtimeChannel } from '@/components/supadb/aksupa.uts'
type ChatMessageType = {
id: string
viewId: 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
time: string
}
export default {
data() {
return {
sessionId: '',
chatUserId: '',
chatTitle: '客户',
inputText: '',
chatMessages: [] as ChatMessageType[],
scrollToView: '',
merchantId: '',
userAvatar: '/static/images/default-avatar.png',
shopAvatar: '/static/images/default-shop.png',
navPaddingTop: '30px',
showEmoji: false,
inputFocus: false,
realtimeChannel: null as AkSupaRealtimeChannel | null
}
},
computed: {
emojiList(): string[] {
return ['😊', '😂', '🤣', '😍', '😘', '🥰', '😭', '😡', '👍', '👏', '🙏', '🎉', '❤️', '🔥', '⭐']
}
},
onLoad(options: any) {
const sysInfo = uni.getSystemInfoSync()
const statusBarH = sysInfo.statusBarHeight
this.navPaddingTop = (statusBarH + 10) + 'px'
console.log('chat page onLoad options:', options)
if (options.session_id) {
this.sessionId = options.session_id
}
if (options.user_id) {
this.chatUserId = options.user_id
}
if (options.title) {
this.chatTitle = decodeURIComponent(options.title)
}
this.initMerchantId()
},
onShow() {
console.log('chat page onShow, chatUserId:', this.chatUserId, 'merchantId:', this.merchantId)
if (this.merchantId) {
this.loadChatMessages()
this.setupRealtimeSubscription()
} else {
setTimeout(() => {
this.loadChatMessages()
this.setupRealtimeSubscription()
}, 300)
}
},
onUnload() {
if (this.realtimeChannel != null) {
supa.removeChannel(this.realtimeChannel!)
}
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
if (session != null && session.user != null) {
this.merchantId = session.user.getString('id') || ''
}
if (!this.merchantId) {
this.merchantId = uni.getStorageSync('user_id') || ''
}
// 加载店铺头像
this.loadShopAvatar()
} catch (e) {
console.error('获取商户ID失败:', e)
}
},
async loadShopAvatar() {
try {
const response = await supa
.from('ml_shops')
.select('shop_logo')
.eq('merchant_id', this.merchantId)
.limit(1)
.execute()
if (response.data && (response.data as any[]).length > 0) {
const shopData = (response.data as any[])[0] as UTSJSONObject
const logo = shopData.getString('shop_logo')
if (logo && logo != '') {
this.shopAvatar = logo
}
}
} catch (e) {
console.error('加载店铺头像失败:', e)
}
},
async loadChatMessages() {
if (!this.chatUserId) {
console.error('chatUserId 为空')
return
}
try {
const response = await supa
.from('ml_chat_messages')
.select('*')
.or(`sender_id.eq.${this.chatUserId},receiver_id.eq.${this.chatUserId}`)
.order('created_at', { ascending: true })
.limit(200)
.execute()
console.log('聊天记录查询结果:', response.data, 'error:', response.error)
if (response.error != null) {
console.error('查询聊天记录失败:', response.error)
return
}
if (response.data && (response.data as any[]).length > 0) {
const rawData = response.data as any[]
const messages: ChatMessageType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
const senderId = item.getString('sender_id') || ''
const isFromUser = senderId === this.chatUserId
const createdAt = item.getString('created_at') || ''
const msgId = item.getString('id') || ''
const safeViewId = 'msg_' + msgId.replace(/[^a-zA-Z0-9]/g, '_')
const date = new Date(createdAt)
const timeStr = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0')
messages.push({
id: msgId,
viewId: safeViewId,
session_id: item.getString('session_id') || '',
sender_id: senderId,
receiver_id: item.getString('receiver_id') || '',
content: item.getString('content') || '',
msg_type: item.getString('msg_type') || 'text',
is_read: item.getBoolean('is_read') || false,
is_from_user: isFromUser,
created_at: createdAt,
time: timeStr
} as ChatMessageType)
}
this.chatMessages = messages
this.scrollToBottom()
this.markAsRead()
} else {
console.log('没有找到聊天记录')
this.chatMessages = []
}
} catch (e) {
console.error('加载聊天记录失败:', e)
}
},
setupRealtimeSubscription(): void {
console.log('开始建立聊天实时订阅...')
this.realtimeChannel = supa.channel('merchant-chat-' + Date.now().toString())
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'ml_chat_messages'
}, (payload: any) => {
console.log('收到实时消息:', payload)
const payloadObj = (payload instanceof UTSJSONObject) ? (payload as UTSJSONObject) : (JSON.parse(JSON.stringify(payload ?? {})) as UTSJSONObject)
const newMsgAny = payloadObj.get('new')
if (newMsgAny == null) return
const newMsg = (newMsgAny instanceof UTSJSONObject) ? (newMsgAny as UTSJSONObject) : (JSON.parse(JSON.stringify(newMsgAny)) as UTSJSONObject)
const senderId = newMsg.getString('sender_id') ?? ''
const receiverId = newMsg.getString('receiver_id') ?? ''
const msgId = newMsg.getString('id') ?? ''
const content = newMsg.getString('content') ?? ''
// 检查是否与当前聊天相关
if (senderId != this.chatUserId && receiverId != this.chatUserId) {
return
}
// 检查消息是否已存在
for (let i = 0; i < this.chatMessages.length; i++) {
if (this.chatMessages[i].id == msgId) return
}
const isFromUser = senderId === this.chatUserId
const createdAt = newMsg.getString('created_at') ?? new Date().toISOString()
const date = new Date(createdAt)
const timeStr = date.getHours().toString().padStart(2, '0') + ':' + date.getMinutes().toString().padStart(2, '0')
const safeViewId = 'msg_' + msgId.replace(/[^a-zA-Z0-9]/g, '_')
this.chatMessages.push({
id: msgId,
viewId: safeViewId,
session_id: newMsg.getString('session_id') || '',
sender_id: senderId,
receiver_id: receiverId,
content: content,
msg_type: newMsg.getString('msg_type') || 'text',
is_read: false,
is_from_user: isFromUser,
created_at: createdAt,
time: timeStr
} as ChatMessageType)
this.scrollToBottom()
if (isFromUser) {
this.markAsRead()
}
})
.subscribe((status: string, err: any | null) => {
console.log('订阅状态:', status)
if (err != null) {
console.log('订阅错误:', err)
}
})
},
scrollToBottom(): void {
if (this.chatMessages.length === 0) return
const lastMsg = this.chatMessages[this.chatMessages.length - 1]
const targetId = lastMsg.viewId
this.scrollToView = ''
setTimeout(() => {
this.scrollToView = targetId
}, 100)
},
async markAsRead() {
try {
await supa
.from('ml_chat_messages')
.update({ is_read: true })
.eq('receiver_id', this.merchantId)
.eq('sender_id', this.chatUserId)
.eq('is_read', false)
.execute()
} catch (e) {
console.error('标记已读失败:', e)
}
},
async sendMessage() {
const content = this.inputText.trim()
if (content == '') return
this.inputText = ''
this.showEmoji = false
try {
const newMessage = {
session_id: this.sessionId || null,
sender_id: this.merchantId,
receiver_id: this.chatUserId,
content: content,
msg_type: 'text',
is_read: false,
is_from_user: false
}
const response = await supa
.from('ml_chat_messages')
.insert([newMessage])
.execute()
if (response.error != null) {
console.error('发送消息失败:', response.error)
uni.showToast({ title: '发送失败', icon: 'none' })
}
} catch (e) {
console.error('发送消息异常:', e)
uni.showToast({ title: '发送失败', icon: 'none' })
}
},
showEmojiPicker(): void {
this.showEmoji = !this.showEmoji
if (this.showEmoji) {
uni.hideKeyboard()
}
},
insertEmoji(emoji: string): void {
this.inputText += emoji
this.showEmoji = false
this.inputFocus = true
},
showImagePicker(): void {
uni.chooseImage({
count: 1,
success: (res) => {
console.log('选择图片:', res.tempFilePaths)
}
})
},
showMoreActions(): void {
uni.showActionSheet({
itemList: ['结束对话', '清除记录'],
success: (res) => {
switch (res.tapIndex) {
case 0:
uni.navigateBack()
break
case 1:
this.chatMessages = []
break
}
}
})
},
previewImage(url : string) {
if (url == '') return
uni.previewImage({
urls: [url],
current: 0
})
},
goBack() {
uni.navigateBack()
}
}
}
</script>
<style>
.chat-page { width: 100%; flex: 1; background-color: #f5f5f5; display: flex; flex-direction: column; overflow: hidden; }
.chat-header { background-color: white; padding-left: 15px; padding-right: 15px; padding-bottom: 10px; display: flex; flex-direction: row; align-items: center; justify-content: space-between; border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: #eee; flex-shrink: 0; }
.header-back { width: 40px; }
.back-icon { font-size: 24px; color: #333; }
.header-info { flex: 1; }
.header-info-text-wrapper { display: flex; flex-direction: column; align-items: center; }
.chat-title { font-size: 16px; font-weight: bold; color: #333; margin-bottom: 2px; }
.chat-status { font-size: 12px; color: #34c759; }
.header-actions .action-icon { font-size: 20px; color: #333; width: 40px; }
.action-icon-text { text-align: right; width: 100%; }
.chat-content { flex: 1; height: 0; padding: 10px; padding-bottom: 20px; }
.chat-messages { display: flex; flex-direction: column; padding-bottom: 80px; }
.message-item.system { display: flex; flex-direction: row; justify-content: center; margin-bottom: 20px; }
.system-text { font-size: 12px; color: #999; background-color: #f0f0f0; padding-top: 5px; padding-bottom: 5px; padding-left: 15px; padding-right: 15px; border-radius: 15px; text-align: center; }
.message-wrapper { display: flex; flex-direction: row; margin-bottom: 15px; }
.message-wrapper.me { justify-content: flex-end; }
.avatar { width: 40px; height: 40px; border-radius: 20px; margin-right: 10px; flex-shrink: 0; background-color: #e0e0e0; }
.avatar.me { margin-right: 0; margin-left: 10px; }
.message-content-wrapper { width: 260px; display: flex; flex-direction: column; }
.message-bubble { background-color: white; padding-top: 10px; padding-bottom: 10px; padding-left: 15px; padding-right: 15px; border-radius: 12px; }
.received-bubble { align-self: flex-start; border-top-left-radius: 2px; }
.message-bubble.me { background-color: #95ec69; align-self: flex-end; border-top-right-radius: 2px; }
.sender-name { font-size: 11px; color: #999; margin-bottom: 2px; align-self: flex-start; }
.message-text { font-size: 15px; color: #333; line-height: 1.4; margin-bottom: 5px; }
.message-time { font-size: 11px; color: #999; text-align: right; }
.chat-input { background-color: white; border-top-width: 1px; border-top-style: solid; border-top-color: #eee; padding-top: 10px; padding-bottom: 20px; padding-left: 15px; padding-right: 15px; position: fixed; bottom: 0; left: 0; right: 0; flex-shrink: 0; }
.input-tools { display: flex; flex-direction: row; margin-bottom: 10px; }
.tool-icon { font-size: 20px; margin-right: 15px; color: #666; }
.input-wrapper { display: flex; flex-direction: row; align-items: center; }
.message-input { flex: 1; background-color: #f5f5f5; border-radius: 20px; padding-top: 10px; padding-bottom: 10px; padding-left: 15px; padding-right: 15px; font-size: 15px; margin-right: 10px; min-height: 40px; max-height: 100px; }
.send-button { background-color: #ccc; color: white; border-radius: 20px; padding-top: 8px; padding-bottom: 8px; padding-left: 20px; padding-right: 20px; font-size: 14px; min-width: 60px; }
.send-button.active { background-color: #ff5000; }
.emoji-picker { background-color: white; border-top-width: 1px; border-top-style: solid; border-top-color: #eee; padding: 10px; height: 200px; position: fixed; bottom: 80px; left: 0; right: 0; }
.emoji-category { display: flex; flex-direction: row; flex-wrap: wrap; }
.emoji-item { font-size: 24px; padding: 8px; width: 45px; height: 45px; display: flex; align-items: center; justify-content: center; }
.image-bubble { padding: 0 !important; background-color: transparent !important; }
.image-bubble .message-time { margin-top: 5px; text-align: right; }
.message-image { width: 150px; border-radius: 8px; }
</style>