<template>
<view class="kefu-chat">
<!-- 顶部导航栏 -->
<uv-navbar :title="friendInfo.nickname || merInfo.merchant_name || '客服'" :autoBack="true" :placeholder="true">
<template v-slot:left>
<uv-icon name="arrow-left" size="20" color="#333" @click="goBack" />
</template>
</uv-navbar>
<!-- 聊天内容区域 -->
<scroll-view class="chat-content" scroll-y :scroll-top="scrollTop" :scroll-into-view="scrollIntoViewId"
:scroll-with-animation="true" @scrolltoupper="loadMoreMessages" @scroll="onScroll"
:enhanced="true"
:bounce="true"
scroll-anchoring="true"
style="transform: translateZ(0); position: relative; z-index: 1;"
>
<view class="message-list">
<view v-for="(message, index) in messageList" :key="index" :id="'' + index"
:class="{ 'message-right': message.uid === userInfo.id }" class="message-item">
<!-- 用户头像 -->
<view class="avatar">
<uv-image
:src="message.uid === userInfo.id ? userInfo.avatar : (friendInfo.avatar || merInfo.brand_logo)"
width="50" height="50" shape="circle"
:errorIcon="getIconPath('https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1208/703ee202512081525532575.png')" />
</view>
<!-- 消息内容 -->
<view class="message-content">
<view class="message-bubble"
:style="message.type !== 4 && message.type !== 5?'background-color: white;padding:20rpx;box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);':''"
:class="{ 'bubble-right': message.uid === userInfo.id }">
<!-- 图片消息 -->
<view v-if="message.type === 4 && message.image_url" class="image-message"
@click="previewImage(message.image_url)">
<uv-image :src="message.image_url" mode="aspectFit"
:style="{width: `min(${message.image_width}rpx)`, height: `min(${message.image_height}rpx, 400rpx)`}"
style="opacity: 1;" :errorIcon="getIconPath('image-error.png')" />
</view>
<!-- 视频消息 -->
<view v-else-if="message.type === 5 && message.video_url" class="video-message">
<video
:src="message.video_url"
class="video-player"
controls
playsinline
webkit-playsinline="true"
x5-playsinline="true"
x5-video-player-type="h5"
x5-video-orientation="portrait"
:show-center-play-btn="true"
:enable-progress-gesture="false"
:vslide-gesture="false"
:app-plus="{
videoEmbeded: true, // 必加:嵌入WebView
zindex: 1, // 层级与WebView一致
renderMode: 'webview', // 强制WebView渲染
width: '400rpx',
height: '400rpx'
}"
@click="playVideo(message.video_url)"
></video>
</view>
<!-- 商品消息 -->
<view v-else-if="message.type === 2 && message.product_info" class="product-message">
<view class="product-message-header">
<text class="product-label">商品信息</text>
</view>
<view class="product-message-content">
<uv-image :src="message.product_info.image" width="60" height="60" mode="aspectFill"
:errorIcon="getIconPath('https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1208/faa3e202512081724257635.png')" />
<view class="product-message-details">
<text class="product-message-name">{{ message.product_info.name }}</text>
<text class="product-message-price">¥{{ message.product_info.price }}</text>
</view>
</view>
</view>
<!-- 服务订单消息 -->
<view v-else-if="message.type === 3 && message.service_order_info"
class="service-order-message">
<view class="service-order-header">
<text class="service-order-label">服务订单信息</text>
</view>
<view class="service-order-content">
<view class="service-order-details">
<text
class="service-order-name">{{ message.service_order_info.product_name }}</text>
<text
class="service-order-price">¥{{ message.service_order_info.total_price }}</text>
<text
class="service-order-time">服务时间:{{ message.service_order_info.service_time }}</text>
<text
class="service-order-address">服务地址:{{ message.service_order_info.address_address }}</text>
<text
class="service-order-no">订单号:{{ message.service_order_info.order_no }}</text>
</view>
</view>
</view>
<!-- 语音消息 -->
<view v-else-if="message.type === 6 && message.voice_url" class="voice-message">
<view class="voice-length">{{ message.voice_url }}</view>
</view>
<!-- 普通消息 -->
<text v-else class="message-text">{{ message.content }}</text>
<!-- 商品消息 -->
<!-- <view class="orderListContent" v-else v-for="(item, index) in orderList.shop" :key="index">
<view class="orderListContentTitle">
<view class="orderListContentTitleLeft">
<uv-avatar :src="item.image" shape="square" size="24"></uv-avatar>
<text class="orderListContentTitleLeftName">{{ item.shopName }}</text>
<uv-icon name="arrow-right" color="#111111" size="16"></uv-icon>
</view>
<text class="orderListContentTitleRight">{{ item.status }}</text>
</view>
<view class="orderListContentCenter">
<view class="orderListContentcenterLeft">
<uv-image :src="item.image" width="80" height="80" radius="10"
class="orderListContentcenterLeftImage" mode="aspectFill" />
<view class="product-details">
<view class="productTitle">
<text class="productTitleName">{{ item.title }}</text>
</view>
<view class="eproductSpecifications">
<text class="eproductSpecificationsName">{{ item.spec }}</text>
</view>
</view>
</view>
<view class="orderListContentCenterRight">
<view class="orderListContentCenterPrice">
<text class="orderListContentCenterPriceValue">¥{{ item.price }}</text>
</view>
<text class="eproductSpecificationsNumberValue">×{{ item.number }}</text>
</view>
</view>
</view> -->
<!-- 日常家具 -->
<!-- <view class="dailyFurnitureCleaning" v-else v-for="(item,index) in orderList.dailyFurniture">
<view class="dailyFurniture">
<view class="dailyFurnitureTitle">
<Text class="dailyFurnitureTitleName">{{item.cleaningName}}</Text>
<Text class="dailyFurnitureTitleValue">{{item.status}}</Text>
</view>
<view class="dailyFurnitureContent">
<view class="dailyFurnitureContentList" :style="idx===0?'margin-top:0rpx':''" v-for="(itm,idx) in item.content">
<Text class="dailyFurnitureContentName">{{itm.name}}</Text>
<Text class="dailyFurnitureContentTime">{{itm.value}}</Text>
<uv-image
src="https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1204/4f518202512041129434253.png"
width="20" height="20" mode="aspectFill" v-if="itm.reward"/>
</view>
</view>
</view>
</view> -->
<!-- 平台外卖 -->
<!-- <view class="platformTakeaway" v-else v-for="(item,index) in orderList.takeout">
<view class="takeout">
<view class="takeoutTitle">
<Text class="takeoutTitleName" style="color: #2D68EC;background-color: #DBEAFE;">{{item.takeoutName}}</Text>
<Text class="takeoutTitleValue">{{item.status}}</Text>
</view>
<view class="takeoutContent" v-for="(itm,idx) in item.content">
<view class="takeoutList">
<uv-image
:src="itm.img"
width="28" height="28" mode="aspectFill" />
<view class="takeoutListDetails">
<view>
<Text class="takeoutListDetailsName">{{itm.name}}</Text>
</view>
<view>
<Text class="takeoutListDetailsValue">姓名 {{itm.value}}</Text>
</view>
</view>
</view>
</view>
</view>
</view> -->
<!-- 宠物服务 -->
<!-- <view class="platformTakeaway" v-else v-for="(item,index) in orderList.petServices">
<view class="takeout">
<view class="takeoutTitle">
<Text class="takeoutTitleName" style="color: #3DA316; background-color: #E3FACB;">{{item.takeoutName}}</Text>
<Text class="takeoutTitleValue">{{item.status}}</Text>
</view>
<view class="takeoutContent" v-for="(itm,idx) in item.content">
<view class="takeoutList">
<uv-image
:src="itm.img"
width="28" height="28" mode="aspectFill" />
<view class="takeoutListDetails">
<view>
<Text class="takeoutListDetailsName">{{itm.name}}</Text>
</view>
<view>
<Text class="takeoutListDetailsValue">姓名 {{itm.value}}</Text>
</view>
</view>
</view>
</view>
</view>
</view> -->
</view>
<view class="message-time">
<text>{{ formatTime(message.created_at) }}</text>
</view>
</view>
</view>
</view>
<!-- 底部占位,避免被输入区遮挡 -->
<view class="chat-bottom-spacer"></view>
</scroll-view>
<!-- 商品信息卡片 -->
<view class="product-card" v-if="productInfo">
<!-- 关闭按钮 -->
<view class="product-close" @click="productInfo = null">×</view>
<view class="product-info">
<uv-image :src="productInfo.image" width="80" height="80" mode="aspectFill"
:errorIcon="getIconPath('avatar.png')" />
<view class="product-details">
<text class="product-name">{{ productInfo.name }}</text>
<text class="product-price">¥{{ productInfo.price }}</text>
</view>
</view>
<view class="product-actions">
<button class="send-product-btn" @click="sendProductMessage">
发送商品
</button>
</view>
</view>
<!-- 服务订单信息卡片 -->
<view class="service-order-card" v-if="serviceOrderInfo">
<!-- 关闭按钮 -->
<view class="service-order-close" @click="serviceOrderInfo = null">×</view>
<view class="service-order-info">
<view class="service-order-details">
<text class="service-order-name">{{ serviceOrderInfo.product_name }}</text>
<text class="service-order-price">¥{{ serviceOrderInfo.total_price }}</text>
<text class="service-order-time">服务时间:{{ serviceOrderInfo.service_time }}</text>
<text class="service-order-address">服务地址:{{ serviceOrderInfo.address_address }}</text>
<text class="service-order-no">订单号:{{ serviceOrderInfo.order_no }}</text>
</view>
</view>
<view class="service-order-actions">
<button class="send-service-order-btn" @click="sendServiceOrderMessage">
发送服务订单
</button>
</view>
</view>
<!-- 底部输入区域 -->
<view class="input-area">
<view class="input-container">
<uv-image :src="switchVoiceTextStatus" width="36" height="36" mode="aspectFill"
@click="switchVoiceText()" />
<view class='input-box' :class="{ 'input-box-focus': inputFocus }" @touchstart="startRecord"
@touchend="stopRecord" @touchcancel="stopRecord">
<input v-model="inputMessage" :disabled="switchStatus === 1?false:true"
:class="switchStatus === 1 ?'message-input':'message-input-status'"
:placeholder="switchStatus === 1 ? '请输入您想要咨询的内容...' : '按住说话'" @confirm="sendMessage"
@blur="inputFocus = false" @focus="onInputFocus" confirm-type="send" />
<uv-image v-if="switchStatus===1"
src="https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/11a04202512021005249142.png"
width="24" height="24" mode="aspectFill" @click="toggleEmojiPanel()" />
</view>
<uv-image
src="https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/7ed38202512021601429234.png"
width="36" height="36" mode="aspectFill" @click="orderShow()" />
<uv-image :src="moreFeaturesStatus" width="36" height="36" mode="aspectFill" @click="moreFeatures()" />
</view>
</view>
<!-- 原生表情面板 -->
<scroll-view scroll-y :style="{height: '518rpx'}" v-if="showEmojiPanel">
<view class="emoji-panel" style="position: relative; z-index: 9999;">
<!-- 表情列表:每个表情项加@click.stop -->
<view class="native-emoji-list">
<view class="native-emoji-item" v-for="(emoji, index) in nativeEmojiList" :key="index"
@click.stop="selectNativeEmoji(emoji)">
{{ emoji }}
</view>
</view>
<view class="send-content">
<uv-image class="emojiDetele"
src="https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1206/60b41202512060905184516.png"
width="32" height="32" radius="8rpx" mode="aspectFill" @click="clearInputMessage" />
<view class="send-btn"
:class="{ 'send-btn-active': inputMessage.trim(), 'send-btn-disabled': !inputMessage.trim() }"
@click="sendMessage">
<text class="send-text">发送</text>
</view>
</view>
</view>
</scroll-view>
<!-- 更多服务 -->
<view class="more-serve" v-if="moreServe">
<view class="more-serve-list" key="index" v-for="(item,index) in moreFunction"
@click="index===0?selectFromAlbum():(index===1?takeVideo():(index === 2?orderShow():''))">
<uv-image :src="item.img" width="64" height="64" mode="aspectFill" class="more-serve-list-img" />
<Text class="more-serve-list-text">{{item.name}}</Text>
</view>
</view>
<!-- 订单列表 -->
<view class="orderList" v-if="showMask">
<view class="orderListTitle">
<uv-icon name="close" size="20" class="iconClose" color="#CDCDCD" @click="closed()" />
<Text class="orderListTitleText">{{orderTitle}}</Text>
<view></view>
</view>
<!-- <view class="orderSelect">
<uv-tabs :list="orderSelect" :lineColor="color_red" :activeStyle="{color: color_red}"
:inactiveStyle="{color: color_black}" :current="active" @change="selectOrderShop" />
</view> -->
<view v-if="orderShopId === 0">
<view class="orderSearch">
<uv-icon name="search" size="25" color="#CDCDCD" @click="closed()" />
<input type="text" class="orderSearchInput" placeholder="搜索我的订单" />
</view>
<!-- 可滚动区域 -->
<scroll-view scroll-y class="orderListScroll" :style="{height: '600rpx'}">
<!-- 订单商品 -->
<view class="orderListContent" v-for="(item, index) in orderList.shop" :key="index">
<view class="orderListContentTitle">
<view class="orderListContentTitleLeft">
<uv-avatar :src="item.image" shape="square" size="24"></uv-avatar>
<text class="orderListContentTitleLeftName">{{ item.shopName }}</text>
<uv-icon name="arrow-right" color="#111111" size="16"></uv-icon>
</view>
<text class="orderListContentTitleRight">{{ item.status }}</text>
</view>
<view class="orderListContentCenter">
<view class="orderListContentcenterLeft">
<uv-image :src="item.image" width="80" height="80" radius="10"
class="orderListContentcenterLeftImage" mode="aspectFill" />
<view class="product-details">
<view class="productTitle">
<text class="productTitleName">{{ item.title }}</text>
</view>
<view class="eproductSpecifications">
<text class="eproductSpecificationsName">{{ item.spec }}</text>
</view>
</view>
</view>
<view class="orderListContentCenterRight">
<view class="orderListContentCenterPrice">
<text class="orderListContentCenterPriceValue">¥{{ item.price }}</text>
</view>
<text class="eproductSpecificationsNumberValue">×{{ item.number }}</text>
</view>
</view>
<view class="consult">
<text class="consultValue">咨询</text>
</view>
<view class="underline"></view>
<!-- 底部奖励标识 -->
<!-- <view class="orderRewards" v-if="item.reward">
<uv-image
src="https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1203/dcbc7202512031011377196.png"
width="24" height="24" class="orderRewardsImg" mode="aspectFill" />
<view class="orderRewardsName">
<text class="orderRewardsValue">订单奖励已发放</text>
</view>
</view> -->
</view>
<!-- 日常家具保洁 -->
<view class="dailyFurnitureCleaning" v-for="(item,index) in orderList.dailyFurniture">
<view class="dailyFurniture">
<view class="dailyFurnitureTitle">
<Text class="dailyFurnitureTitleName">{{item.cleaningName}}</Text>
<Text class="dailyFurnitureTitleValue">{{item.status}}</Text>
</view>
<view class="dailyFurnitureContent">
<view class="dailyFurnitureContentList" :style="idx===0?'margin-top:0rpx':''"
v-for="(itm,idx) in item.content">
<Text class="dailyFurnitureContentName">{{itm.name}}</Text>
<Text class="dailyFurnitureContentTime">{{itm.value}}</Text>
<uv-image
src="https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1204/4f518202512041129434253.png"
width="20" height="20" mode="aspectFill" v-if="itm.reward" />
</view>
</view>
</view>
<view class="consult">
<text class="consultValue">咨询</text>
</view>
<view class="underline"></view>
</view>
<!-- 平台外卖 -->
<view class="platformTakeaway" v-for="(item,index) in orderList.takeout">
<view class="takeout">
<view class="takeoutTitle">
<Text class="takeoutTitleName"
style="color: #2D68EC;background-color: #DBEAFE;">{{item.takeoutName}}</Text>
<Text class="takeoutTitleValue">{{item.status}}</Text>
</view>
<view class="takeoutContent" v-for="(itm,idx) in item.content">
<view class="takeoutList">
<uv-image :src="itm.img" width="28" height="28" mode="aspectFill" />
<view class="takeoutListDetails">
<view>
<Text class="takeoutListDetailsName">{{itm.name}}</Text>
</view>
<view>
<Text class="takeoutListDetailsValue">姓名 {{itm.value}}</Text>
</view>
</view>
</view>
</view>
</view>
<view class="consult">
<text class="consultValue">咨询</text>
</view>
<view class="underline"></view>
</view>
<!-- 宠物服务 -->
<view class="platformTakeaway" v-for="(item,index) in orderList.petServices">
<view class="takeout">
<view class="takeoutTitle">
<Text class="takeoutTitleName"
style="color: #3DA316; background-color: #E3FACB;">{{item.takeoutName}}</Text>
<Text class="takeoutTitleValue">{{item.status}}</Text>
</view>
<view class="takeoutContent" v-for="(itm,idx) in item.content">
<view class="takeoutList">
<uv-image :src="itm.img" width="28" height="28" mode="aspectFill" />
<view class="takeoutListDetails">
<view>
<Text class="takeoutListDetailsName">{{itm.name}}</Text>
</view>
<view>
<Text class="takeoutListDetailsValue">姓名 {{itm.value}}</Text>
</view>
</view>
</view>
</view>
</view>
<view class="consult">
<text class="consultValue">咨询</text>
</view>
<view class="underline"></view>
</view>
</scroll-view>
</view>
<scroll-view class="orderListScroll" scroll-y="true" :style="{height: '600rpx'}" v-if="orderShopId===1">
<view v-for="(item,index) in productList" :key="index" class="orderListContentCenter"
style="margin-bottom: 56rpx;">
<view class="shopListContentcenterLeft">
<uv-image :src="item.image" width="80" height="80" radius="10"
class="orderListContentcenterLeftImage" mode="aspectFill" />
<view>
<view class="productTitle">
<text class="productTitleName">{{ item.title }}</text>
</view>
</view>
</view>
<view class="orderListContentCenterRight">
<view class="orderListContentCenterPrice">
<text class="orderListContentCenterPriceValue">¥{{ item.price }}</text>
</view>
<view class="consult">
<text class="consultValue">发送</text>
</view>
</view>
</view>
</scroll-view>
<view class="notOrderProductInquiry" @click="closed()">
<Text class="notOrderProductInquiryValue">非订单/商品咨询</Text>
</view>
</view>
</view>
<view class="mask" v-if="showMask" @click="closed()"></view>
</template>
<script setup>
import {
ref,
reactive,
onMounted,
onUnmounted,
nextTick
} from 'vue'
import {
onLoad,
onShow
} from '@dcloudio/uni-app'
import {
bindCustomer,
sendMessage as apiSendMessage,
getChatLists,
getChatList,
addOfficial,
videoUplaod,
audioUplaod,
getFileTaskResult
} from '@/api/kefu.js'
import {
getUserInfo
} from '@/api/index.js'
import {
storeToRefs
} from 'pinia'
import {
useWebSocketStore
} from '@/store/MessageWebsocket.js'
import {
useUserStore
} from '@/store'
import {
uploadImage,
videoUpload
} from '@/api/index.js'
// 用户状态管理
const userStore = useUserStore()
const {
token,
userInfo: storeUserInfo
} = storeToRefs(userStore)
const wsStore = useWebSocketStore()
// 响应式数据
const userInfo = ref({})
const merInfo = ref({})
const friendInfo = ref({})
const messageList = ref([])
const inputMessage = ref('')
const inputFocus = ref(false)
const scrollTop = ref(0)
const scrollIntoViewId = ref('')
const loading = ref(false)
const page = ref(1)
const hasMore = ref(true)
const productInfoInput = ref(false)
const switchVoiceTextStatus = ref(
'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/b601a202512020937515649.png');
const moreFeaturesStatus = ref(
'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/5831a202512020940542856.png');
const switchStatus = ref(1);
const showEmojiPanel = ref(false) // 表情面板显示状态
const moreServe = ref(false);
const showMask = ref(false);
const videoUrl = ref('');
// 状态定义
const isRecording = ref(false)
const currentPlayingVoice = ref('')
// 录音相关实例(区分环境)
const recorderManager = ref(null) // 小程序录音管理器
const h5Recorder = ref(null) // H5 MediaRecorder实例
const audioStream = ref(null) // H5音频流
const innerAudioContext = ref(null) // 音频播放上下文
// 判断是否为H5环境
const isH5 = () => {
// #ifdef H5
return true
// #endif
return false
}
const nativeEmojiList = ref([
'?', '?', '?', '?', '?', '?', '?',
'?', '?', '?', '?', '?', '?', '?',
'?', '?', '?', '?', '?', '?', '?',
'?', '?', '?', '?', '?', '?', '?',
'?', '?', '?', '?', '?', '?', '?',
'?', '?', '?', '?', '?', '?', '?',
'?', '?', '?', '?', '?', '☹️', '?',
'?', '?', '?', '?', '?', '?', '?',
'?', '?', '?', '?', '?', '?', '?' // 确保都是基础emoji
])
const moreFunction = ref([{
id: 0,
name: '相册',
img: 'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/7408e202512021104333458.png'
},
{
id: 1,
name: '拍摄',
img: 'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/3331b202512021106431887.png'
},
{
id: 2,
name: '订单',
img: 'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/d03ab202512021107236046.png'
},
{
id: 3,
name: '服务评价',
img: 'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/c27ec202512021109092650.png'
},
]);
const color_red = uni.$config.color.red
const orderTitle = ref('为更快解决您的问题,请选择要咨询的内容');
const orderShopId = ref(0);
const orderSelect = ref([{
id: 0,
name: '订单',
},
{
id: 1,
name: '商品',
}
]);
const orderList = ref({
shop: [{
shopName: "依利安达家具旗舰店",
status: "完成",
image: "https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1203/42567202512030927251507.png",
title: "商品标题商品标题商品标题商品标题",
spec: "产品规格",
number: 1,
price: 19999.9,
reward: true
}],
dailyFurniture: [{
cleaningName: '日常家具保洁2小时',
status: '待服务',
content: [{
name: '服务时间',
value: '2025-12-12 12:00',
reward: false
},
{
name: '服务地点',
value: '盛世御龙湾-3号楼一单元402',
reward: false
},
{
name: '服务报价',
value: '¥19999.9',
reward: false
},
{
name: '订单号',
value: '202020448476474748484848',
reward: true
}
]
}],
takeout: [{
takeoutName: '待取外卖',
status: '已取消',
content: [{
img: 'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1204/1f1ec202512041157416797.png',
name: '详细地址',
value: '12034809810'
},
{
img: 'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1204/9a939202512041334109414.png',
name: '详细地址',
value: '12034809810'
}
]
}],
petServices: [{
takeoutName: '宠物待遛',
status: '待服务',
content: [{
img: 'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1204/4731a202512041345189036.png',
name: '详细地址',
value: '12034809810'
}]
}]
})
const productList = ref([{
image: "https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1203/42567202512030927251507.png",
title: "商品标题商品标题商品标题商品标题商品标题商品标题",
price: 19999.9
},
{
image: "https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1203/42567202512030927251507.png",
title: "第二个商品标题商品标题",
price: 299.9
},
{
image: "https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1203/42567202512030927251507.png",
title: "第三个商品标题",
price: 88
},
{
image: "https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1203/42567202512030927251507.png",
title: "第三个商品标题",
price: 88
},
{
image: "https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1203/42567202512030927251507.png",
title: "第三个商品标题",
price: 88
}
])
// WebSocket相关
const chatId = ref('');
let clientId = ''
let merchantId = 0
let goodsId = 0
// 页面参数
const pageParams = ref({})
const productInfo = ref(null)
const serviceOrderInfo = ref(null)
const getCustomerInformation = async () => {
const res = await addOfficial();
console.log(res.result.friend_info);
if (res.code === 200) {
friendInfo.value = res.result.friend_info;
userInfo.value = res.result.user_info;
chatId.value = res.result.chat_id;
}
}
// 页面加载
onLoad((options) => {
getCustomerInformation();
})
// 页面显示
onShow(async () => {
await getCustomerInformation();
await initPage()
})
// WebSocket回调函数引用
let wsCallback = null
// 添加WebSocket消息监听器
const addWebSocketListener = () => {
wsCallback = async (data) => {
console.log('wsCallback', data)
// 检查是否是发送者自己的消息(避免重复显示)
if (data.from === userInfo.value.id) {
// 检查是否已经存在相同的消息(避免重复)
const messageExists = messageList.value.some(msg =>
msg.content === data.content &&
msg.created_at === data.time &&
msg.uid === data.from
)
if (messageExists) {
return // 消息已存在,忽略
}
}
// 检查是否是当前聊天的消息
if (data.chat_id && data.chat_id !== chatId.value) {
// 不是当前聊天的消息,忽略
return
}
// 对于自己的消息,已经在上面检查过了,这里只检查非自己的消息
if (data.from !== userInfo.value.id) {
const messageExists = messageList.value.some(msg =>
msg.content === data.content &&
msg.created_at === data.time &&
msg.uid === data.from
)
if (messageExists) {
return // 消息已存在,忽略
}
}
// 解析消息内容
let messageContent = data.content
let messageType = data.type || 1
let productInfo = null
let serviceOrderInfo = null
// 添加新消息到列表
messageList.value.push({
uid: data.from,
content: messageContent,
created_at: data.time || Math.floor(Date.now() / 1000), // 使用秒级时间戳
type: messageType,
status: 0,
product_info: productInfo,
service_order_info: serviceOrderInfo
})
// 使用锚点滚动到最新消息,避免被输入框遮挡
nextTick(() => {
scrollIntoViewId.value = '' + (messageList.value.length - 1)
})
}
wsStore.subscribe('chat', wsCallback)
}
// 移除WebSocket消息监听器
const removeWebSocketListener = () => {
if (wsCallback) {
wsStore.unsubscribe('chat', wsCallback)
wsCallback = null
}
}
// 初始化页面
const initPage = async () => {
try {
console.log('chatId:', chatId.value); // 检查 chatId
// 检查chat_id参数
if (!chatId.value) {
uni.showToast({
title: '聊天ID无效',
icon: 'none'
});
return;
}
console.log('storeUserInfo:', storeUserInfo.value); // 打印 storeUserInfo
// 使用store中的用户信息
userInfo.value = storeUserInfo.value || {};
// 如果store中没有用户信息,尝试获取
if (!userInfo.value.id) {
const userRes = await getUserInfo();
userInfo.value = userRes.data || {};
}
// 确保WebSocket已初始化
console.log('WebSocket initialized:', wsStore.ws); // 检查 WebSocket
if (!wsStore.ws) {
wsStore.init();
}
// 添加WebSocket消息监听器
addWebSocketListener();
// 加载聊天记录
await loadMessages();
} catch (error) {
console.error('Initialization failed:', error); // 打印异常
uni.showToast({
title: '初始化失败',
icon: 'none'
});
}
}
// 监听 WebSocket 消息
const handleChatMessage = (payload) => {
console.log('--handleChatMessage--payload--', payload)
}
// 点击切换语音还是文字按钮
const switchVoiceText = () => {
if (switchVoiceTextStatus.value ===
'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/b601a202512020937515649.png') {
switchVoiceTextStatus.value =
'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/ea63f202512020949292692.png';
switchStatus.value = 2;
} else {
switchVoiceTextStatus.value =
'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/b601a202512020937515649.png';
switchStatus.value = 1;
}
showEmojiPanel.value = false;
moreServe.value = false;
moreFeaturesStatus.value =
'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/5831a202512020940542856.png';
}
const toggleEmojiPanel = () => {
showEmojiPanel.value = !showEmojiPanel.value;
inputFocus.value = false;
moreServe.value = false;
moreFeaturesStatus.value =
'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/5831a202512020940542856.png';
}
const selectNativeEmoji = (emoji) => {
inputMessage.value += emoji;
}
const moreFeatures = () => {
if (moreFeaturesStatus.value ===
'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/5831a202512020940542856.png') {
moreFeaturesStatus.value =
'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/00b00202512021539127978.png';
} else {
moreFeaturesStatus.value =
'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/5831a202512020940542856.png';
}
moreServe.value = !moreServe.value;
showEmojiPanel.value = false;
}
const orderShow = () => {
showMask.value = true;
showEmojiPanel.value = false;
moreServe.value = false;
moreFeaturesStatus.value =
'https://linshitong.oss-cn-shenzhen.aliyuncs.com/dev/image/2025/1202/5831a202512020940542856.png';
}
const closed = () => {
showMask.value = false;
}
const selectOrderShop = (e) => {
console.log(e.index);
orderShopId.value = e.index;
}
// 从相册选择图片
const selectFromAlbum = () => {
uni.chooseImage({
count: 1,
sourceType: ['album'],
sizeType: ['original', 'compressed'],
success: async (res) => {
console.log('图片上传成功', res);
const tempFile = res.tempFiles[0];
await uploadImages(tempFile);
},
fail: (err) => {
console.error('选择图片失败:', err)
uni.showToast({
title: '选择图片失败',
icon: 'none'
})
}
})
};
// 上传图片到服务器
const uploadImages = async (tempFile) => {
console.log(tempFile);
uni.showLoading({
title: '上传中...'
});
try {
// 调试上传请求
console.log('开始上传', tempFile);
const result = await uploadImage({
name: 'file',
filePath: tempFile.path
});
console.log('上传结果', result); // 查看上传结果
if (result.code === 0) {
await sendMediaMessage({
type: 4, // 图片消息类型
content: JSON.stringify({
image_url: result.data,
})
})
}
uni.showToast({
title: '上传成功',
icon: 'success'
});
} catch (err) {
console.error('上传失败', err);
uni.showToast({
title: '上传失败',
icon: 'none'
});
} finally {
// 确保始终隐藏加载状态
uni.hideLoading();
}
};
// 预览图片
const previewImage = (imageUrl) => {
uni.previewImage({
current: imageUrl,
urls: [imageUrl]
})
}
// 拍摄视频
const takeVideo = () => {
uni.chooseVideo({
sourceType: ['camera'],
maxDuration: 60, // 最大拍摄时长60秒
camera: 'back',
success: async (res) => {
console.log('拍摄视频成功', res);
await uploadVideo(res.tempFilePath, res.tempThumbPath)
},
fail: () => {
uni.showToast({
title: '拍摄视频失败',
icon: 'none'
})
}
})
}
// 上传视频到服务器
const uploadVideo = async (tempVideoPath, tempCoverPath) => {
console.log(tempVideoPath);
uni.showLoading({
title: '视频上传中...'
})
try {
// 获取视频信息(支持 H5 / APP / 小程序)
const videoInfo = (videoPath) => {
console.log(videoPath);
return new Promise((resolve, reject) => {
// APP / 小程序原生支持
//#ifdef APP || MP-WEIXIN
uni.getVideoInfo({
src: videoPath,
success: resolve,
fail: (err) => reject(new Error(`uni.getVideoInfo 失败:${err.errMsg}`))
})
//#endif
// H5:使用 <video> 对象获取
//#ifdef H5
try {
const video = document.createElement('video')
video.src = videoPath
video.crossOrigin = 'anonymous' // 如果跨域,必须加
video.onloadedmetadata = () => {
resolve({
width: video.videoWidth || 280,
height: video.videoHeight || 350,
duration: video.duration || 0,
orientation: video.videoWidth > video.videoHeight ?
'landscape' : 'portrait'
})
}
video.onerror = () => {
reject(new Error('H5 获取视频信息失败'))
}
} catch (e) {
reject(new Error('H5 获取视频信息异常:' + e.message))
}
//#endif
})
}
// 2. 计算显示尺寸
let width = videoInfo.width > 0 ? videoInfo.width : 280
let height = videoInfo.height > 0 ? videoInfo.height : 350
const scale = Math.min(280 / width, 350 / height, 1)
width = Math.floor(width * scale)
height = Math.floor(height * scale)
const res = await videoUpload({
name: 'file',
filePath: tempVideoPath
})
// 5. 接口响应处理
console.log('视频上传接口响应:', res)
if (res.code !== 0) {
uni.showToast({
title: '上传文件过大',
icon: 'none'
});
return
}
if (res.code === 0 && res?.data) {
await sendMediaMessage({
type: 5,
content: JSON.stringify({
video_url: res.data.url || res.data,
cover_url: res.data.cover_url || tempCoverPath,
width,
height,
duration: Math.floor(videoInfo.duration)
}),
video_url: res.data.url || res.data,
cover_url: res.data.cover_url || tempCoverPath,
video_width: width,
video_height: height
})
uni.showToast({
title: '视频上传成功',
icon: 'success'
})
} else {
throw new Error(res.msg || '视频上传失败:未返回视频URL')
}
} catch (error) {
console.error('视频上传完整错误:', error)
uni.showToast({
title: error.message || '视频上传失败',
icon: 'none',
duration: 2000
})
} finally {
uni.hideLoading()
}
}
// 播放视频
const playVideo = (url) => {
videoUrl.value = url;
}
// ==================== 小程序录音初始化 ====================
const initMpRecorder = () => {
recorderManager.value = uni.getRecorderManager()
// 小程序录音开始回调
recorderManager.value.onStart(() => {
isRecording.value = true
uni.showToast({
title: '开始录音...',
icon: 'none',
duration: 1000
})
})
// 小程序录音停止回调
recorderManager.value.onStop(async (res) => {
isRecording.value = false
if (res.duration < 1000) {
uni.showToast({
title: '录音时间太短',
icon: 'none'
})
return
}
await uploadVoice(res.tempFilePath, res.duration)
})
// 小程序录音错误回调
recorderManager.value.onError((err) => {
isRecording.value = false
console.error('小程序录音失败:', err)
uni.showToast({
title: '录音失败',
icon: 'none'
})
})
}
// ==================== H5录音初始化 ====================
const initH5Recorder = async () => {
try {
// 检测浏览器支持性
if (!window.MediaRecorder || !navigator.mediaDevices?.getUserMedia) {
uni.showToast({
title: '当前浏览器不支持录音功能',
icon: 'none'
})
return false
}
alert('支持录音')
// 浏览器不支持强制设置sampleRate/bitrate,仅保留channelCount
audioStream.value = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1, // 仅保留单声道(部分浏览器支持)
echoCancellation: true, // 降噪(提升体验)
noiseSuppression: true
}
})
// ========== 修复2:适配不同浏览器的编码格式 ==========
const isWechat = /MicroMessenger/i.test(navigator.userAgent);
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
let mimeType = '';
// 优先级:微信iOS → 微信安卓 → Chrome → 兜底
if (isWechat && isIOS) {
// iOS微信:优先mp4(mpeg4aac)
mimeType = MediaRecorder.isTypeSupported('audio/mp4') ?
'audio/mp4' :
MediaRecorder.isTypeSupported('audio/wav') ? 'audio/wav' : '';
} else if (isWechat) {
// 安卓微信:优先webm
mimeType = MediaRecorder.isTypeSupported('audio/webm') ?
'audio/webm' :
MediaRecorder.isTypeSupported('audio/wav') ? 'audio/wav' : '';
} else {
// Chrome:优先webm
mimeType = MediaRecorder.isTypeSupported('audio/webm') ?
'audio/webm' :
MediaRecorder.isTypeSupported('audio/mp3') ? 'audio/mp3' : '';
}
// 无可用编码格式
if (!mimeType) {
uni.showToast({
title: '当前浏览器不支持音频编码',
icon: 'none'
})
return false;
}
// 创建MediaRecorder实例(使用适配后的编码)
h5Recorder.value = new MediaRecorder(audioStream.value, {
mimeType
});
const audioChunks = []
// H5录音数据收集
h5Recorder.value.ondataavailable = (e) => {
if (e.data.size > 0) {
audioChunks.push(e.data)
}
}
// H5录音停止回调
h5Recorder.value.onstop = async () => {
isRecording.value = false
const duration = h5Recorder.value?.startTime ?
Date.now() - h5Recorder.value.startTime :
0
if (duration < 1000) {
uni.showToast({
title: '录音时间太短',
icon: 'none'
})
audioChunks.length = 0
return
}
// ========== 修复3:根据实际编码设置文件名后缀 ==========
const ext = mimeType.split('/')[1]; // webm/mp4/wav/mp3
const audioBlob = new Blob(audioChunks, {
type: mimeType
})
const tempFilePath = URL.createObjectURL(audioBlob)
await uploadVoice(tempFilePath, duration, audioBlob, ext) // 传递后缀
audioChunks.length = 0
}
h5Recorder.value.onerror = (err) => {
isRecording.value = false
console.error('H5录音失败:', err)
uni.showToast({
title: '录音失败',
icon: 'none'
})
}
// ========== 修复4:微信浏览器权限引导 ==========
if (isWechat) {
uni.showToast({
title: '请允许麦克风权限后录音',
icon: 'none',
duration: 2000
})
}
return true
} catch (err) {
console.error('H5录音初始化失败:', err)
// ========== 修复5:区分权限失败和其他错误 ==========
if (err.name === 'NotAllowedError') {
// 权限被拒绝
if (/MicroMessenger/i.test(navigator.userAgent)) {
uni.showToast({
title: '请点击右上角···→允许使用麦克风',
icon: 'none',
duration: 3000
})
} else {
uni.showToast({
title: '麦克风权限被拒绝,请手动授权',
icon: 'none',
duration: 3000
})
}
} else if (err.name === 'NotFoundError') {
uni.showToast({
title: '未检测到麦克风设备',
icon: 'none'
})
} else {
uni.showToast({
title: '获取麦克风权限失败',
icon: 'none'
})
}
return false
}
}
// ==================== 统一录音初始化入口 ====================
const initRecorderManager = async () => {
if (isH5()) {
// H5环境初始化录音
await initH5Recorder()
} else {
// 小程序环境初始化录音
initMpRecorder()
}
}
// ==================== 音频播放初始化(兼容H5) ====================
const initInnerAudioContext = () => {
if (isH5()) {
// H5使用HTML5 Audio
innerAudioContext.value = new Audio()
innerAudioContext.value.onplay = () => {
currentPlayingVoice.value = innerAudioContext.value.src
}
innerAudioContext.value.onended = () => {
currentPlayingVoice.value = ''
}
innerAudioContext.value.onerror = () => {
currentPlayingVoice.value = ''
uni.showToast({
title: '播放失败',
icon: 'none'
})
}
} else {
// 小程序使用内置音频上下文
innerAudioContext.value = uni.createInnerAudioContext()
innerAudioContext.value.onPlay(() => {
currentPlayingVoice.value = innerAudioContext.value.src
})
innerAudioContext.value.onEnded(() => {
currentPlayingVoice.value = ''
})
innerAudioContext.value.onError(() => {
currentPlayingVoice.value = ''
uni.showToast({
title: '播放失败',
icon: 'none'
})
})
}
}
// ==================== 开始录音(统一接口) ====================
const startRecord = async () => {
// 非语音模式不录音
if (switchStatus.value === 1) return
// H5环境
if (isH5()) {
if (!h5Recorder.value) {
const initSuccess = await initH5Recorder()
if (!initSuccess) return
}
// 开始H5录音
h5Recorder.value.start()
h5Recorder.value.startTime = Date.now() // 记录开始时间(计算时长)
isRecording.value = true
uni.showToast({
title: '开始录音...',
icon: 'none',
duration: 1000
})
} else {
// 小程序环境
recorderManager.value.start({
sampleRate: 16000,
numberOfChannels: 1,
format: 'mp3',
bitRate: 48000
})
}
}
// ==================== 停止录音(统一接口) ====================
const stopRecord = () => {
if (!isRecording.value) return
if (isH5()) {
// H5停止录音
if (h5Recorder.value?.state === 'recording') {
h5Recorder.value.stop()
}
// 释放音频流
if (audioStream.value) {
audioStream.value.getTracks().forEach(track => track.stop())
}
} else {
// 小程序停止录音
recorderManager.value.stop()
}
}
// ==================== 上传语音(兼容H5文件格式) ====================
const uploadVoice = async (tempFilePath, duration, audioBlob = null) => {
console.log(tempFilePath);
uni.showLoading({
title: '语音上传中...'
})
try {
const msg = await videoUpload({
name: 'file',
filePath: tempFilePath
})
console.log('msg',msg);
const result = await audioUplaod({
file_url: msg.data.url
})
console.log("上传结果:", result)
if (result.code !== 200) {
throw new Error('上传失败:audioUpload 返回错误')
}
const res = await getFileTaskResult({
task_id: result.result.task_id
})
console.log("任务结果:", res)
if (res.code !== 200) {
throw new Error('上传失败:getFileTaskResult 返回错误')
}
// 上传成功,发送语音
await sendMediaMessage({
type: 6,
content: JSON.stringify({
voice_url: res.result.full_text,
duration: Math.floor(duration / 1000)
}),
voice_url: res.result.voice_url,
voice_duration: Math.floor(duration / 1000)
})
} catch (error) {
console.error('语音上传失败:', error)
uni.showToast({
title: '语音上传失败',
icon: 'none'
})
} finally {
uni.hideLoading()
if (isH5() && tempFilePath.startsWith('blob:')) {
URL.revokeObjectURL(tempFilePath)
}
}
}
// ==================== 播放语音(兼容H5) ====================
const playVoice = (voiceUrl) => {
if (currentPlayingVoice.value === voiceUrl) {
// 暂停当前播放
if (isH5()) {
innerAudioContext.value.pause()
innerAudioContext.value.currentTime = 0
} else {
innerAudioContext.value.stop()
}
currentPlayingVoice.value = ''
return
}
// 播放新语音
if (isH5()) {
innerAudioContext.value.src = voiceUrl
innerAudioContext.value.play().catch(err => {
console.error('H5播放失败:', err)
uni.showToast({
title: '播放失败',
icon: 'none'
})
})
} else {
innerAudioContext.value.stop()
innerAudioContext.value.src = voiceUrl
innerAudioContext.value.play()
}
}
// ==================== 生命周期清理 ====================
const cleanupRecorder = () => {
// 停止录音
if (isRecording.value) {
stopRecord()
}
// 释放H5资源
if (isH5()) {
if (audioStream.value) {
audioStream.value.getTracks().forEach(track => track.stop())
}
h5Recorder.value = null
audioStream.value = null
// 停止播放
if (innerAudioContext.value) {
innerAudioContext.value.pause()
innerAudioContext.value.src = ''
}
} else {
// 小程序清理
if (innerAudioContext.value) {
innerAudioContext.value.destroy()
}
}
}
// 发送媒体消息(图片/视频/语音)
const sendMediaMessage = async (messageData) => {
console.log('131454', JSON.parse(messageData.content));
try {
// 调用发送消息接口
await apiSendMessage({
friend_id: friendInfo.value.id,
content: messageData.content,
type: messageData.type,
chat_id: chatId.value
})
// 添加到本地列表
messageList.value.push({
uid: userInfo.value.id,
content: messageData.type === 4 ? '[图片]' : messageData.type === 5 ? '[视频]' :(messageData.type === 6?'[语音]':'') ,
created_at: Math.floor(Date.now() / 1000),
type: messageData.type,
status: 0,
image_url: JSON.parse(messageData.content).image_url || '',
image_width: JSON.parse(messageData.content).image_width || 0,
image_height: JSON.parse(messageData.content).image_height || 0,
video_url: messageData.video_url || '',
cover_url: messageData.cover_url || '',
// video_width: messageData.video_width || 0,
// video_height: messageData.video_height || 0,
voice_url: JSON.parse(messageData.content).voice_url || '',
voice_duration: messageData.content.voice_duration || 0
})
// 滚动到最新消息
nextTick(() => {
scrollIntoViewId.value = '' + (messageList.value.length - 1)
setTimeout(() => scrollToBottom(), 200)
})
} catch (error) {
console.error('发送媒体消息失败:', error)
uni.showToast({
title: '发送失败',
icon: 'none'
})
}
}
// 加载聊天记录
const loadMessages = async (previousScrollHeight = 0, previousScrollTop = 0) => {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const res = await getChatLists({
chat_id: chatId.value,
page: page.value,
limit: 50
})
if (res.code === 200) {
const messages = res.result || [] // 处理消息,解析商品信息和服务订单信息
const processedMessages = messages.map(message => {
// 处理图片消息
if (message.type === 4) {
try {
const parsed = JSON.parse(message.content)
return {
...message,
image_url: parsed.image_url,
image_width: parsed.width || 400,
image_height: parsed.height || 400
}
} catch (e) {
return message
}
}
// 处理视频消息
else if (message.type === 5) {
try {
const parsed = JSON.parse(message.content)
return {
...message,
video_url: parsed.video_url,
cover_url: parsed.cover_url || '/static/images/bgUrl.png',
video_width: 400,
video_height: 400
}
} catch (e) {
return message
}
}
// 处理语音消息
else if (message.type === 6) {
try {
const parsed = JSON.parse(message.content)
return {
...message,
voice_url: parsed.voice_url,
voice_duration: parsed.duration || 0
}
} catch (e) {
return message
}
}
if (message.type === 2) {
// 商品消息,解析content中的JSON
try {
const productData = JSON.parse(message.content)
return {
...message,
product_info: {
id: productData.product_id,
image: productData.product_image,
price: productData.product_price,
name: productData.product_name
}
}
} catch (error) {
console.error('解析商品消息失败:', error)
return message
}
} else if (message.type === 3) {
// 服务订单消息,解析content中的JSON
try {
const serviceOrderData = JSON.parse(message.content)
return {
...message,
service_order_info: {
order_id: serviceOrderData.order_id,
order_no: serviceOrderData.order_no,
product_name: serviceOrderData.product_name,
service_time: serviceOrderData.service_time,
address_address: serviceOrderData.address_address,
total_price: serviceOrderData.total_price,
status: serviceOrderData.status
}
}
} catch (error) {
console.error('解析服务订单消息失败:', error)
return message
}
}
return message
})
if (page.value === 1) {
// 第一页:显示最新消息,直接替换
messageList.value = processedMessages
nextTick(() => {
// 立即尝试滚动
scrollToBottom()
// 延迟滚动确保渲染完成
setTimeout(() => {
scrollToBottom()
}, 100)
// 再次延迟确保滚动到底部
setTimeout(() => {
scrollToBottom()
}, 300)
})
} else {
// 后续页:静默加载历史消息,添加到前面
messageList.value = [...processedMessages, ...messageList.value]
if (previousScrollHeight > 0) {
nextTick(() => {
const query = uni.createSelectorQuery()
query.select('.chat-content').scrollOffset()
query.exec((res) => {
if (res[0]) {
const newScrollHeight = res[0].scrollHeight
console.log(newScrollHeight);
const newScrollTop = newScrollHeight - previousScrollHeight +
previousScrollTop
scrollTop.value = newScrollTop
}
})
})
}
}
// 检查是否还有更多数据
hasMore.value = messages.length === 50
}
} catch (error) {
// 加载失败,忽略
} finally {
loading.value = false
}
}
// 加载更多消息(向上滑动加载历史消息)
const loadMoreMessages = () => {
if (hasMore.value && !loading.value) {
// 同步记录当前滚动高度和位置
const query = uni.createSelectorQuery()
query.select('.chat-content').scrollOffset()
query.exec((res) => {
if (res[0]) {
const previousScrollHeight = res[0].scrollHeight
const previousScrollTop = res[0].scrollTop
page.value++
loadMessages(previousScrollHeight, previousScrollTop)
}
})
}
}
// 滚动事件处理
const onScroll = (e) => {
// 静默处理,不做任何操作
}
// 发送商品消息
const sendProductMessage = async () => {
if (!productInfo.value) return
try {
// 发送商品消息到服务器
const res = await apiSendMessage({
friend_id: friendInfo.value.id,
content: {
product_id: productInfo.value.id,
product_image: productInfo.value.image,
product_price: productInfo.value.price,
product_name: productInfo.value.name
},
type: 2, // 传递消息类型给后端
chat_id: chatId.value
})
console.log(res);
// 添加到本地列表(发送者自己的消息)
messageList.value.push({
uid: userInfo.value.id,
content: `[商品] ${productInfo.value.name}`,
created_at: Date.now(),
type: 2, // 商品消息类型
status: 0,
product_info: productInfo.value
})
// 立即隐藏商品卡片
productInfo.value = null
// 立即滚动到新消息
nextTick(() => {
setTimeout(() => {
scrollIntoViewId.value = '' + (messageList.value.length - 1)
// 备用滚动方案
setTimeout(() => {
scrollTop.value = 999999
}, 50)
}, 50)
})
} catch (error) {
uni.showToast({
title: '发送失败',
icon: 'none'
})
}
}
// 发送消息
const sendMessage = async () => {
const message = inputMessage.value.trim();
console.log(message);
if (!message) {
uni.showToast({
title: '请输入消息内容',
icon: 'none',
duration: 1500
})
return
}
inputMessage.value = '';
console.log(friendInfo.value.id);
console.log(message);
console.log(chatId.value);
try {
// 发送到服务器
await apiSendMessage({
friend_id: friendInfo.value.id,
content: message, // 直接发送消息内容
type: 1, // 传递消息类型给后端
chat_id: chatId.value
})
// 添加到本地列表(发送者自己的消息)
messageList.value.push({
uid: userInfo.value.id,
content: message,
created_at: Date.now(),
type: 1,
status: 0
})
// 立即滚动到新消息
nextTick(() => {
setTimeout(() => {
scrollIntoViewId.value = '' + (messageList.value.length - 1)
// 备用滚动方案
setTimeout(() => {
scrollTop.value = 999999
}, 50)
}, 50)
})
} catch (error) {
uni.showToast({
title: '发送失败',
icon: 'none'
})
}
}
const clearInputMessage = () => {
if (!inputMessage.value) return; // 空值直接返回
// 关键:按 Unicode 码点分割,避免拆分 Emoji
const codePoints = Array.from(inputMessage.value);
// 删除最后一个码点(完整删除一个Emoji/文字)
codePoints.pop();
// 重新拼接字符串
inputMessage.value = codePoints.join('');
}
// 发送服务订单信息
const sendServiceOrderMessage = async () => {
if (!serviceOrderInfo.value) return
try {
// 发送服务订单信息到服务器
await apiSendMessage({
friend_id: friendInfo.value.id,
content: JSON.stringify(serviceOrderInfo.value),
type: 3, // 服务订单消息类型
chat_id: chatId.value
})
// 添加到本地列表(发送者自己的消息)
messageList.value.push({
uid: userInfo.value.id,
content: `[服务订单] ${serviceOrderInfo.value.product_name}`,
created_at: Date.now(),
type: 3, // 服务订单消息类型
status: 0,
service_order_info: serviceOrderInfo.value
})
// 立即隐藏服务订单卡片
serviceOrderInfo.value = null
// 立即滚动到新消息
nextTick(() => {
setTimeout(() => {
scrollIntoViewId.value = '' + (messageList.value.length - 1)
// 备用滚动方案
setTimeout(() => {
scrollTop.value = 999999
}, 50)
}, 50)
})
} catch (error) {
uni.showToast({
title: '发送失败',
icon: 'none'
})
}
}
const scrollToBottom = () => {
// 使用nextTick确保DOM更新完成
nextTick(() => {
// 方案1:使用scroll-into-view滚动到最后一个消息
if (messageList.value.length > 0) {
scrollIntoViewId.value = '' + (messageList.value.length - 1)
}
// 方案2:使用scrollTop作为备用方案
setTimeout(() => {
scrollTop.value = 999999
}, 10)
// 方案3:再次确保滚动到底部
setTimeout(() => {
scrollTop.value = 999999
}, 50)
// 方案
1 个回复
爱豆豆 - 办法总比困难多
你好 运行的那个端?可以发一个能直接运行的demo吗?