2026-04-01 11:13:15 +08:00
|
|
|
|
<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_icon.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="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'
|
|
|
|
|
|
|
|
|
|
|
|
// 配置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字段
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 11:33:03 +08:00
|
|
|
|
// 快捷按钮接口定义
|
|
|
|
|
|
interface QuickAction {
|
|
|
|
|
|
label: string // 按钮显示文字
|
|
|
|
|
|
message: string // 点击后发送的消息内容
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 11:13:15 +08:00
|
|
|
|
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 11:33:03 +08:00
|
|
|
|
// 快捷按钮数据定义
|
|
|
|
|
|
const quickActions: QuickAction[] = [
|
|
|
|
|
|
{ label: '我的账单', message: '请帮我查询我的账单信息' },
|
|
|
|
|
|
{ label: '我要报修', message: '我要报修' },
|
|
|
|
|
|
{ label: '工单查询', message: '请帮我查询我的工单' },
|
|
|
|
|
|
{ label: '社区服务', message: '请提供社区服务信息' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-04-01 11:13:15 +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) => {
|
|
|
|
|
|
if (quickQuestionsData.value.length === 0) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 第一次进入会话聊天(没有历史记录)
|
|
|
|
|
|
if (messages.value.length === 0) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 当分页加载到最后一页时,展示开场白
|
|
|
|
|
|
if (isLastPage && messages.value.length > 0) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 找到第一条AI消息并添加开场白
|
|
|
|
|
|
const addQuickQuestionsToFirstAIMessage = (isLastPage: boolean = false) => {
|
|
|
|
|
|
if (quickQuestionsData.value.length === 0) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否应该展示开场白
|
|
|
|
|
|
if (!checkShouldShowQuickQuestions(isLastPage)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 检查是否已经存在开场白消息
|
|
|
|
|
|
const existingWelcomeMessage = messages.value.find(
|
|
|
|
|
|
msg => msg.role === 'ai' && msg.quickQuestions && msg.quickQuestions.length > 0
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if (existingWelcomeMessage) {
|
|
|
|
|
|
console.log('[Customer] 开场白已存在,跳过添加')
|
|
|
|
|
|
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 if (isLastPage) {
|
|
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 11:13:15 +08:00
|
|
|
|
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 () => {
|
|
|
|
|
|
// 始终获取开场白数据
|
|
|
|
|
|
quickQuestionsData.value = await getQuickQuestions()
|
|
|
|
|
|
console.log('页面加载时已获取开场白按钮数据:', quickQuestionsData.value.length)
|
|
|
|
|
|
|
|
|
|
|
|
// 获取历史记录
|
|
|
|
|
|
getHistoryMessages(1)
|
|
|
|
|
|
})
|
|
|
|
|
|
</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(200rpx + 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;
|
|
|
|
|
|
|
|
|
|
|
|
&.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: 0;
|
|
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.ai-message {
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
|
|
|
|
|
|
.message-content-wrapper {
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.message-content {
|
|
|
|
|
|
background-color: #fff;
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@keyframes fadeIn {
|
|
|
|
|
|
from {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: translateY(10rpx);
|
|
|
|
|
|
}
|
|
|
|
|
|
to {
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
transform: translateY(0);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.message-avatar {
|
|
|
|
|
|
width: 60rpx;
|
|
|
|
|
|
height: 60rpx;
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
|
|
&.loading {
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 纯图片消息的样式优化
|
|
|
|
|
|
&:has(.message-images) {
|
|
|
|
|
|
padding: 12rpx;
|
|
|
|
|
|
|
|
|
|
|
|
.user-message & {
|
|
|
|
|
|
padding: 8rpx;
|
|
|
|
|
|
background-color: transparent;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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: 12rpx;
|
|
|
|
|
|
padding: 16rpx 32rpx;
|
|
|
|
|
|
font-size: 28rpx;
|
|
|
|
|
|
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: #fff;
|
|
|
|
|
|
border-top: 1px solid #e5e5e5;
|
|
|
|
|
|
padding: 20rpx 20rpx;
|
|
|
|
|
|
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
|
|
|
|
|
z-index: 100;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.input-container {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
background-color: #f5f5f5;
|
|
|
|
|
|
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;
|
|
|
|
|
|
padding: 0 20rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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>
|