1763 lines
48 KiB
Vue
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-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>