952 lines
27 KiB
Plaintext
952 lines
27 KiB
Plaintext
<!-- pages/mall/consumer/chat.uvue -->
|
||
<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">{{ headerTitle }}</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"
|
||
upper-threshold="100"
|
||
@scrolltoupper="onScrollToUpper"
|
||
>
|
||
<!-- 聊天消息列表 -->
|
||
<view class="chat-messages">
|
||
<!-- 系统消息 -->
|
||
<view class="message-item system">
|
||
<text class="system-text">客服 小美 已接入,请描述您的问题</text>
|
||
</view>
|
||
|
||
<!-- 时间分割线 -->
|
||
<view class="time-divider">
|
||
<text class="time-text">今天 14:30</text>
|
||
</view>
|
||
|
||
<!-- 消息项 -->
|
||
<view
|
||
v-for="message in messages"
|
||
:key="message.id"
|
||
:class="['message-item', message.type]"
|
||
:id="message.viewId"
|
||
>
|
||
<!-- 对方消息 -->
|
||
<view v-if="message.type === 'received'" class="message-wrapper">
|
||
<image
|
||
class="avatar"
|
||
:src="merchantAvatar"
|
||
mode="aspectFill"
|
||
/>
|
||
<view class="message-content-wrapper">
|
||
<text class="sender-name">{{ headerTitle }}</text>
|
||
<view class="message-bubble received-bubble">
|
||
<!-- 图片消息 -->
|
||
<image
|
||
v-if="message.msgType == 'image'"
|
||
class="message-image"
|
||
:src="message.content"
|
||
mode="widthFix"
|
||
@click="previewImage(message.content)"
|
||
/>
|
||
<!-- 文本消息 -->
|
||
<text v-if="message.msgType != 'image'" class="message-text">{{ message.content }}</text>
|
||
<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">
|
||
<!-- 图片消息 -->
|
||
<image
|
||
v-if="message.msgType == 'image'"
|
||
class="message-image"
|
||
:src="message.content"
|
||
mode="widthFix"
|
||
@click="previewImage(message.content)"
|
||
/>
|
||
<!-- 文本消息 -->
|
||
<text v-if="message.msgType != 'image'" class="message-text">{{ message.content }}</text>
|
||
<text class="message-time">{{ message.time }}</text>
|
||
</view>
|
||
</view>
|
||
<image
|
||
class="avatar me"
|
||
src="/static/images/default-product.png"
|
||
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>
|
||
<text class="tool-icon" @click="showMoreTools">➕</text>
|
||
</view>
|
||
|
||
<view class="input-wrapper">
|
||
<input
|
||
class="message-input"
|
||
v-model="inputMessage"
|
||
placeholder="请输入消息..."
|
||
:focus="inputFocus"
|
||
@confirm="sendMessage"
|
||
confirm-type="send"
|
||
/>
|
||
<button
|
||
class="send-button"
|
||
:class="{ active: inputMessage.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 setup lang="uts">
|
||
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
|
||
import { supabaseService, type ChatMessage } from '@/utils/supabaseService.uts'
|
||
import supa from '@/components/supadb/aksupainstance.uts'
|
||
import type { AkSupaRealtimeChannel } from '@/components/supadb/aksupa.uts'
|
||
import { getCurrentUser } from '@/utils/store.uts'
|
||
|
||
type UiChatMessage = {
|
||
id: string
|
||
viewId: string
|
||
type: string
|
||
content: string
|
||
time: string
|
||
msgType: string // 'text' | 'image'
|
||
}
|
||
|
||
// 响应式数据
|
||
const messages = ref<UiChatMessage[]>([])
|
||
const inputMessage = ref<string>('')
|
||
const inputFocus = ref<boolean>(false)
|
||
const showEmoji = ref<boolean>(false)
|
||
const scrollToView = ref<string>('')
|
||
const currentUserId = ref<string>('')
|
||
const merchantId = ref<string>('') // 商家ID
|
||
const headerTitle = ref<string>('在线客服')
|
||
const merchantAvatar = ref<string>('/static/default-shop.png') // 商家头像
|
||
const navPaddingTop = ref<string>('30px') // 默认值,包含状态栏高度+原有内边距
|
||
const isInitialLoading = ref<boolean>(true)
|
||
let realtimeChannel: AkSupaRealtimeChannel | null = null
|
||
|
||
// 模拟表情列表
|
||
const emojiList = ['😊', '😂', '🤣', '😍', '😘', '🥰', '😭', '😡', '👍', '👏', '🙏', '🎉', '❤️', '🔥', '⭐']
|
||
|
||
function scrollToBottom() : void {
|
||
if (messages.value.length === 0) return
|
||
|
||
// 获取最后一条消息的 ID
|
||
const lastMsg = messages.value[messages.value.length - 1]
|
||
const targetId = lastMsg.viewId
|
||
|
||
// 关键点:在 UVue 安卓端,直接连续赋值可能被合并。
|
||
// 我们先清空 ID,然后在下一帧赋值,确保 scroll-view 监听到变化。
|
||
scrollToView.value = ''
|
||
|
||
// 延迟更久一点,确保安卓端列表排版彻底完成
|
||
setTimeout(() => {
|
||
scrollToView.value = targetId
|
||
console.log('[scrollToBottom] 发起滚动定位:', targetId)
|
||
|
||
// 分级校准:针对长消息或渲染抖动导致的高度变化
|
||
setTimeout(() => {
|
||
scrollToView.value = ''
|
||
setTimeout(() => {
|
||
scrollToView.value = targetId
|
||
console.log('[scrollToBottom] 第一阶段校准:', targetId)
|
||
}, 50)
|
||
}, 500)
|
||
|
||
// 最终深度校准(针对首屏数据较多时)
|
||
setTimeout(() => {
|
||
scrollToView.value = ''
|
||
setTimeout(() => {
|
||
scrollToView.value = targetId
|
||
console.log('[scrollToBottom] 最终校准:', targetId)
|
||
}, 50)
|
||
}, 1200)
|
||
}, 300)
|
||
}
|
||
|
||
function getCurrentTime(): string {
|
||
const now = new Date()
|
||
const hours = now.getHours().toString().padStart(2, '0')
|
||
const minutes = now.getMinutes().toString().padStart(2, '0')
|
||
return `${hours}:${minutes}`
|
||
}
|
||
|
||
function setupRealtimeSubscription(): void {
|
||
console.log('开始建立聊天实时订阅...')
|
||
console.log('当前用户ID:', currentUserId.value, '商家ID:', merchantId.value)
|
||
|
||
realtimeChannel = supa.channel('chat-messages-' + Date.now().toString())
|
||
.on('postgres_changes', {
|
||
event: 'INSERT',
|
||
schema: 'public',
|
||
table: 'ml_chat_messages'
|
||
}, (payload: any) => {
|
||
console.log('=== 收到实时订阅回调 ===')
|
||
const payloadObj = (payload instanceof UTSJSONObject) ? (payload as UTSJSONObject) : (JSON.parse(JSON.stringify(payload ?? {})) as UTSJSONObject)
|
||
const newMsgAny = payloadObj.get('new')
|
||
if (newMsgAny == null) {
|
||
console.log('newMsgAny 为空,跳过')
|
||
return
|
||
}
|
||
const newMsg = (newMsgAny instanceof UTSJSONObject) ? (newMsgAny as UTSJSONObject) : (JSON.parse(JSON.stringify(newMsgAny)) as UTSJSONObject)
|
||
console.log('收到新消息:', newMsg)
|
||
|
||
const senderId = newMsg.getString('sender_id') ?? ''
|
||
const receiverId = newMsg.getString('receiver_id') ?? ''
|
||
const msgId = newMsg.getString('id') ?? ''
|
||
const content = newMsg.getString('content') ?? ''
|
||
const msgType = newMsg.getString('msg_type') ?? 'text'
|
||
|
||
console.log('=== 消息详情 ===')
|
||
console.log('消息ID:', msgId)
|
||
console.log('发送者ID:', senderId)
|
||
console.log('接收者ID:', receiverId)
|
||
console.log('当前用户ID:', currentUserId.value)
|
||
console.log('商家ID:', merchantId.value)
|
||
console.log('消息内容:', content)
|
||
console.log('消息类型 msgType:', msgType)
|
||
|
||
// 检查消息是否已经在列表中(避免重复)
|
||
for (let i = 0; i < messages.value.length; i++) {
|
||
if (messages.value[i].id == msgId) {
|
||
console.log('消息已存在,跳过')
|
||
return
|
||
}
|
||
}
|
||
|
||
// 判断消息类型
|
||
const isMyMessage = (senderId == currentUserId.value)
|
||
const isForMe = (receiverId == currentUserId.value)
|
||
const isRelatedToCurrentChat = (senderId == merchantId.value || receiverId == merchantId.value)
|
||
|
||
console.log('=== 条件判断 ===')
|
||
console.log('isMyMessage:', isMyMessage)
|
||
console.log('isForMe:', isForMe)
|
||
console.log('isRelatedToCurrentChat:', isRelatedToCurrentChat)
|
||
|
||
// 如果消息与当前聊天无关,跳过
|
||
if (!isRelatedToCurrentChat) {
|
||
console.log('消息与当前聊天无关,跳过')
|
||
return
|
||
}
|
||
|
||
// 如果是自己发送的消息,或者是发给自己的消息,都显示
|
||
if (isMyMessage || isForMe) {
|
||
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')}`
|
||
|
||
// 生成安全的 viewId
|
||
const safeViewId = 'msg_' + msgId.replace(/[^a-zA-Z0-9]/g, '_')
|
||
|
||
const incomingMsg: UiChatMessage = {
|
||
id: msgId,
|
||
viewId: safeViewId,
|
||
type: isMyMessage ? 'sent' : 'received',
|
||
content: content,
|
||
time: timeStr,
|
||
msgType: msgType
|
||
}
|
||
|
||
console.log('=== 添加新消息到列表 ===')
|
||
console.log('消息类型:', incomingMsg.type)
|
||
console.log('消息内容:', incomingMsg.content)
|
||
messages.value.push(incomingMsg)
|
||
scrollToBottom()
|
||
} else {
|
||
console.log('条件不满足,不添加消息')
|
||
}
|
||
})
|
||
.subscribe((status: string, err: any | null) => {
|
||
console.log('订阅状态:', status)
|
||
if (err != null) {
|
||
console.log('订阅错误:', err)
|
||
}
|
||
})
|
||
}
|
||
|
||
async function loadChatHistory(): Promise<void> {
|
||
let rawMsgs : ChatMessage[] = []
|
||
|
||
if (merchantId.value != '') {
|
||
rawMsgs = await supabaseService.getChatMessages(merchantId.value)
|
||
} else {
|
||
console.warn("No merchant ID provided for chat")
|
||
return
|
||
}
|
||
|
||
// 确保时间顺序是升序(旧的在前,新的在后)
|
||
// Supabase 返回的消息如果是降序,我们需要 reverse 过来显示
|
||
const sortedRawMsgs = rawMsgs.sort((a, b) => {
|
||
const timeA = new Date(a.created_at ?? '').getTime()
|
||
const timeB = new Date(b.created_at ?? '').getTime()
|
||
return timeA - timeB
|
||
})
|
||
|
||
const uiMessages : UiChatMessage[] = []
|
||
for (let i = 0; i < sortedRawMsgs.length; i++) {
|
||
const m = sortedRawMsgs[i]
|
||
const date = new Date(m.created_at ?? new Date().toISOString())
|
||
const timeStr = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
|
||
|
||
const sender = m.sender_id ?? ''
|
||
const msgType = (currentUserId.value != '' && sender == currentUserId.value) ? 'sent' : 'received'
|
||
const rawId = (m.id ?? '').toString()
|
||
const msgId = rawId != '' ? rawId : Date.now().toString() + i.toString()
|
||
const safeViewId = 'msg_' + msgId.replace(/[^a-zA-Z0-9]/g, '_')
|
||
|
||
const uiMsg : UiChatMessage = {
|
||
id: msgId,
|
||
viewId: safeViewId,
|
||
type: msgType,
|
||
content: m.content ?? '',
|
||
time: timeStr,
|
||
msgType: m.msg_type ?? 'text'
|
||
}
|
||
uiMessages.push(uiMsg)
|
||
}
|
||
messages.value = uiMessages
|
||
|
||
if (isInitialLoading.value) {
|
||
// 增加一点初始化延迟,等待 scroll-view 渲染就绪
|
||
setTimeout(() => {
|
||
scrollToBottom()
|
||
isInitialLoading.value = false
|
||
}, 500)
|
||
}
|
||
}
|
||
|
||
function onScrollToUpper(e: any): void {
|
||
console.log('[onScrollToUpper] 触发加载历史记录')
|
||
}
|
||
|
||
async function loadMerchantInfo(): Promise<void> {
|
||
if (merchantId.value == '') return
|
||
|
||
try {
|
||
const response = await supa
|
||
.from('ml_shops')
|
||
.select('shop_logo, shop_name')
|
||
.eq('merchant_id', merchantId.value)
|
||
.limit(1)
|
||
.execute()
|
||
|
||
if (response.error != null) {
|
||
console.error('[loadMerchantInfo] 获取商家信息失败:', response.error)
|
||
return
|
||
}
|
||
|
||
const rawData = response.data
|
||
if (rawData == null) return
|
||
|
||
const rawList = rawData as any[]
|
||
if (rawList.length == 0) return
|
||
|
||
const shopData = rawList[0]
|
||
const shopObj = JSON.parse(JSON.stringify(shopData)) as UTSJSONObject
|
||
|
||
const logo = shopObj.getString('shop_logo')
|
||
if (logo != null && logo != '') {
|
||
merchantAvatar.value = logo
|
||
}
|
||
|
||
const name = shopObj.getString('shop_name')
|
||
if (name != null && name != '' && headerTitle.value == '在线客服') {
|
||
headerTitle.value = name
|
||
}
|
||
} catch (e) {
|
||
console.error('[loadMerchantInfo] 获取商家信息异常:', e)
|
||
}
|
||
}
|
||
|
||
// 生命周期
|
||
onLoad((options: any) => {
|
||
// 动态获取状态栏高度
|
||
const sysInfo = uni.getSystemInfoSync()
|
||
const statusBarH = sysInfo.statusBarHeight
|
||
// 状态栏高度 + 10px 原有顶部内边距
|
||
navPaddingTop.value = (statusBarH + 10) + 'px'
|
||
|
||
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
|
||
}
|
||
const mname = optObj.getString('merchantName') ?? ''
|
||
if (mname !== '') {
|
||
headerTitle.value = mname
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
supabaseService.ensureSession().then((uid) => {
|
||
if (uid != null) {
|
||
currentUserId.value = uid
|
||
} else {
|
||
getCurrentUser().then((user) => {
|
||
if (user != null) {
|
||
currentUserId.value = user.id ?? ''
|
||
}
|
||
})
|
||
}
|
||
|
||
loadMerchantInfo()
|
||
loadChatHistory()
|
||
setupRealtimeSubscription()
|
||
})
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (realtimeChannel != null) {
|
||
supa.removeChannel(realtimeChannel!!)
|
||
}
|
||
})
|
||
|
||
const sendMessage = async () => {
|
||
const content = inputMessage.value.trim()
|
||
if (content == '') return
|
||
|
||
// 清空输入框
|
||
inputMessage.value = ''
|
||
// 发送消息时确保收起表情面板
|
||
showEmoji.value = false
|
||
|
||
// 发送到 Supabase
|
||
if (merchantId.value != '') {
|
||
console.log('[sendMessage] 开始发送消息到:', merchantId.value)
|
||
const success = await supabaseService.sendMessage(merchantId.value, content)
|
||
console.log('[sendMessage] 发送结果:', success)
|
||
if (!success) {
|
||
uni.showToast({
|
||
title: '发送失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
// 不需要手动添加消息,等待实时订阅推送
|
||
}
|
||
}
|
||
|
||
// 模拟客服回复 (已禁用,改用 Realtime)
|
||
/*
|
||
const simulateCustomerReply = async () => {
|
||
// ...
|
||
}
|
||
*/
|
||
|
||
/* 移除不再使用的 simulateCustomerReply 和 addReceivedMessage */
|
||
|
||
// 插入表情
|
||
function insertEmoji(emoji: string): void {
|
||
inputMessage.value += emoji
|
||
showEmoji.value = false // 选中表情后收起表情列表
|
||
inputFocus.value = true
|
||
}
|
||
|
||
// 显示表情选择器
|
||
function showEmojiPicker(): void {
|
||
showEmoji.value = !showEmoji.value
|
||
if (showEmoji.value) {
|
||
// 如果打开表情,通常需要收起键盘
|
||
uni.hideKeyboard()
|
||
}
|
||
}
|
||
|
||
// 执行图片上传
|
||
async function doUploadImage(filePath: string): Promise<void> {
|
||
console.log('[doUploadImage] 开始上传图片:', filePath)
|
||
|
||
// 显示加载提示
|
||
uni.showLoading({
|
||
title: '发送中...',
|
||
mask: true
|
||
})
|
||
|
||
try {
|
||
// 上传图片
|
||
const imageUrl = await supabaseService.uploadChatImage(filePath)
|
||
|
||
uni.hideLoading()
|
||
|
||
if (imageUrl == '') {
|
||
uni.showToast({
|
||
title: '图片上传失败',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
console.log('[doUploadImage] 图片上传成功:', imageUrl)
|
||
|
||
// 发送图片消息
|
||
const success = await supabaseService.sendMessage(merchantId.value, imageUrl, 'image')
|
||
if (!success) {
|
||
uni.showToast({
|
||
title: '发送失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
} catch (e) {
|
||
uni.hideLoading()
|
||
console.error('[doUploadImage] 上传异常:', e)
|
||
uni.showToast({
|
||
title: '上传失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
}
|
||
|
||
// 显示图片选择器
|
||
function showImagePicker(): void {
|
||
uni.chooseImage({
|
||
count: 1,
|
||
success: (res) => {
|
||
console.log('选择图片成功:', JSON.stringify(res))
|
||
|
||
// 处理 tempFilePaths,兼容不同平台
|
||
let filePath: string = ''
|
||
const tempFilePaths = res.tempFilePaths
|
||
if (tempFilePaths != null) {
|
||
if (Array.isArray(tempFilePaths)) {
|
||
const arr = tempFilePaths as string[]
|
||
if (arr.length > 0) {
|
||
filePath = arr[0]
|
||
}
|
||
} else if (tempFilePaths instanceof UTSJSONObject) {
|
||
const keys = UTSJSONObject.keys(tempFilePaths as UTSJSONObject)
|
||
if (keys.length > 0) {
|
||
filePath = (tempFilePaths as UTSJSONObject).getString(keys[0]) ?? ''
|
||
}
|
||
} else if (typeof tempFilePaths === 'string') {
|
||
filePath = tempFilePaths as string
|
||
}
|
||
}
|
||
|
||
// 尝试从 tempFiles 获取
|
||
if (filePath == '' && res.tempFiles != null) {
|
||
const tempFiles = res.tempFiles
|
||
if (Array.isArray(tempFiles)) {
|
||
const files = tempFiles as any[]
|
||
if (files.length > 0) {
|
||
const firstFile = files[0]
|
||
if (firstFile instanceof UTSJSONObject) {
|
||
filePath = firstFile.getString('path') ?? ''
|
||
} else if (typeof firstFile === 'object' && firstFile != null) {
|
||
const fileObj = JSON.parse(JSON.stringify(firstFile)) as UTSJSONObject
|
||
filePath = fileObj.getString('path') ?? ''
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log('[showImagePicker] 文件路径:', filePath)
|
||
|
||
if (filePath == '') {
|
||
uni.showToast({
|
||
title: '获取图片路径失败',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 执行上传
|
||
doUploadImage(filePath)
|
||
},
|
||
fail: (err) => {
|
||
console.log('选择图片失败:', err)
|
||
uni.showToast({
|
||
title: '选择图片失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// 预览图片
|
||
function previewImage(url: string): void {
|
||
uni.previewImage({
|
||
urls: [url],
|
||
current: url
|
||
})
|
||
}
|
||
|
||
// 显示更多工具
|
||
function showMoreTools(): void {
|
||
uni.showActionSheet({
|
||
itemList: ['发送位置', '发送文件', '发送语音'],
|
||
success: (res) => {
|
||
console.log('选择工具:', res.tapIndex)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 显示更多操作
|
||
function showMoreActions(): void {
|
||
uni.showActionSheet({
|
||
itemList: ['投诉客服', '结束对话', '清除记录'],
|
||
success: (res) => {
|
||
switch (res.tapIndex) {
|
||
case 0:
|
||
uni.navigateTo({ url: '/pages/mall/consumer/complaint' })
|
||
break
|
||
case 1:
|
||
uni.showModal({
|
||
title: '确认结束',
|
||
content: '确定要结束本次对话吗?',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
uni.navigateBack()
|
||
}
|
||
}
|
||
})
|
||
break
|
||
case 2:
|
||
uni.showModal({
|
||
title: '确认清除',
|
||
content: '确定要清除聊天记录吗?',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
messages.value = []
|
||
}
|
||
}
|
||
})
|
||
break
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
// 返回
|
||
const 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: 10px 15px;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
border-bottom: 1px solid #eee;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.header-back {
|
||
width: 40px;
|
||
}
|
||
|
||
.back-icon {
|
||
font-size: 20px;
|
||
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;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.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: 5px 15px;
|
||
border-radius: 15px;
|
||
text-align: center;
|
||
}
|
||
|
||
/* 时间分割线 */
|
||
.time-divider {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: center;
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.time-text {
|
||
font-size: 12px;
|
||
color: #999;
|
||
background-color: #f0f0f0;
|
||
padding: 3px 10px;
|
||
border-radius: 10px;
|
||
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;
|
||
}
|
||
|
||
.avatar.me {
|
||
margin-right: 0;
|
||
margin-left: 10px;
|
||
/* order: 2; removed for uni-app-x */
|
||
}
|
||
|
||
.message-content-wrapper {
|
||
width: 260px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.message-bubble {
|
||
background-color: white;
|
||
padding: 10px 15px;
|
||
border-radius: 12px;
|
||
position: relative;
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.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;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.message-image {
|
||
max-width: 200px;
|
||
min-width: 100px;
|
||
border-radius: 8px;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.message-time {
|
||
font-size: 11px;
|
||
color: #999;
|
||
text-align: right;
|
||
}
|
||
|
||
/* 聊天输入区 */
|
||
.chat-input {
|
||
background-color: white;
|
||
border-top: 1px solid #eee;
|
||
padding: 10px 15px;
|
||
padding-bottom: 20px;
|
||
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: 10px 15px;
|
||
font-size: 15px;
|
||
margin-right: 10px;
|
||
min-height: 40px;
|
||
max-height: 100px;
|
||
}
|
||
|
||
.send-button {
|
||
background-color: #ccc;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 20px;
|
||
padding: 8px 20px;
|
||
font-size: 14px;
|
||
min-width: 60px;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.send-button.active {
|
||
background-color: #ff5000;
|
||
}
|
||
|
||
/* 表情选择器 */
|
||
.emoji-picker {
|
||
background-color: white;
|
||
border-top: 1px solid #eee;
|
||
padding: 10px;
|
||
height: 200px;
|
||
position: fixed;
|
||
bottom: 80px;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 99;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 响应式适配 removed for strict uv-app-x compliance */
|
||
</style>
|
||
|