1763 lines
48 KiB
Vue
1763 lines
48 KiB
Vue
<template>
|
||
<view class="chat-container">
|
||
<view class="chat-messages">
|
||
<view
|
||
v-for="(message, index) in messages"
|
||
:key="index"
|
||
:id="`msg-${index}`"
|
||
class="message-item"
|
||
:class="message.role === 'user' ? 'user-message' : 'ai-message'"
|
||
>
|
||
<view v-if="message.role === 'ai'" class="message-avatar">
|
||
<image src="/static/svg/ai_avatar.svg" mode="aspectFit" />
|
||
</view>
|
||
<view class="message-content-wrapper">
|
||
<view class="message-content">
|
||
<!-- 图片展示 - 支持多张图片显示 -->
|
||
<view v-if="message.image_url && message.image_url.length > 0" class="message-images">
|
||
<view
|
||
v-for="(image, imgIndex) in message.image_url"
|
||
:key="imgIndex"
|
||
class="message-image-item"
|
||
:class="{ 'single-image': message.image_url.length === 1 }"
|
||
>
|
||
<image
|
||
:src="image"
|
||
:mode="message.image_url.length === 1 ? 'widthFix' : 'aspectFill'"
|
||
class="message-image"
|
||
@click="previewImage(image, message.image_url!)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 兼容旧的images字段 -->
|
||
<view v-else-if="message.images && message.images.length > 0" class="message-images">
|
||
<view
|
||
v-for="(image, imgIndex) in message.images"
|
||
:key="imgIndex"
|
||
class="message-image-item"
|
||
:class="{ 'single-image': message.images.length === 1 }"
|
||
>
|
||
<image
|
||
:src="image"
|
||
:mode="message.images.length === 1 ? 'widthFix' : 'aspectFill'"
|
||
class="message-image"
|
||
@click="previewImage(image, message.images!)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 兼容metadata.image_url字段 -->
|
||
<view
|
||
v-else-if="
|
||
message.metadata &&
|
||
message.metadata.image_url &&
|
||
message.metadata.image_url.length > 0
|
||
"
|
||
class="message-images"
|
||
>
|
||
<view
|
||
v-for="(image, imgIndex) in message.metadata.image_url"
|
||
:key="imgIndex"
|
||
class="message-image-item"
|
||
:class="{ 'single-image': message.metadata.image_url.length === 1 }"
|
||
>
|
||
<image
|
||
:src="image"
|
||
:mode="message.metadata.image_url.length === 1 ? 'widthFix' : 'aspectFill'"
|
||
class="message-image"
|
||
@click="previewImage(image, message.metadata.image_url!)"
|
||
/>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 文本内容 -->
|
||
<view
|
||
v-if="message.content && message.content.trim()"
|
||
v-for="(line, lineIndex) in formatMessageContent(message.content)"
|
||
:key="'text-' + lineIndex"
|
||
class="message-text-line"
|
||
>
|
||
<text>{{ line }}</text>
|
||
</view>
|
||
</view>
|
||
<view
|
||
v-if="message.quickQuestions && message.quickQuestions.length > 0"
|
||
class="quick-questions"
|
||
>
|
||
<view
|
||
v-for="(question, qIndex) in message.quickQuestions"
|
||
:key="qIndex"
|
||
class="question-btn"
|
||
@click="handleQuickQuestion(question)"
|
||
>
|
||
<text>{{ question }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 确认按钮 -->
|
||
<view v-if="message.needConfirmation" class="confirmation-buttons">
|
||
<view
|
||
class="confirmation-btn"
|
||
:class="{
|
||
selected: message.selectedConfirmation === '是',
|
||
disabled: message.selectedConfirmation !== undefined
|
||
}"
|
||
@click="handleConfirmation(message, '是')"
|
||
>
|
||
<text>是</text>
|
||
</view>
|
||
<view
|
||
class="confirmation-btn"
|
||
:class="{
|
||
selected: message.selectedConfirmation === '否',
|
||
disabled: message.selectedConfirmation !== undefined
|
||
}"
|
||
@click="handleConfirmation(message, '否')"
|
||
>
|
||
<text>否</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="message.created_at && !message.quickQuestions" class="message-meta">
|
||
<view
|
||
v-if="message.role === 'ai'"
|
||
class="copy-button"
|
||
@click="copyMessage(message.content)"
|
||
>
|
||
<image src="/static/svg/copy.svg" mode="aspectFit" class="copy-icon" />
|
||
</view>
|
||
<text class="message-time">{{ formatTime(message.created_at) }}</text>
|
||
<view
|
||
v-if="message.role === 'user'"
|
||
class="copy-button"
|
||
@click="copyMessage(message.content)"
|
||
>
|
||
<image src="/static/svg/copy.svg" mode="aspectFit" class="copy-icon" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-if="loading" class="message-item ai-message">
|
||
<view class="message-avatar">
|
||
<image src="/static/svg/ai_icon.svg" mode="aspectFit" />
|
||
</view>
|
||
<view class="message-content loading">
|
||
<text>{{ loadingText }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="chat-input-area">
|
||
<!-- 图片预览区域 -->
|
||
<view v-if="selectedImages.length > 0" class="image-preview-area">
|
||
<view class="image-preview-list">
|
||
<view v-for="(image, index) in selectedImages" :key="index" class="image-preview-item">
|
||
<image :src="image" mode="aspectFill" class="preview-image" />
|
||
<view class="remove-image" @click="removeImage(index)">
|
||
<uni-icons type="closeempty" size="14" color="#fff" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 添加更多图片按钮 -->
|
||
<view
|
||
v-if="selectedImages.length < 9"
|
||
class="image-preview-item add-more-button"
|
||
@click="chooseImage"
|
||
>
|
||
<uni-icons type="plus" size="30" color="#999" />
|
||
<text class="add-more-text">添加图片</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 快捷按钮栏 -->
|
||
<view class="quick-actions-bar">
|
||
<view
|
||
v-for="(action, index) in quickActions"
|
||
:key="index"
|
||
class="quick-action-btn"
|
||
@click="handleQuickAction(action)"
|
||
>
|
||
<text>{{ action.label }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="input-container">
|
||
<input
|
||
v-model="inputMessage"
|
||
class="message-input"
|
||
placeholder="请输入您的问题..."
|
||
:confirm-type="'send'"
|
||
@focus="isInputFocused = true"
|
||
@blur="isInputFocused = false"
|
||
@confirm="handleSendMessage"
|
||
/>
|
||
|
||
<!-- 相机按钮(当没有输入内容且输入框未聚焦时显示) -->
|
||
<view
|
||
v-if="!inputMessage.trim() && !isInputFocused && selectedImages.length === 0"
|
||
class="upload-button"
|
||
@click="chooseImage"
|
||
>
|
||
<uni-icons type="camera" size="24" color="#666" />
|
||
</view>
|
||
|
||
<!-- 发送按钮(当有输入内容或输入框聚焦或选择了图片时显示) -->
|
||
<view
|
||
v-if="(inputMessage.trim() || isInputFocused || selectedImages.length > 0) && !loading"
|
||
class="send-button"
|
||
@click="handleSendMessage"
|
||
>
|
||
<uni-icons type="paperplane-filled" size="20" color="#fff" />
|
||
</view>
|
||
|
||
<!-- 加载中状态 -->
|
||
<view v-if="loading" class="send-button disabled">
|
||
<uni-icons type="spinner-cycle" size="20" color="#ccc" />
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, nextTick, onUnmounted } from 'vue'
|
||
import { useWeAppAuthStore } from '@/common'
|
||
import { onPullDownRefresh, onLoad } from '@dcloudio/uni-app'
|
||
import dayjs from 'dayjs'
|
||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||
import 'dayjs/locale/zh-cn'
|
||
import { upload } from '@/common/libraries/upload'
|
||
import { quickActions,QuickAction } from '@/common/libraries/public'
|
||
|
||
// 配置dayjs
|
||
dayjs.extend(relativeTime)
|
||
dayjs.locale('zh-cn')
|
||
|
||
interface Message {
|
||
role: 'user' | 'ai'
|
||
content: string
|
||
created_at?: string
|
||
quickQuestions?: string[]
|
||
needConfirmation?: boolean
|
||
confirmationType?: string | null
|
||
selectedConfirmation?: string
|
||
images?: string[] // 新增图片字段
|
||
message_type?: 'text' | 'image' | 'mixed' // 消息类型
|
||
image_url?: string[] // 图片URL数组
|
||
metadata?: {
|
||
image_url?: string[] // metadata字段下的图片URL数组
|
||
[key: string]: any // 其他metadata字段
|
||
}
|
||
}
|
||
|
||
|
||
const auth = useWeAppAuthStore()
|
||
const inputMessage = ref('')
|
||
const selectedImages = ref<string[]>([]) // 选中的图片
|
||
const isInputFocused = ref(false) // 输入框是否聚焦
|
||
const messages = ref<Message[]>([
|
||
{
|
||
role: 'ai',
|
||
content: '您好!我是物业客服,有什么可以帮助您的吗?',
|
||
quickQuestions: []
|
||
}
|
||
])
|
||
const loading = ref(false)
|
||
const loadingText = ref('正在输入中...')
|
||
let socketTask: any = null
|
||
let isConnected = ref(false)
|
||
|
||
// 接口域名配置
|
||
const API_BASE_URL = 'https://kf-api-test.linyikj.com.cn' //测试环境
|
||
const WS_BASE_URL = 'wss://kf-api-test.linyikj.com.cn' //测试环境
|
||
|
||
// 历史记录相关
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(10)
|
||
const hasMore = ref(true)
|
||
const isLoadingHistory = ref(false)
|
||
const conversationId = ref<string>('') // 会话ID
|
||
const currentOffset = ref(0) // 当前偏移量
|
||
const quickQuestionsData = ref<string[]>([]) // 存储开场白数据
|
||
const lastMessageContent = ref('') // 用于防止重复显示相同的消息
|
||
|
||
|
||
|
||
// 获取开场白按钮数据
|
||
const getQuickQuestions = async () => {
|
||
try {
|
||
const response = await uni.request({
|
||
url: `${API_BASE_URL}/api/public/quick-questions`,
|
||
method: 'GET'
|
||
})
|
||
|
||
if (response.statusCode === 200) {
|
||
const result = response.data as any
|
||
let quickQuestions: string[] = []
|
||
|
||
// 处理不同的数据结构
|
||
if (result.data && Array.isArray(result.data)) {
|
||
quickQuestions = result.data.map(
|
||
(item: any) => item.question || item.title || item.text || item
|
||
)
|
||
} else if (result.questions && Array.isArray(result.questions)) {
|
||
quickQuestions = result.questions.map(
|
||
(item: any) => item.question || item.title || item.text || item
|
||
)
|
||
} else if (Array.isArray(result)) {
|
||
quickQuestions = result.map((item: any) => item.question || item.title || item.text || item)
|
||
}
|
||
|
||
console.log('获取到开场白按钮数据:', quickQuestions)
|
||
return quickQuestions
|
||
} else {
|
||
console.error('获取开场白数据失败,状态码:', response.statusCode)
|
||
return []
|
||
}
|
||
} catch (error) {
|
||
console.error('获取开场白数据异常:', error)
|
||
return []
|
||
}
|
||
}
|
||
|
||
// 格式化消息内容,处理换行符
|
||
const formatMessageContent = (content: string) => {
|
||
if (!content || typeof content !== 'string') {
|
||
return ['']
|
||
}
|
||
return content.split('\n').filter(line => line.trim() !== '')
|
||
}
|
||
|
||
// 预览图片
|
||
const previewImage = (currentImage: string, images: string[]) => {
|
||
uni.previewImage({
|
||
current: currentImage,
|
||
urls: images
|
||
})
|
||
}
|
||
|
||
// 复制消息内容
|
||
const copyMessage = (content: string) => {
|
||
if (!content) {
|
||
uni.showToast({
|
||
title: '暂无内容可复制',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
uni.setClipboardData({
|
||
data: content,
|
||
success: () => {
|
||
uni.showToast({
|
||
title: '复制成功',
|
||
icon: 'success'
|
||
})
|
||
},
|
||
fail: () => {
|
||
uni.showToast({
|
||
title: '复制失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
})
|
||
}
|
||
|
||
// 时间格式化函数
|
||
const formatTime = (timeStr: string) => {
|
||
if (!timeStr) return ''
|
||
|
||
try {
|
||
const date = dayjs(timeStr)
|
||
|
||
// 检查日期是否有效
|
||
if (!date.isValid()) {
|
||
return ''
|
||
}
|
||
|
||
// 使用相对时间显示
|
||
return date.fromNow()
|
||
} catch (e) {
|
||
console.error('时间格式化错误:', e)
|
||
return ''
|
||
}
|
||
}
|
||
|
||
const scrollToBottom = () => {
|
||
nextTick(() => {
|
||
// 使用页面滚动
|
||
uni.pageScrollTo({
|
||
scrollTop: 999999,
|
||
duration: 300
|
||
})
|
||
})
|
||
}
|
||
|
||
// 获取历史记录
|
||
const getHistoryMessages = async (page: number = 1) => {
|
||
const userPhone = auth.data?.user?.phone
|
||
|
||
if (!userPhone) {
|
||
console.log('用户未登录,无法获取历史记录')
|
||
return
|
||
}
|
||
|
||
if (isLoadingHistory.value) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
isLoadingHistory.value = true
|
||
|
||
// 计算offset,从0开始
|
||
const offset = (page - 1) * pageSize.value
|
||
currentOffset.value = offset
|
||
|
||
console.log('分页信息 - 页码:', page, '每页数量:', pageSize.value, '计算出的offset:', offset)
|
||
|
||
const response = await uni.request({
|
||
url: `${API_BASE_URL}/api/public/customer/init`,
|
||
method: 'POST',
|
||
data: {
|
||
platform: 'property',
|
||
platform_user_id: userPhone, //userPhone
|
||
limit: pageSize.value,
|
||
offset: offset
|
||
}
|
||
})
|
||
|
||
if (response.statusCode === 200) {
|
||
const result = response.data as any
|
||
let historyMessages: Message[] = []
|
||
|
||
// 获取会话ID
|
||
if (result.conversation && result.conversation.id) {
|
||
conversationId.value = result.conversation.id
|
||
console.log('获取到会话ID:', conversationId.value)
|
||
} else if (result.conversation_id) {
|
||
conversationId.value = result.conversation_id
|
||
console.log('获取到会话ID:', conversationId.value)
|
||
} else if (result.conversations && result.conversations.id) {
|
||
conversationId.value = result.conversations.id
|
||
console.log('获取到会话ID:', conversationId.value)
|
||
}
|
||
|
||
// 优先处理messages字段
|
||
if (result.messages && Array.isArray(result.messages)) {
|
||
historyMessages = result.messages.map((item: any) => {
|
||
const message: Message = {
|
||
role: item.sender_type === 'customer' ? 'user' : 'ai',
|
||
content: item.message || item.content || '',
|
||
created_at: item.created_at || item.timestamp || new Date().toISOString()
|
||
}
|
||
|
||
// 添加消息类型和图片字段支持
|
||
if (item.message_type) {
|
||
message.message_type = item.message_type
|
||
}
|
||
|
||
// 处理图片URL,支持多种数据结构
|
||
let imageUrlData: string[] | null = null
|
||
|
||
if (item.image_url && Array.isArray(item.image_url)) {
|
||
imageUrlData = item.image_url
|
||
} else if (item.images && Array.isArray(item.images)) {
|
||
imageUrlData = item.images
|
||
} else if (
|
||
item.metadata &&
|
||
item.metadata.image_url &&
|
||
Array.isArray(item.metadata.image_url)
|
||
) {
|
||
imageUrlData = item.metadata.image_url
|
||
}
|
||
|
||
if (imageUrlData) {
|
||
message.image_url = imageUrlData
|
||
message.images = imageUrlData // 兼容旧字段
|
||
}
|
||
|
||
// 保存metadata数据
|
||
if (item.metadata) {
|
||
message.metadata = item.metadata
|
||
}
|
||
|
||
return message
|
||
})
|
||
console.log('从messages字段获取到历史记录:', historyMessages.length)
|
||
}
|
||
// 兼容data字段
|
||
else if (result.data && Array.isArray(result.data)) {
|
||
historyMessages = result.data.map((item: any) => {
|
||
const message: Message = {
|
||
role: item.sender_type === 'customer' ? 'user' : 'ai',
|
||
content: item.message || item.content || '',
|
||
created_at: item.created_at || item.timestamp || new Date().toISOString()
|
||
}
|
||
|
||
// 添加消息类型和图片字段支持
|
||
if (item.message_type) {
|
||
message.message_type = item.message_type
|
||
}
|
||
|
||
// 处理图片URL,支持多种数据结构
|
||
let imageUrlData: string[] | null = null
|
||
|
||
if (item.image_url && Array.isArray(item.image_url)) {
|
||
imageUrlData = item.image_url
|
||
} else if (item.images && Array.isArray(item.images)) {
|
||
imageUrlData = item.images
|
||
} else if (
|
||
item.metadata &&
|
||
item.metadata.image_url &&
|
||
Array.isArray(item.metadata.image_url)
|
||
) {
|
||
imageUrlData = item.metadata.image_url
|
||
}
|
||
|
||
if (imageUrlData) {
|
||
message.image_url = imageUrlData
|
||
message.images = imageUrlData // 兼容旧字段
|
||
}
|
||
|
||
// 保存metadata数据
|
||
if (item.metadata) {
|
||
message.metadata = item.metadata
|
||
}
|
||
|
||
return message
|
||
})
|
||
console.log('从data字段获取到历史记录:', historyMessages.length)
|
||
}
|
||
// 兼容list字段
|
||
else if (result.list && Array.isArray(result.list)) {
|
||
historyMessages = result.list.map((item: any) => {
|
||
const message: Message = {
|
||
role: item.sender_type === 'customer' ? 'user' : 'ai',
|
||
content: item.message || item.content || '',
|
||
created_at: item.created_at || item.timestamp || new Date().toISOString()
|
||
}
|
||
|
||
// 添加消息类型和图片字段支持
|
||
if (item.message_type) {
|
||
message.message_type = item.message_type
|
||
}
|
||
|
||
// 处理图片URL,支持多种数据结构
|
||
let imageUrlData: string[] | null = null
|
||
|
||
if (item.image_url && Array.isArray(item.image_url)) {
|
||
imageUrlData = item.image_url
|
||
} else if (item.images && Array.isArray(item.images)) {
|
||
imageUrlData = item.images
|
||
} else if (
|
||
item.metadata &&
|
||
item.metadata.image_url &&
|
||
Array.isArray(item.metadata.image_url)
|
||
) {
|
||
imageUrlData = item.metadata.image_url
|
||
}
|
||
|
||
if (imageUrlData) {
|
||
message.image_url = imageUrlData
|
||
message.images = imageUrlData // 兼容旧字段
|
||
}
|
||
|
||
// 保存metadata数据
|
||
if (item.metadata) {
|
||
message.metadata = item.metadata
|
||
}
|
||
|
||
return message
|
||
})
|
||
console.log('从list字段获取到历史记录:', historyMessages.length)
|
||
}
|
||
|
||
if (page === 1) {
|
||
// 第一页直接替换消息(如果有历史记录)
|
||
if (historyMessages.length > 0) {
|
||
messages.value = historyMessages
|
||
console.log('第一页历史记录已加载,总共', historyMessages.length, '条消息')
|
||
} else {
|
||
// 如果没有历史记录,清空消息列表
|
||
console.log('没有历史记录')
|
||
messages.value = []
|
||
}
|
||
|
||
// 检查是否应该展示开场白(第一次进入或全部加载完成)
|
||
// 第一页如果hasMore为false,说明是最后一页,需要展示开场白
|
||
const isFirstPageLastPage = !hasMore.value
|
||
if (checkShouldShowQuickQuestions(isFirstPageLastPage)) {
|
||
addQuickQuestionsToFirstAIMessage(isFirstPageLastPage)
|
||
}
|
||
} else {
|
||
// 加载更多,使用unshift插入到前面
|
||
if (historyMessages.length > 0) {
|
||
// 延迟渲染数据,使过渡更流畅
|
||
setTimeout(() => {
|
||
// 使用unshift方法将新数据插入到数组前面
|
||
messages.value.unshift(...historyMessages)
|
||
console.log(
|
||
'加载更多历史记录,新增',
|
||
historyMessages.length,
|
||
'条消息,总共',
|
||
messages.value.length,
|
||
'条'
|
||
)
|
||
}, 300)
|
||
}
|
||
}
|
||
|
||
// 判断是否还有更多数据
|
||
const totalItems = result.total || result.count || result.total_count || 0
|
||
hasMore.value = totalItems > messages.value.length
|
||
currentPage.value = page
|
||
|
||
// 如果不是第一页且已经加载完所有数据,展示开场白
|
||
if (page > 1 && !hasMore.value) {
|
||
console.log('分页加载完成,当前页:', page, 'hasMore:', hasMore.value, '展示开场白')
|
||
// 等待数据渲染完成后再添加开场白
|
||
setTimeout(() => {
|
||
addQuickQuestionsToFirstAIMessage(true)
|
||
}, 500)
|
||
}
|
||
|
||
console.log(
|
||
'第',
|
||
page,
|
||
'页数据加载完成,offset:',
|
||
offset,
|
||
', limit:',
|
||
pageSize.value,
|
||
', 总数据:',
|
||
totalItems,
|
||
', 当前已加载:',
|
||
messages.value.length,
|
||
', 是否有更多:',
|
||
hasMore.value
|
||
)
|
||
|
||
// 如果是第一页,滚动到底部
|
||
if (page === 1) {
|
||
setTimeout(() => {
|
||
scrollToBottom()
|
||
}, 100)
|
||
|
||
// 第一页加载完成后,初始化WebSocket连接
|
||
console.log('[Customer] 历史记录加载完成,开始建立WebSocket连接')
|
||
initWebSocket()
|
||
}
|
||
} else {
|
||
console.error('获取历史记录失败,状态码:', response.statusCode)
|
||
}
|
||
} catch (error) {
|
||
console.error('获取历史记录异常:', error)
|
||
} finally {
|
||
isLoadingHistory.value = false
|
||
}
|
||
}
|
||
|
||
// 下拉分页加载更多历史记录
|
||
onPullDownRefresh(async () => {
|
||
if (isLoadingHistory.value || !hasMore.value) {
|
||
console.log('正在加载或没有更多数据')
|
||
uni.stopPullDownRefresh()
|
||
return
|
||
}
|
||
|
||
console.log('触发下拉分页,加载更多历史记录')
|
||
const nextPage = currentPage.value + 1
|
||
|
||
try {
|
||
// 先等待数据加载完成
|
||
await getHistoryMessages(nextPage)
|
||
|
||
// 再等待一下让数据渲染完成
|
||
await new Promise(resolve => setTimeout(resolve, 500))
|
||
} catch (error) {
|
||
console.error('加载历史记录失败:', error)
|
||
} finally {
|
||
// 确保停止下拉刷新动画
|
||
uni.stopPullDownRefresh()
|
||
}
|
||
})
|
||
|
||
const initWebSocket = () => {
|
||
if (socketTask) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
// Socket.IO兼容的WebSocket连接,使用/customer命名空间
|
||
const wsUrl = `${WS_BASE_URL}/ws/socket.io/?EIO=4&transport=websocket`
|
||
|
||
console.log('正在建立Socket.IO连接')
|
||
console.log('连接URL:', wsUrl)
|
||
|
||
socketTask = uni.connectSocket({
|
||
url: wsUrl,
|
||
header: {
|
||
'content-type': 'application/json'
|
||
}
|
||
})
|
||
|
||
// 监听Socket.IO连接打开事件
|
||
uni.onSocketOpen((res: any) => {
|
||
console.log('[Customer] WebSocket connected', res)
|
||
isConnected.value = true
|
||
|
||
// 发送Socket.IO连接包到/customer命名空间
|
||
const connectPacket = '40/customer,{"jwt":""}'
|
||
uni.sendSocketMessage({
|
||
data: connectPacket,
|
||
success: () => {
|
||
console.log('[Customer] Socket.IO连接包发送成功')
|
||
|
||
// 加入会话房间
|
||
if (conversationId.value) {
|
||
setTimeout(() => {
|
||
const joinMessage = `42/customer,["join_conversation",{"conversation_id":"${conversationId.value}"}]`
|
||
console.log('[Customer] 加入会话房间:', joinMessage)
|
||
|
||
uni.sendSocketMessage({
|
||
data: joinMessage,
|
||
success: () => {
|
||
console.log('[Customer] 成功加入会话房间')
|
||
},
|
||
fail: (error: any) => {
|
||
console.error('[Customer] 加入会话房间失败:', error)
|
||
}
|
||
})
|
||
}, 200)
|
||
}
|
||
},
|
||
fail: (error: any) => {
|
||
console.error('[Customer] Socket.IO连接包发送失败:', error)
|
||
}
|
||
})
|
||
})
|
||
|
||
// 监听Socket.IO错误事件
|
||
uni.onSocketError((error: any) => {
|
||
console.error('[Customer] WebSocket error:', error)
|
||
isConnected.value = false
|
||
socketTask = null
|
||
})
|
||
|
||
// 监听Socket.IO关闭事件
|
||
uni.onSocketClose((res: any) => {
|
||
console.log('[Customer] WebSocket disconnected', res)
|
||
isConnected.value = false
|
||
socketTask = null
|
||
})
|
||
|
||
// 监听Socket.IO消息事件
|
||
uni.onSocketMessage((res: any) => {
|
||
console.log('收到WebSocket原始消息:', res)
|
||
console.log('消息数据类型:', typeof res.data)
|
||
console.log('消息内容:', res.data)
|
||
|
||
// 处理WebSocket消息
|
||
const data = res.data
|
||
|
||
// 处理字符串类型的消息
|
||
if (typeof data === 'string') {
|
||
// 处理Socket.IO协议数据包
|
||
if (data.startsWith('42/customer,')) {
|
||
// Socket.IO事件数据包
|
||
const payload = data.substring('42/customer,'.length)
|
||
try {
|
||
const parsedData = JSON.parse(payload)
|
||
console.log('解析Socket.IO事件:', parsedData)
|
||
handleSocketEvent(parsedData)
|
||
} catch (e) {
|
||
console.error('解析Socket.IO数据失败:', e, '原始数据:', payload)
|
||
}
|
||
} else if (data.startsWith('40/customer')) {
|
||
console.log('Socket.IO连接确认')
|
||
} else if (data.startsWith('2')) {
|
||
// Ping包,回复Pong
|
||
uni.sendSocketMessage({
|
||
data: '3'
|
||
})
|
||
} else if (data.startsWith('0')) {
|
||
console.log('Socket.IO握手成功')
|
||
} else {
|
||
// 尝试直接解析JSON消息
|
||
try {
|
||
const jsonData = JSON.parse(data)
|
||
console.log('解析JSON消息:', jsonData)
|
||
handleJsonMessage(jsonData)
|
||
} catch (e) {
|
||
console.log('非JSON格式的字符串消息:', data)
|
||
}
|
||
}
|
||
}
|
||
// 处理对象类型的消息
|
||
else if (typeof data === 'object') {
|
||
console.log('收到对象类型消息:', data)
|
||
handleObjectMessage(data)
|
||
}
|
||
// 处理二进制数据或其他类型
|
||
else {
|
||
console.log('收到其他类型消息,类型:', typeof data)
|
||
}
|
||
})
|
||
} catch (error) {
|
||
console.error('初始化Socket.IO异常:', error)
|
||
isConnected.value = false
|
||
socketTask = null
|
||
}
|
||
}
|
||
|
||
// 处理Socket.IO事件消息
|
||
const handleSocketEvent = (parsedData: any) => {
|
||
if (Array.isArray(parsedData) && parsedData.length >= 2) {
|
||
const eventType = parsedData[0]
|
||
const eventData = parsedData[1]
|
||
console.log('[Customer] Socket.IO事件类型:', eventType, '事件数据:', eventData)
|
||
|
||
if (eventType === 'new_message') {
|
||
processMessage(eventData)
|
||
} else if (eventType === 'join_conversation_response') {
|
||
console.log('[Customer] Joined room:', eventData)
|
||
} else if (eventType === 'message') {
|
||
processMessage(eventData)
|
||
} else if (eventType === 'typing') {
|
||
processTypingIndicator(eventData)
|
||
} else {
|
||
console.log('[Customer] 未处理的Socket.IO事件类型:', eventType)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理JSON格式消息
|
||
const handleJsonMessage = (jsonData: any) => {
|
||
console.log('[Customer] 处理JSON消息:', jsonData)
|
||
|
||
// 处理各种可能的消息结构
|
||
if (jsonData.type === 'message' || jsonData.event === 'new_message') {
|
||
processMessage(jsonData.data || jsonData)
|
||
} else if (jsonData.type === 'typing' || jsonData.event === 'typing') {
|
||
processTypingIndicator(jsonData.data || jsonData)
|
||
} else if (jsonData.message || jsonData.content) {
|
||
processMessage(jsonData)
|
||
} else {
|
||
console.log('[Customer] 未识别的JSON消息结构:', jsonData)
|
||
}
|
||
}
|
||
|
||
// 处理对象类型消息
|
||
const handleObjectMessage = (objData: any) => {
|
||
console.log('[Customer] 处理对象消息:', objData)
|
||
|
||
// 直接处理消息对象
|
||
if (objData.message || objData.content || objData.text) {
|
||
processMessage(objData)
|
||
} else if (objData.is_typing !== undefined) {
|
||
processTypingIndicator(objData)
|
||
} else {
|
||
console.log('[Customer] 未识别的对象消息结构:', objData)
|
||
}
|
||
}
|
||
|
||
// 处理typing指示器
|
||
const processTypingIndicator = (typingData: any) => {
|
||
console.log('[Customer] 处理typing指示器:', typingData)
|
||
|
||
// 检查是否是客服正在输入
|
||
if (typingData.is_typing && typingData.user_type === 'staff') {
|
||
loading.value = true
|
||
loadingText.value = '客服正在输入...'
|
||
} else {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 检查是否应该展示开场白
|
||
const checkShouldShowQuickQuestions = (isLastPage: boolean = false) => {
|
||
// 只要有开场白数据,就展示开场白
|
||
return quickQuestionsData.value.length > 0
|
||
}
|
||
|
||
// 找到第一条AI消息并添加开场白
|
||
const addQuickQuestionsToFirstAIMessage = (isLastPage: boolean = false) => {
|
||
if (quickQuestionsData.value.length === 0) {
|
||
return
|
||
}
|
||
|
||
// 检查是否应该展示开场白
|
||
if (!checkShouldShowQuickQuestions(isLastPage)) {
|
||
return
|
||
}
|
||
|
||
// 创建开场白消息
|
||
const welcomeMessage: Message = {
|
||
role: 'ai',
|
||
content: '您好!我是物业客服,有什么可以帮助您的吗?',
|
||
quickQuestions: quickQuestionsData.value,
|
||
created_at: new Date().toISOString()
|
||
}
|
||
|
||
console.log('[Customer] 创建开场白消息,是否最后一页:', isLastPage)
|
||
|
||
// 如果没有历史记录,直接设置开场白
|
||
if (messages.value.length === 0) {
|
||
messages.value = [welcomeMessage]
|
||
console.log('[Customer] 设置开场白为第一条消息')
|
||
}
|
||
// 如果有历史记录,在第一条消息前面插入开场白
|
||
else {
|
||
messages.value.unshift(welcomeMessage)
|
||
console.log('[Customer] 在第一条消息前面插入开场白')
|
||
}
|
||
}
|
||
|
||
// 处理消息内容的统一函数
|
||
const processMessage = (messageData: any) => {
|
||
console.log('[Customer] 处理消息数据:', messageData)
|
||
|
||
// 不显示自己发的消息(已经通过HTTP显示了)
|
||
if (messageData.sender_type === 'customer' || messageData.role === 'user') {
|
||
console.log('[Customer] 过滤掉客户消息')
|
||
return
|
||
}
|
||
|
||
// 提取消息内容 - 客户端统一显示"客服",不区分AI/人工
|
||
let messageContent = ''
|
||
if (typeof messageData === 'string') {
|
||
messageContent = messageData
|
||
} else {
|
||
messageContent =
|
||
messageData.message ||
|
||
messageData.content ||
|
||
messageData.text ||
|
||
messageData.body ||
|
||
'收到回复'
|
||
}
|
||
|
||
// 处理消息内容中的换行符
|
||
if (typeof messageContent === 'string') {
|
||
messageContent = messageContent.replace(/↵/g, '\n').replace(/\\n/g, '\n')
|
||
}
|
||
|
||
console.log('[Customer] 收到新消息内容:', messageContent)
|
||
|
||
// 检查是否与上一条HTTP返回的消息相同,避免重复显示
|
||
if (messageContent === lastMessageContent.value) {
|
||
console.log('[Customer] 消息与HTTP返回的消息相同,跳过显示')
|
||
lastMessageContent.value = '' // 清除缓存,准备下一次检查
|
||
return
|
||
}
|
||
|
||
// 隐藏typing指示器
|
||
loading.value = false
|
||
|
||
// 添加到消息列表,统一显示为"客服"
|
||
const newMessage: Message = {
|
||
role: 'ai',
|
||
content: messageContent,
|
||
created_at:
|
||
messageData.created_at ||
|
||
messageData.timestamp ||
|
||
messageData.time ||
|
||
new Date().toISOString()
|
||
}
|
||
|
||
// 处理建议问题
|
||
if (
|
||
messageData.suggested_questions &&
|
||
Array.isArray(messageData.suggested_questions) &&
|
||
messageData.suggested_questions.length > 0
|
||
) {
|
||
newMessage.quickQuestions = messageData.suggested_questions
|
||
} else if (
|
||
messageData.quickQuestions &&
|
||
Array.isArray(messageData.quickQuestions) &&
|
||
messageData.quickQuestions.length > 0
|
||
) {
|
||
newMessage.quickQuestions = messageData.quickQuestions
|
||
}
|
||
|
||
// 处理确认信息
|
||
if (messageData.need_confirmation !== undefined) {
|
||
newMessage.needConfirmation = messageData.need_confirmation
|
||
}
|
||
if (messageData.confirmation_type !== undefined) {
|
||
newMessage.confirmationType = messageData.confirmation_type
|
||
}
|
||
|
||
messages.value.push(newMessage)
|
||
scrollToBottom()
|
||
}
|
||
|
||
const handleQuickQuestion = (question: string) => {
|
||
inputMessage.value = question
|
||
handleSendMessage()
|
||
}
|
||
|
||
// 处理快捷按钮点击
|
||
const handleQuickAction = (action: QuickAction) => {
|
||
inputMessage.value = action.message
|
||
handleSendMessage()
|
||
}
|
||
|
||
const handleConfirmation = (message: Message, confirmation: string) => {
|
||
// 如果已经选择过,不再处理
|
||
if (message.selectedConfirmation !== undefined) {
|
||
return
|
||
}
|
||
|
||
// 设置选中状态
|
||
message.selectedConfirmation = confirmation
|
||
|
||
// 延迟发送消息,让用户看到选中效果
|
||
setTimeout(() => {
|
||
inputMessage.value = confirmation
|
||
handleSendMessage()
|
||
}, 300)
|
||
}
|
||
|
||
const chooseImage = async () => {
|
||
const maxImages = 9
|
||
if (selectedImages.value.length >= maxImages) {
|
||
uni.showToast({
|
||
title: '最多选择9张图片',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
const remainingCount = maxImages - selectedImages.value.length
|
||
const uploadResult = await upload(remainingCount, 2, ['image'])
|
||
|
||
if (uploadResult && Array.isArray(uploadResult) && uploadResult.length > 0) {
|
||
const imageUrls = uploadResult.map((item: any) => item.url)
|
||
selectedImages.value.push(...imageUrls)
|
||
|
||
uni.showToast({
|
||
title: '图片上传成功',
|
||
icon: 'success'
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.error('选择图片失败:', error)
|
||
uni.showToast({
|
||
title: '选择图片失败',
|
||
icon: 'none'
|
||
})
|
||
}
|
||
}
|
||
|
||
const removeImage = (index: number) => {
|
||
selectedImages.value.splice(index, 1)
|
||
}
|
||
|
||
const handleSendMessage = async () => {
|
||
const message = inputMessage.value.trim()
|
||
const hasImages = selectedImages.value.length > 0
|
||
|
||
if ((!message && !hasImages) || loading.value) return
|
||
|
||
const userPhone = auth.data?.user?.phone
|
||
const projectId = auth.data?.selected_house?.asset_projects_id
|
||
const projectName = auth.data?.selected_house?.full_name
|
||
|
||
if (!userPhone || !projectId) {
|
||
uni.showToast({
|
||
title: '请先登录并绑定房屋',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
|
||
// 如果WebSocket未连接,先建立连接
|
||
if (!isConnected.value) {
|
||
initWebSocket()
|
||
// 等待连接建立
|
||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||
}
|
||
|
||
// 图片已经在上传时就存储了OSS链接,直接使用
|
||
const uploadedImages = selectedImages.value
|
||
|
||
// 确定消息类型
|
||
let messageType: 'text' | 'image' | 'mixed' = 'text'
|
||
if (hasImages && message) {
|
||
messageType = 'mixed'
|
||
} else if (hasImages && !message) {
|
||
messageType = 'image'
|
||
}
|
||
|
||
// 构建用户消息对象
|
||
const userMessage: Message = {
|
||
role: 'user',
|
||
content: message || '', // 纯图片时传空字符串
|
||
created_at: new Date().toISOString(),
|
||
message_type: messageType,
|
||
image_url: uploadedImages.length > 0 ? uploadedImages : undefined,
|
||
images: uploadedImages.length > 0 ? uploadedImages : undefined
|
||
}
|
||
|
||
messages.value.push(userMessage)
|
||
|
||
inputMessage.value = ''
|
||
selectedImages.value = []
|
||
scrollToBottom()
|
||
|
||
// 判断消息中是否包含"查询"关键词,设置相应的loading文案
|
||
if (message && message.includes('查询')) {
|
||
loadingText.value = '正在查询中请稍后...'
|
||
} else {
|
||
loadingText.value = '正在输入中...'
|
||
}
|
||
|
||
loading.value = true
|
||
|
||
try {
|
||
// 使用HTTP接口发送消息
|
||
const response = await uni.request({
|
||
url: `${API_BASE_URL}/api/public/chat`,
|
||
method: 'POST',
|
||
data: {
|
||
platform: 'property',
|
||
message: message || '', // 纯图片时传空字符串
|
||
tenant_project_id: projectId,
|
||
tenant_project_name: projectName || '',
|
||
platform_user_id: userPhone,
|
||
conversation_id: conversationId.value,
|
||
message_type: messageType,
|
||
image_url: uploadedImages.length > 0 ? uploadedImages : undefined
|
||
},
|
||
header: {
|
||
'Content-Type': 'application/json'
|
||
}
|
||
})
|
||
|
||
if (response.statusCode === 200) {
|
||
const result = response.data as any
|
||
console.log('[Customer] HTTP接口响应数据:', result)
|
||
|
||
// 处理HTTP接口返回的回答消息
|
||
let httpReplyMessage = null
|
||
let suggestedQuestions = []
|
||
let needConfirmation = false
|
||
let confirmationType = null
|
||
|
||
// 处理新的数据结构:{reply: "...", suggested_questions: [...], success: true, confirmation_type: "...", ...}
|
||
if (result.reply && typeof result.reply === 'string') {
|
||
httpReplyMessage = result.reply
|
||
suggestedQuestions = result.suggested_questions || []
|
||
needConfirmation = result.need_confirmation || false
|
||
confirmationType = result.confirmation_type || null
|
||
}
|
||
// 兼容其他可能的数据结构
|
||
else if (result.reply && result.reply.message) {
|
||
httpReplyMessage = result.reply.message
|
||
suggestedQuestions = result.reply.suggested_questions || result.suggested_questions || []
|
||
needConfirmation = result.reply.need_confirmation || result.need_confirmation || false
|
||
confirmationType = result.reply.confirmation_type || result.confirmation_type || null
|
||
} else if (result.reply && result.reply.content) {
|
||
httpReplyMessage = result.reply.content
|
||
suggestedQuestions = result.reply.suggested_questions || result.suggested_questions || []
|
||
needConfirmation = result.reply.need_confirmation || result.need_confirmation || false
|
||
confirmationType = result.reply.confirmation_type || result.confirmation_type || null
|
||
} else if (result.message) {
|
||
httpReplyMessage = result.message
|
||
suggestedQuestions = result.suggested_questions || []
|
||
needConfirmation = result.need_confirmation || false
|
||
confirmationType = result.confirmation_type || null
|
||
} else if (result.content) {
|
||
httpReplyMessage = result.content
|
||
suggestedQuestions = result.suggested_questions || []
|
||
needConfirmation = result.need_confirmation || false
|
||
confirmationType = result.confirmation_type || null
|
||
} else if (result.answer) {
|
||
httpReplyMessage = result.answer
|
||
suggestedQuestions = result.suggested_questions || []
|
||
needConfirmation = result.need_confirmation || false
|
||
confirmationType = result.confirmation_type || null
|
||
} else if (result.response) {
|
||
httpReplyMessage = result.response
|
||
suggestedQuestions = result.suggested_questions || []
|
||
needConfirmation = result.need_confirmation || false
|
||
confirmationType = result.confirmation_type || null
|
||
} else if (typeof result === 'string') {
|
||
httpReplyMessage = result
|
||
}
|
||
|
||
// 如果HTTP接口返回了回答消息,直接展示
|
||
if (httpReplyMessage) {
|
||
console.log('[Customer] HTTP接口返回回答消息:', httpReplyMessage)
|
||
console.log('[Customer] 建议问题:', suggestedQuestions)
|
||
console.log('[Customer] 需要确认:', needConfirmation, '确认类型:', confirmationType)
|
||
|
||
// 处理消息内容中的换行符
|
||
let formattedMessage = httpReplyMessage
|
||
if (typeof formattedMessage === 'string') {
|
||
// 将 ↵ 替换为换行符,将 \n 替换为换行符
|
||
formattedMessage = formattedMessage.replace(/↵/g, '\n').replace(/\\n/g, '\n')
|
||
}
|
||
|
||
// 记录消息内容,用于防止WebSocket重复显示
|
||
lastMessageContent.value = formattedMessage
|
||
|
||
// 隐藏loading指示器
|
||
loading.value = false
|
||
|
||
// 添加AI回答到消息列表,包含建议问题和确认信息
|
||
messages.value.push({
|
||
role: 'ai',
|
||
content: formattedMessage,
|
||
created_at: new Date().toISOString(),
|
||
quickQuestions: suggestedQuestions.length > 0 ? suggestedQuestions : undefined,
|
||
needConfirmation: needConfirmation,
|
||
confirmationType: confirmationType
|
||
})
|
||
scrollToBottom()
|
||
} else {
|
||
// 如果HTTP接口没有返回回答消息,等待WebSocket接收AI的回复
|
||
console.log('[Customer] 消息发送成功,等待WebSocket回复')
|
||
}
|
||
} else {
|
||
throw new Error('请求失败')
|
||
}
|
||
} catch (error) {
|
||
console.error('发送消息失败:', error)
|
||
uni.showToast({
|
||
title: '发送失败,请稍后重试',
|
||
icon: 'none'
|
||
})
|
||
// 发送失败时才添加错误消息
|
||
messages.value.push({
|
||
role: 'ai',
|
||
content: '抱歉,网络连接出现问题,请稍后再试。'
|
||
})
|
||
loading.value = false
|
||
scrollToBottom()
|
||
}
|
||
// 发送成功时不设置loading为false,等待WebSocket回复后再设置
|
||
}
|
||
|
||
// 页面卸载时关闭WebSocket连接
|
||
onUnmounted(() => {
|
||
if (socketTask || isConnected.value) {
|
||
uni.closeSocket()
|
||
socketTask = null
|
||
isConnected.value = false
|
||
console.log('页面卸载,WebSocket连接已关闭')
|
||
}
|
||
})
|
||
|
||
// 页面加载时获取历史记录和开场白
|
||
onLoad(async (op) => {
|
||
// 始终获取开场白数据
|
||
quickQuestionsData.value = await getQuickQuestions()
|
||
console.log('页面加载时已获取开场白按钮数据:', quickQuestionsData.value.length)
|
||
|
||
// 获取历史记录
|
||
getHistoryMessages(1)
|
||
|
||
// 每次进入页面都在最前方插入一条新的开场白信息
|
||
if (quickQuestionsData.value.length > 0) {
|
||
const welcomeMessage: Message = {
|
||
role: 'ai',
|
||
content: '您好!我是物业客服,有什么可以帮助您的吗?',
|
||
quickQuestions: quickQuestionsData.value,
|
||
created_at: new Date().toISOString()
|
||
}
|
||
// 等待历史记录加载完成后插入到最前方
|
||
nextTick(() => {
|
||
messages.value.unshift(welcomeMessage)
|
||
console.log('[Customer] 每次进入页面都在最前方插入新开场白消息')
|
||
scrollToBottom()
|
||
})
|
||
}
|
||
|
||
if(op?.message){
|
||
handleQuickAction(op as QuickAction)
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.chat-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 100vh;
|
||
width: 100vw;
|
||
// background-color: #f5f5f5;
|
||
}
|
||
|
||
.chat-messages {
|
||
width: 100%;
|
||
padding: 30rpx 20rpx;
|
||
padding-bottom: calc(220rpx + env(safe-area-inset-bottom));
|
||
box-sizing: border-box;
|
||
flex: 1;
|
||
}
|
||
|
||
.message-item {
|
||
display: flex;
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
margin-bottom: 30rpx;
|
||
animation: fadeIn 0.3s ease-in;
|
||
|
||
|
||
}
|
||
.ai-message {
|
||
align-items: flex-start;
|
||
|
||
.message-content-wrapper {
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.message-content {
|
||
background-color: #f5f5f5;
|
||
color: #333;
|
||
margin-left: 12rpx;
|
||
margin-right: 0;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.message-time {
|
||
color: #999;
|
||
font-size: 22rpx;
|
||
min-width: 80rpx;
|
||
}
|
||
|
||
.message-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
margin-top: 6rpx;
|
||
gap: 8rpx;
|
||
margin-left: 12rpx;
|
||
flex-direction: row;
|
||
}
|
||
|
||
.message-avatar {
|
||
margin-right: 0;
|
||
flex-shrink: 0;
|
||
}
|
||
}
|
||
|
||
.user-message {
|
||
justify-content: flex-end;
|
||
align-items: flex-start;
|
||
|
||
.message-content-wrapper {
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.message-content {
|
||
background-color: #1c64f2;
|
||
color: #fff;
|
||
margin-right: 0;
|
||
flex-shrink: 0;
|
||
|
||
// 用户消息中的图片显示优化
|
||
.message-images {
|
||
margin-bottom: 20rpx;
|
||
|
||
.message-image-item {
|
||
border-radius: 8rpx;
|
||
}
|
||
}
|
||
}
|
||
|
||
.message-time {
|
||
color: #999;
|
||
font-size: 22rpx;
|
||
margin-top: 6rpx;
|
||
text-align: right;
|
||
min-width: 80rpx;
|
||
}
|
||
|
||
.message-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
margin-top: 6rpx;
|
||
gap: 8rpx;
|
||
flex-direction: row;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
@keyframes fadeIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(10rpx);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.message-avatar {
|
||
width: 80rpx;
|
||
height: 80rpx;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
|
||
image {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.user-avatar-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.user-avatar {
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #fff;
|
||
font-size: 24rpx;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.message-content-wrapper {
|
||
display: flex;
|
||
flex-direction: column;
|
||
max-width: calc(100vw - 100rpx);
|
||
}
|
||
|
||
.message-content {
|
||
padding: 20rpx 24rpx;
|
||
border-radius: 16rpx;
|
||
font-size: 28rpx;
|
||
line-height: 1.5;
|
||
word-wrap: break-word;
|
||
word-break: break-all;
|
||
box-sizing: border-box;
|
||
background-color: #1c64f2;
|
||
&.loading {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
// 纯图片消息的样式优化
|
||
&:has(.message-images) {
|
||
padding: 12rpx;
|
||
|
||
.user-message & {
|
||
padding: 8rpx;
|
||
}
|
||
}
|
||
|
||
view {
|
||
display: block;
|
||
width: 100%;
|
||
word-wrap: break-word;
|
||
word-break: break-all;
|
||
|
||
&:not(:last-child) {
|
||
margin-bottom: 8rpx;
|
||
}
|
||
}
|
||
|
||
text {
|
||
display: inline;
|
||
word-wrap: break-word;
|
||
word-break: break-all;
|
||
}
|
||
}
|
||
|
||
.quick-questions {
|
||
margin-top: 16rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12rpx;
|
||
padding-left: 12rpx;
|
||
}
|
||
|
||
.question-btn {
|
||
background-color: #f0f7ff;
|
||
border: 1px solid #d0e3ff;
|
||
border-radius: 12rpx;
|
||
padding: 16rpx 20rpx;
|
||
font-size: 26rpx;
|
||
color: #1c64f2;
|
||
line-height: 1.4;
|
||
transition: all 0.2s ease;
|
||
|
||
&:active {
|
||
background-color: #e6f2ff;
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
text {
|
||
display: block;
|
||
word-wrap: break-word;
|
||
word-break: break-all;
|
||
}
|
||
}
|
||
|
||
.confirmation-buttons {
|
||
margin-top: 16rpx;
|
||
display: flex;
|
||
gap: 16rpx;
|
||
justify-content: flex-start;
|
||
padding-left: 12rpx;
|
||
}
|
||
|
||
.confirmation-btn {
|
||
width: 38rpx;
|
||
border-radius: 10rpx;
|
||
padding: 16rpx 22rpx;
|
||
font-size: 24rpx;
|
||
line-height: 1.4;
|
||
text-align: center;
|
||
transition: all 0.2s ease;
|
||
background-color: #fff;
|
||
color: #000;
|
||
border: 1px solid #e5e5e5;
|
||
|
||
&:active:not(.disabled) {
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
&.selected {
|
||
background-color: #1c64f2;
|
||
color: #fff;
|
||
border-color: #1c64f2;
|
||
}
|
||
|
||
&.disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
text {
|
||
display: block;
|
||
font-weight: 500;
|
||
}
|
||
}
|
||
|
||
.chat-input-area {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
background-color: #f8f8f8;
|
||
border-top: 1px solid #eee;
|
||
padding: 20rpx 20rpx;
|
||
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
|
||
z-index: 100;
|
||
}
|
||
|
||
.input-container {
|
||
display: flex;
|
||
align-items: center;
|
||
background-color: #fff;
|
||
border-radius: 50rpx;
|
||
padding: 10rpx 20rpx;
|
||
}
|
||
|
||
.message-input {
|
||
flex: 1;
|
||
height: 70rpx;
|
||
font-size: 28rpx;
|
||
padding: 0 20rpx;
|
||
background-color: transparent;
|
||
}
|
||
|
||
.send-button {
|
||
width: 70rpx;
|
||
height: 70rpx;
|
||
background: linear-gradient(135deg, #1c64f2 0%, #0e4aa7 100%);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-left: 20rpx;
|
||
|
||
&.disabled {
|
||
opacity: 0.5;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
.upload-button {
|
||
width: 70rpx;
|
||
height: 70rpx;
|
||
background-color: #f5f5f5;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-left: 20rpx;
|
||
}
|
||
|
||
.image-preview-area {
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
// 快捷按钮栏样式
|
||
.quick-actions-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16rpx;
|
||
padding: 0 0 16rpx 0;
|
||
}
|
||
|
||
.quick-action-btn {
|
||
height: 60rpx;
|
||
padding: 0 24rpx;
|
||
background-color: #ffffff;
|
||
border: 1px solid #e5e5e5;
|
||
border-radius: 100rpx;
|
||
font-size: 23rpx;
|
||
color: #333333;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s ease;
|
||
white-space: nowrap;
|
||
|
||
&:active {
|
||
transform: scale(0.98);
|
||
background-color: #f8f8f8;
|
||
}
|
||
|
||
text {
|
||
display: block;
|
||
}
|
||
}
|
||
|
||
.image-preview-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.image-preview-item {
|
||
position: relative;
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
border-radius: 12rpx;
|
||
overflow: hidden;
|
||
|
||
&.add-more-button {
|
||
background-color: #f5f5f5;
|
||
border: 2rpx dashed #ddd;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
|
||
&:active {
|
||
background-color: #e8e8e8;
|
||
}
|
||
}
|
||
}
|
||
|
||
.preview-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.add-more-text {
|
||
font-size: 22rpx;
|
||
color: #999;
|
||
margin-top: 8rpx;
|
||
}
|
||
|
||
.remove-image {
|
||
position: absolute;
|
||
top: 8rpx;
|
||
right: 8rpx;
|
||
width: 40rpx;
|
||
height: 40rpx;
|
||
background-color: rgba(0, 0, 0, 0.6);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.message-images {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12rpx;
|
||
margin-bottom: 12rpx;
|
||
max-width: 500rpx;
|
||
|
||
// 用户消息中的图片样式优化
|
||
.user-message & {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.message-image-item {
|
||
border-radius: 12rpx;
|
||
overflow: hidden;
|
||
|
||
&.single-image {
|
||
max-width: 500rpx;
|
||
width: 500rpx !important;
|
||
height: auto !important;
|
||
|
||
// 用户消息单张图片的样式
|
||
.user-message & {
|
||
max-width: 450rpx;
|
||
width: 450rpx !important;
|
||
}
|
||
}
|
||
|
||
&:not(.single-image) {
|
||
width: 200rpx;
|
||
height: 200rpx;
|
||
}
|
||
}
|
||
|
||
.message-image {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
}
|
||
|
||
.message-text-line {
|
||
margin-bottom: 4rpx;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
}
|
||
|
||
.copy-button {
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 4rpx;
|
||
transition: background-color 0.2s ease;
|
||
|
||
&:active {
|
||
background-color: rgba(0, 0, 0, 0.05);
|
||
}
|
||
|
||
.copy-icon {
|
||
width: 26rpx;
|
||
height: 26rpx;
|
||
opacity: 0.7;
|
||
}
|
||
}
|
||
</style>
|