1746 lines
48 KiB
Vue
Raw Normal View History

<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">
2026-04-01 14:27:31 +08:00
<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'
2026-04-01 14:27:31 +08:00
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('') // 用于防止重复显示相同的消息
2026-04-01 14:27:31 +08:00
// 获取开场白按钮数据
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()
}
2026-04-01 11:33:41 +08:00
// 处理快捷按钮点击
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连接已关闭')
}
})
// 页面加载时获取历史记录和开场白
2026-04-01 14:27:31 +08:00
onLoad(async (op) => {
// 始终获取开场白数据
quickQuestionsData.value = await getQuickQuestions()
console.log('页面加载时已获取开场白按钮数据:', quickQuestionsData.value.length)
// 获取历史记录
getHistoryMessages(1)
2026-04-01 14:27:31 +08:00
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 {
2026-04-01 14:27:31 +08:00
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;
2026-04-01 14:27:31 +08:00
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;
}
2026-04-01 11:35:25 +08:00
// 快捷按钮栏样式
.quick-actions-bar {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
padding: 0 0 16rpx 0;
2026-04-01 11:35:25 +08:00
}
.quick-action-btn {
height: 60rpx;
2026-04-01 11:35:25 +08:00
padding: 0 24rpx;
background-color: #ffffff;
border: 1px solid #e5e5e5;
border-radius: 100rpx;
font-size: 23rpx;
2026-04-01 11:35:25 +08:00
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>