Files
medical-mall/pages/mall/consumer/chat_new.uvue

623 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: statusBarHeight + 'px' }">
<view class="header-content">
<view class="header-back" @click="goBack">
<text class="back-icon"></text>
</view>
<view class="header-info">
<text class="chat-title">{{ merchantName || '在线客服' }}</text>
<text class="chat-status">在线</text>
</view>
<view class="header-actions">
<text class="action-icon" @click="showMoreActions">⋮</text>
</view>
</view>
</view>
<!-- 聊天内容 -->
<scroll-view
scroll-y
class="chat-content"
:scroll-into-view="scrollToView"
scroll-with-animation
@scrolltoupper="loadMoreHistory"
>
<!-- 占位,防止内容被头部遮挡 -->
<view :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
<!-- 聊天消息列表 -->
<view class="chat-messages">
<!-- 系统提示 -->
<view class="message-item system">
<text class="system-text">已连接到商家,开始聊天吧</text>
</view>
<!-- 消息项 -->
<view
v-for="(message, index) in messages"
:key="message.id"
:class="['message-item', message.type]"
:id="'msg-' + message.id"
>
<!-- 时间显示逻辑每5分钟显示一次时间 -->
<view v-if="shouldShowTime(index)" class="time-divider">
<text>{{ formatTime(message.rawTime) }}</text>
</view>
<!-- 对方消息 -->
<view v-if="message.type === 'received'" class="message-wrapper">
<image
class="avatar"
:src="merchantLogo || '/static/logo.png'"
mode="aspectFill"
/>
<view class="message-content-wrapper">
<!-- <text class="sender-name">{{ merchantName }}</text> -->
<view class="message-bubble">
<text class="message-text">{{ message.content }}</text>
</view>
</view>
</view>
<!-- 我的消息 -->
<view v-else class="message-wrapper me">
<view class="message-content-wrapper">
<view class="message-bubble me">
<text class="message-text">{{ message.content }}</text>
</view>
</view>
<image
class="avatar me"
:src="userAvatar || '/static/default-avatar.png'"
mode="aspectFill"
/>
</view>
</view>
<!-- 底部填充,防止被输入框遮挡 -->
<view style="height: 20px;"></view>
<view id="bottom-anchor" style="height: 1px;"></view>
</view>
</scroll-view>
<!-- 聊天输入区 -->
<view class="chat-input-area">
<view class="input-tools">
<text class="tool-icon" @click="toggleEmoji">😊</text>
<!-- <text class="tool-icon" @click="chooseImage">📷</text> -->
</view>
<view class="input-wrapper">
<input
class="message-input"
v-model="inputMessage"
placeholder="请输入消息..."
:adjust-position="true"
confirm-type="send"
@confirm="sendMessage"
/>
<button
class="send-button"
:class="{ active: inputMessage.trim().length > 0 }"
@click="sendMessage"
>
发送
</button>
</view>
<!-- 表情选择器 (简化版) -->
<scroll-view scroll-y v-if="showEmoji" class="emoji-picker">
<view class="emoji-grid">
<text
v-for="emoji in emojiList"
:key="emoji"
class="emoji-item"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</text>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted, onUnmounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import supabaseService from '@/utils/supabaseService.uts'
import type { ChatMessage } from '@/utils/supabaseService.uts'
import supa from '@/components/supadb/aksupainstance.uts'
// import { getCurrentUser } from '@/utils/store.uts'
// 界面状态
const statusBarHeight = ref(0)
const scrollToView = ref('')
const showEmoji = ref(false)
const inputMessage = ref('')
// 业务数据
const merchantId = ref('')
const merchantName = ref('')
const merchantLogo = ref('')
const userAvatar = ref('')
const currentUserId = ref('')
const messages = ref<any[]>([])
const emojiList = ['😊', '😂', '👍', '👌', '❤️', '🌹', '🙏', '🎉', '😡', '😭', '🤔', '👋', '🤝', '💊', '🏥']
let realtimeChannel: any | null = null
onLoad((options: any) => {
// 获取状态栏高度
const sys = uni.getSystemInfoSync()
statusBarHeight.value = sys.statusBarHeight ?? 0
// 获取参数
const optObj = (options instanceof UTSJSONObject) ? (options as UTSJSONObject) : (JSON.parse(JSON.stringify(options ?? {})) as UTSJSONObject)
const mid = optObj.getString('merchantId') ?? ''
if (mid !== '') {
merchantId.value = mid
merchantName.value = optObj.getString('merchantName') ?? '商家'
merchantLogo.value = optObj.getString('merchantLogo') ?? ''
console.log('开始聊天商家ID:', merchantId.value)
} else {
merchantName.value = '平台客服'
}
// 获取当前用户
const uid = supabaseService.getCurrentUserId()
if (uid != null) {
currentUserId.value = uid
// 简单获取一下头像实际应该从Profile获取
userAvatar.value = 'https://picsum.photos/100'
}
// 加载历史消息
loadHistory()
// 开启实时订阅
startRealtimeSubscription()
})
onUnmounted(() => {
if (realtimeChannel != null) {
supa.removeChannel(realtimeChannel!)
}
})
// 加载历史记录
const loadHistory = async () => {
let rawMsgs: ChatMessage[] = []
if (merchantId.value) {
// 获取与特定商家的聊天
rawMsgs = await supabaseService.getChatMessages(merchantId.value)
} else {
// 获取所有(比如客服)
rawMsgs = await supabaseService.getUserChatMessages()
}
const formatted = rawMsgs.reverse().map((m: ChatMessage) => formatMessage(m))
messages.value = formatted
scrollToBottom()
}
const loadMoreHistory = () => {
// TODO: 实现下拉加载更多历史
}
// 开启实时订阅
const startRealtimeSubscription = () => {
if (currentUserId.value == '') return
console.log('开启消息监听...')
const filterObj = ({
event: 'INSERT',
schema: 'public',
table: 'ml_chat_messages'
} as UTSJSONObject)
realtimeChannel = supa.channel(`chat_${currentUserId.value}`)
.on(
'postgres_changes',
filterObj,
(payload: any) => {
console.log('收到新消息:', payload)
const payloadObj = (payload instanceof UTSJSONObject) ? (payload as UTSJSONObject) : (JSON.parse(JSON.stringify(payload ?? {})) as UTSJSONObject)
const newAny = payloadObj.get('new')
if (newAny == null) return
const newMsg = (newAny instanceof UTSJSONObject) ? (newAny as UTSJSONObject) : (JSON.parse(JSON.stringify(newAny)) as UTSJSONObject)
// 只有来自当前聊天的商家的消息才显示,或者如果是全局客服模式
const senderId = newMsg.getString('sender_id') ?? ''
if (senderId === merchantId.value || merchantId.value == '') {
const formatted = formatMessage({
id: newMsg.getString('id') ?? '',
content: newMsg.getString('content') ?? '',
msg_type: newMsg.getString('msg_type') ?? '',
sender_id: senderId,
receiver_id: newMsg.getString('receiver_id') ?? '',
is_from_user: false, // 收到的一定不是自己发的
created_at: newMsg.getString('created_at') ?? ''
} as ChatMessage)
messages.value.push(formatted)
scrollToBottom()
// 震动提示
uni.vibrateShort({})
}
}
)
.subscribe()
}
// 格式化消息
const formatMessage = (m: ChatMessage): any => {
// 如果 sender_id 是自己,就是 'sent',否则 'received'
// 注意:数据库字段 is_from_user 有时可能只是标记是否由C端用户发起
// 最准确的是对比 id
let isMe = false
if (currentUserId.value) {
isMe = m.sender_id === currentUserId.value
} else {
isMe = m.is_from_user === true
}
return {
id: m.id,
type: isMe ? 'sent' : 'received',
content: m.content,
rawTime: m.created_at || new Date().toISOString(),
senderId: m.sender_id
}
}
const sendMessage = async () => {
const text = inputMessage.value.trim()
if (text == '') return
// 乐观更新 UI
const tempId = 'temp_' + Date.now()
const tempMsg = {
id: tempId,
type: 'sent',
content: text,
rawTime: new Date().toISOString(),
senderId: currentUserId.value
}
messages.value.push(tempMsg)
inputMessage.value = ''
scrollToBottom()
showEmoji.value = false
// 发送请求
// 注意:如果 merchantId 为空sendChatMessage 第二个参数传 null会变成无主消息
const success = await supabaseService.sendChatMessage(text, merchantId.value ? merchantId.value : null)
if (!success) {
uni.showToast({ title: '发送失败', icon: 'none' })
// 这里可以加一个重试按钮或移除消息
}
}
const scrollToBottom = () => {
// 延时滚动以确保视图更新
setTimeout(() => {
scrollToView.value = 'bottom-anchor'
// Hack: 重置再设置以强制触发
setTimeout(() => {
scrollToView.value = 'msg-' + (messages.value.length > 0 ? messages.value[messages.value.length-1].id : '')
}, 50)
}, 100)
}
const goBack = () => {
uni.navigateBack({})
}
const formatTime = (isoString: string): string => {
const date = new Date(isoString)
const now = new Date()
// 如果是今天,显示 HH:mm
if (date.toDateString() === now.toDateString()) {
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
return `${h}:${m}`
}
// 否则显示 MM-DD HH:mm
const mo = (date.getMonth() + 1).toString().padStart(2, '0')
const d = date.getDate().toString().padStart(2, '0')
const h = date.getHours().toString().padStart(2, '0')
const m = date.getMinutes().toString().padStart(2, '0')
return `${mo}-${d} ${h}:${m}`
}
const shouldShowTime = (index: number): boolean => {
if (index === 0) return true
const prev = messages.value[index - 1]
const curr = messages.value[index]
const t1 = new Date(prev.rawTime).getTime()
const t2 = new Date(curr.rawTime).getTime()
// 间隔超过5分钟(300000ms)显示时间
return (t2 - t1) > 300000
}
const toggleEmoji = () => {
showEmoji.value = !showEmoji.value
if (showEmoji.value) {
scrollToBottom()
}
}
const insertEmoji = (emoji: string) => {
inputMessage.value += emoji
}
const showMoreActions = () => {
uni.showActionSheet({
itemList: ['清空记录', '投诉商家'],
success: (res) => {
if (res.tapIndex === 0) {
messages.value = [] // 仅本地清空
}
}
})
}
</script>
<style>
.chat-page {
background-color: #f5f5f5;
height: 100vh;
display: flex;
flex-direction: column;
}
.chat-header {
background-color: #fff;
border-bottom: 1px solid #eee;
z-index: 100;
}
.header-content {
height: 44px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 16px;
justify-content: space-between;
}
.header-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: flex-start;
}
.back-icon {
font-size: 32px;
color: #333;
line-height: 1;
}
.header-info {
display: flex;
flex-direction: column;
align-items: center;
}
.chat-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.chat-status {
font-size: 10px;
color: #4CAF50;
margin-top: 2px;
}
.header-actions {
width: 40px;
display: flex;
justify-content: flex-end;
}
.action-icon {
font-size: 24px;
color: #333;
}
.chat-content {
flex: 1;
/* height: 0; flex grow handles it */
background-color: #f5f5f5;
padding: 10px 0;
}
.chat-messages {
padding: 16px;
padding-bottom: 20px;
display: flex;
flex-direction: column;
}
.time-divider {
display: flex;
justify-content: center;
margin: 16px 0;
}
.time-divider text {
background-color: rgba(0,0,0,0.1);
color: #999;
font-size: 12px;
padding: 4px 8px;
border-radius: 4px;
}
.system {
justify-content: center;
margin-bottom: 16px;
display: flex;
align-items: center;
}
.system-text {
background-color: #e0e0e0;
color: #666;
font-size: 12px;
padding: 4px 12px;
border-radius: 12px;
}
.message-item {
margin-bottom: 16px;
display: flex;
width: 100%;
}
.received {
justify-content: flex-start;
flex-direction: row;
}
.sent {
justify-content: flex-end;
}
.message-wrapper {
display: flex;
flex-direction: row;
max-width: 80%;
align-items: flex-start;
}
.me {
flex-direction: row; /* Keep standard flow but justify-end handles position */
}
.avatar {
width: 40px;
height: 40px;
border-radius: 20px;
background-color: #ddd;
margin-right: 8px;
flex-shrink: 0;
}
.avatar.me {
margin-right: 0;
margin-left: 8px;
}
.message-content-wrapper {
display: flex;
flex-direction: column;
}
.sender-name {
font-size: 12px;
color: #999;
margin-bottom: 4px;
margin-left: 4px;
}
.message-bubble {
background-color: #fff;
padding: 10px 14px;
border-radius: 4px 12px 12px 12px;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.message-bubble.me {
background-color: #95ec69; /* WeChat green */
border-radius: 12px 4px 12px 12px;
}
.message-text {
font-size: 15px;
color: #333;
line-height: 1.4;
word-break: break-all;
}
.chat-input-area {
background-color: #f9f9f9;
padding: 10px;
border-top: 1px solid #e0e0e0;
padding-bottom: env(safe-area-inset-bottom);
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
background-color: #fff;
border-radius: 24px;
padding: 8px 12px;
}
.message-input {
flex: 1;
font-size: 16px;
height: 36px;
}
.send-button {
margin-left: 8px;
background-color: #e0e0e0;
color: #999;
font-size: 14px;
padding: 4px 12px;
border-radius: 16px;
border: none;
line-height: 24px;
}
.send-button.active {
background-color: #4CAF50;
color: #fff;
}
.input-tools {
display: flex;
flex-direction: row;
padding-bottom: 8px;
}
.tool-icon {
font-size: 24px;
color: #666;
margin-right: 16px;
padding: 4px;
}
.emoji-picker {
height: 150px;
background-color: #f9f9f9;
border-top: 1px solid #eee;
padding: 10px;
}
.emoji-grid {
display: flex;
flex-wrap: wrap;
flex-direction: row;
}
.emoji-item {
font-size: 24px;
padding: 8px;
margin: 4px;
}
</style>