623 lines
17 KiB
Plaintext
623 lines
17 KiB
Plaintext
<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>
|