529 lines
17 KiB
Plaintext
529 lines
17 KiB
Plaintext
<!-- 商家端 - 聊天页面 -->
|
||
<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>
|