chatPage.vue 49 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619
  1. <template>
  2. <view class="chat-container">
  3. <!-- 连接状态指示器 -->
  4. <view class="connection-status" v-if="!isConnected && !isManualDisconnect">
  5. <text class="status-text">连接已断开,请退出重新进入</text>
  6. </view>
  7. <!-- 聊天消息列表 -->
  8. <scroll-view
  9. class="message-list"
  10. :scroll-top="scrollTop"
  11. scroll-y
  12. :scroll-into-view="scrollIntoView"
  13. :scroll-with-animation="scrollWithAnimation"
  14. @scrolltoupper="loadMoreMessages"
  15. >
  16. <!-- 加载更多 -->
  17. <view class="load-more" v-if="showLoadMore" @click="loadMoreMessages">
  18. <text class="load-more-text">加载更多</text>
  19. </view>
  20. <view class="message-item" v-for="(message, index) in messageList" :key="index" :id="'message-' + message.id">
  21. <!-- 左侧消息(客户) -->
  22. <view class="message-left" v-if="!message.isMine">
  23. <view class="avatar-wrapper">
  24. <image class="avatar" :src="formatAvatarUrl(message.avatar)" mode="aspectFill" />
  25. </view>
  26. <view v-if="message.messageType == 'text' || message.messageType != 'goods'" class="message-content-wrapper">
  27. <view class="message-bubble left-bubble">
  28. <text class="message-text">{{ message.message }}</text>
  29. </view>
  30. <view class="message-time">{{ message.time }}</view>
  31. </view>
  32. <view v-if="message.messageType == 'goods'" class="goods-content-wrapper">
  33. <view class="goods-info left-bubble">
  34. <view class="goods-main" @click="toGoodsDetail(message.goodsInfo)">
  35. <image class="goods-image" :src="message.goodsInfo.goodsImg" mode="aspectFill"></image>
  36. <view class="goods-details">
  37. <view class="goods-name-wrapper">
  38. <text class="goods-name">{{ message.goodsInfo.goodsName }}</text>
  39. </view>
  40. <!-- <view class="sales-info">
  41. <text class="delivery-time">48小时发</text>
  42. </view> -->
  43. <text v-if="message.goodsInfo.goodsPriceType == 1 || message.goodsInfo.goodsPriceType == 2" class="goods-price">¥{{ parseFloat(message.goodsInfo.goodsPrice).toFixed(2) }}</text>
  44. <text v-if="message.goodsInfo.resourcePrice && message.goodsInfo.resourcePrice != parseFloat(message.goodsInfo.goodsPrice).toFixed(2)" class="goods-price--secondary">原先 ¥{{ message.goodsInfo.resourcePrice }}</text>
  45. </view>
  46. </view>
  47. <view class="goods-footer" v-if="message.goodsInfo.goodsPriceType != 1"> </view>
  48. <view v-if="message.goodsInfo.goodsPriceType !== 2 && isExpired(message.timestamp)" class="action-buttons">
  49. <form @submit.prevent="createQrotedPrice(message)">
  50. <input
  51. class="price-input"
  52. type="digit"
  53. v-model="message.quotedPrice"
  54. placeholder="输入价格"
  55. @focus="hideKeyboard"
  56. @blur="showKeyboard"
  57. />
  58. <button class="action-btn buy-btn" form-type="submit">改价</button>
  59. </form>
  60. </view>
  61. <view v-else>
  62. <text v-if="message.goodsInfo.goodsPriceType == 0">改价窗口期已过</text>
  63. </view>
  64. <!-- 改价类型显示重新改价 -->
  65. <view v-if="message.goodsInfo.goodsPriceType == 2 && isExpired(message.timestamp)" class="action-buttons">
  66. <form @submit.prevent="createQrotedPrice(message)">
  67. <!-- <text class="goods-price" v-if="message.goodsInfo.lastPrice || message.goodsInfo.goodsPrice"
  68. >¥{{ parseFloat(message.goodsInfo.lastPrice || message.goodsInfo.goodsPrice).toFixed(2) }}</text
  69. > -->
  70. <input
  71. class="price-input"
  72. type="digit"
  73. v-model="message.quotedPrice"
  74. placeholder="输入价格"
  75. @focus="hideKeyboard"
  76. @blur="showKeyboard"
  77. />
  78. <button class="action-btn buy-btn" form-type="submit">重新改价</button>
  79. </form>
  80. </view>
  81. </view>
  82. <view class="message-time">{{ message.time }}</view>
  83. </view>
  84. </view>
  85. <!-- 右侧消息(我的) -->
  86. <view class="message-right" v-if="message.isMine">
  87. <view v-if="message.messageType == 'text' || message.messageType != 'goods'" class="message-content-wrapper">
  88. <view class="message-bubble right-bubble">
  89. <text class="message-text">{{ message.message }}</text>
  90. </view>
  91. <view class="message-time align-right">{{ message.time }}</view>
  92. </view>
  93. <view v-if="message.messageType == 'goods'" class="goods-content-wrapper">
  94. <view class="goods-info right-bubble">
  95. <view class="goods-main" @click="toGoodsDetail(message.goodsInfo)">
  96. <image class="goods-image" :src="message.goodsInfo.goodsImg" mode="aspectFill" />
  97. <view class="goods-details">
  98. <view class="goods-name-wrapper">
  99. <text class="goods-name">{{ message.goodsInfo.goodsName }}</text>
  100. </view>
  101. <text v-if="message.goodsInfo.goodsPriceType == 1 || message.goodsInfo.goodsPriceType == 2" class="goods-price">¥{{ parseFloat(message.goodsInfo.goodsPrice).toFixed(2) }}</text>
  102. <view class="sales-info">
  103. <!-- <text class="delivery-time">48小时发</text> -->
  104. </view>
  105. <text v-if="message.goodsInfo.goodsPriceType == 1" class="goods-price"
  106. >¥{{ parseFloat(message.goodsInfo.goodsPrice).toFixed(2) }}</text
  107. >
  108. </view>
  109. </view>
  110. <!-- <view class="goods-footer">
  111. <text class="footer-text">破损包退·极速退款·7天无理由退货</text>
  112. <text class="arrow">></text>
  113. </view> -->
  114. <!--<view class="action-buttons">
  115. <button class="action-btn spec-btn" @click="toGoodsDetail(message.goodsInfo)">查看商品</button>
  116. <button v-if="message.goodsInfo.goodsPriceType == 1" class="action-btn buy-btn" @click="toBuy">去购买</button>
  117. //改价类型显示去购买
  118. <button v-if="message.goodsInfo.goodsPriceType == 2" class="action-btn buy-btn" @click="toBuy">去购买</button>
  119. </view> -->
  120. </view>
  121. <view class="message-time align-right">{{ message.time }}</view>
  122. </view>
  123. <view class="avatar-wrapper">
  124. <image class="avatar" :src="formatAvatarUrl(message.avatar)" mode="aspectFill" />
  125. </view>
  126. </view>
  127. </view>
  128. <!-- 空状态 -->
  129. <view class="empty-state" v-if="messageList.length === 0">
  130. <text class="empty-text">暂无聊天记录</text>
  131. </view>
  132. </scroll-view>
  133. <!-- 输入区域 -->
  134. <view v-show="msgInputShow" class="input-area" :style="{ 'padding-bottom': keyboardHeight > 0 ? keyboardHeight + 'px' : 'calc(20upx + env(safe-area-inset-bottom))' }">
  135. <view class="input-wrapper">
  136. <textarea
  137. class="message-input"
  138. v-model="inputText"
  139. placeholder="请输入消息..."
  140. :placeholder-style="placeholderStyle"
  141. @confirm="sendMessage"
  142. @focus="onInputFocus"
  143. @input="onInputChange"
  144. confirm-type="send"
  145. :adjust-position="false"
  146. :auto-height="true"
  147. :maxlength="500"
  148. :show-confirm-bar="false"
  149. :focus="inputFocus"
  150. /><!-- @blur="onInputBlur" -->
  151. <button class="send-button" :class="{ 'send-active': inputText.trim() }" @click="sendMessage" :disabled="!inputText.trim()">
  152. <!-- @touchstart.prevent="sendMessage" -->
  153. 发送
  154. </button>
  155. </view>
  156. </view>
  157. </view>
  158. </template>
  159. <script>
  160. import { getChatHistory, createQuotedPrice as createQuotedPriceApi, getQuotedPrice } from '@/api/chat';
  161. import { WSSHOST } from '@/config.js';
  162. export default {
  163. data() {
  164. return {
  165. inputText: '',
  166. scrollTop: 0,
  167. scrollIntoView: '',
  168. placeholderStyle: 'color: #ccc; font-size: 28upx;',
  169. // 当前用户信息
  170. currentUser: {
  171. userId: 0, //客户登录帐户的id(不是 customId)
  172. staffId: 0, //门店员工id
  173. customId: 0, //客户id
  174. shopId: 0, //门店id
  175. username: '', //门店名称
  176. avatar: '' //门店头像
  177. },
  178. // 聊天消息列表
  179. messageList: [],
  180. today: new Date().toLocaleDateString(),
  181. // WebSocket相关
  182. socket: null,
  183. isConnected: true,
  184. room: '',
  185. reconnectCount: 0,
  186. maxReconnectCount: 5,
  187. isManualDisconnect: false, // 添加标志位:是否主动断开连接
  188. scrollWithAnimation: false, // 控制滚动动画
  189. // 分页加载
  190. allMessages: [], // 存储所有从API获取的消息
  191. initialLoadSize: 50, // 首次加载数量
  192. loadMoreSize: 50, // 每次加载更多的数量
  193. showLoadMore: false, // 是否显示“加载更多”
  194. // 键盘相关
  195. keyboardHeight: 0, // 键盘高度
  196. isKeyboardShow: false, // 键盘是否显示
  197. inputFocus: false, // 输入框焦点
  198. isInputFocused: false, // 标记输入框是否聚焦
  199. expireTime: 24 * 60 * 60, // 商品改价有效窗口时长 -- 24小时
  200. msgInputShow: true, // 消息输入框是否显示
  201. };
  202. },
  203. onLoad(options) {
  204. const eventChannel = this.getOpenerEventChannel();
  205. eventChannel.on('acceptDataFromList', (data) => {
  206. this.pageParams = data;
  207. // console.log("---------- pageParams: ", data);
  208. this.initializePage(data);
  209. })
  210. },
  211. onReady() {
  212. // 页面加载完成后滚动到底部
  213. this.$nextTick(() => {
  214. this.scrollToBottom();
  215. });
  216. // 监听键盘弹起事件
  217. uni.onKeyboardHeightChange((res) => {
  218. this.keyboardHeight = res.height;
  219. this.isKeyboardShow = res.height > 0;
  220. // 键盘弹起时,延迟滚动到底部
  221. if (this.isKeyboardShow) {
  222. this.$nextTick(() => {
  223. setTimeout(() => {
  224. this.scrollToBottom();
  225. }, 300);
  226. });
  227. }
  228. });
  229. },
  230. onUnload() {
  231. // 页面卸载时关闭 WebSocket连接
  232. this.disconnectWebSocket();
  233. },
  234. onHide() {
  235. // 页面隐藏时关闭 WebSocket连接(根据业务需求)
  236. this.disconnectWebSocket();
  237. },
  238. onShow() {
  239. // 页面显示时重新连接(如果之前断开了)
  240. if (!this.isConnected && this.room) {
  241. this.connectWebSocket();
  242. }
  243. },
  244. methods: {
  245. init() {},
  246. initializePage(options) {
  247. if (options.chatPerson) {
  248. // 设置聊天人名称
  249. uni.setNavigationBarTitle({
  250. title: options.chatPerson
  251. });
  252. }
  253. this.currentUser.userId = this._parseAndValidateId(options.userId, 'userId 出错');
  254. this.currentUser.customId = this._parseAndValidateId(options.customId, 'customId 出错');
  255. this.currentUser.shopId = this._parseAndValidateId(options.shopId, 'shopId 出错');
  256. this.currentUser.staffId = this._parseAndValidateId(options.staffId, 'staffId 出错');
  257. if (options.name) {
  258. this.currentUser.username = options.name;
  259. }
  260. if (options.avatar) {
  261. this.currentUser.avatar = options.avatar;
  262. }
  263. // 获取房间号
  264. if (this.currentUser.customId && this.currentUser.shopId) {
  265. this.room = 'custom_id-' + this.currentUser.customId + 'AND' + 'shop_id-' + this.currentUser.shopId;
  266. } else {
  267. // 如果没有传入房间号,使用默认值或生成一个
  268. this.room = 'default_room';
  269. console.error('房间号为默认房间 ---------- this.room: ', this.room);
  270. }
  271. // console.log('---------- this.room: ', this.room);
  272. // 加载聊天记录
  273. this.loadChatHistory();
  274. // 连接WebSocket
  275. this.connectWebSocket();
  276. },
  277. /**
  278. * 解析并验证ID
  279. * @param {any} id - 需要解析和验证的ID
  280. * @param {string} errorMsg - 验证失败时的错误信息
  281. * @returns {number|null} - 验证通过则返回ID,否则返回null
  282. */
  283. _parseAndValidateId(id, errorMsg) {
  284. if (id === null || id === undefined) {
  285. return null;
  286. }
  287. const parsedId = parseInt(id);
  288. if (isNaN(parsedId) || parsedId <= 0) {
  289. console.error(errorMsg + ' ---------- id: ', id);
  290. // this.$msg(errorMsg); // 根据项目情况,决定是否需要用户提示
  291. return null;
  292. }
  293. return parsedId;
  294. },
  295. // 加载聊天历史记录
  296. loadChatHistory() {
  297. getChatHistory({
  298. roomName: this.room
  299. }).then((res) => {
  300. if (res.code == 1 && res.data) {
  301. this.allMessages = res.data.map((item, index) => {
  302. // 判断是否是我(本店)发送的消息
  303. const isMine = item && item.shopId ? parseInt(item.shopId) == this.currentUser.shopId : false;
  304. //console.log("---isMine---: ", isMine);
  305. const newItem = {
  306. ...item,
  307. id: `message-${index}` // 为每条消息添加唯一ID // 'msg_local_' + timestamp + '_' + Math.floor(Math.random() * 100000) + 1,
  308. };
  309. //如果消息类型为 'goods',就调用 parseGoodsMessage 并将结果存入新属性 goodsInfo
  310. if (newItem.messageType === 'goods') {
  311. newItem.goodsInfo = this.parseGoodsMessage(newItem.message);
  312. }
  313. newItem.isMine = isMine;
  314. // 每6条消息显示一次时间
  315. if (index % 6 === 0) {
  316. newItem.time = this.getCurrentTime(newItem.timestamp);
  317. }
  318. return newItem;
  319. });
  320. if (this.allMessages.length > this.initialLoadSize) {
  321. this.messageList = this.allMessages.slice(this.allMessages.length - this.initialLoadSize);
  322. this.showLoadMore = true;
  323. } else {
  324. this.messageList = this.allMessages;
  325. this.showLoadMore = false;
  326. }
  327. this.$nextTick(() => {
  328. this.scrollToBottom(false); // 初始加载时不使用动画
  329. });
  330. }
  331. });
  332. },
  333. // 加载更多历史记录
  334. loadMoreMessages() {
  335. if (!this.showLoadMore) {
  336. return;
  337. }
  338. const currentLoadedCount = this.messageList.length;
  339. const remainingMessagesCount = this.allMessages.length - currentLoadedCount;
  340. if (remainingMessagesCount <= 0) {
  341. this.showLoadMore = false;
  342. return;
  343. }
  344. const countToLoad = Math.min(this.loadMoreSize, remainingMessagesCount);
  345. const moreMessages = this.allMessages.slice(remainingMessagesCount - countToLoad, remainingMessagesCount);
  346. const oldTopMessageId = this.messageList.length > 0 ? this.messageList[0].id : null;
  347. this.messageList = [...moreMessages, ...this.messageList];
  348. if (this.allMessages.length - (currentLoadedCount + countToLoad) <= 0) {
  349. this.showLoadMore = false;
  350. }
  351. if (oldTopMessageId) {
  352. this.$nextTick(() => {
  353. this.scrollWithAnimation = false; // 加载更多时不使用动画
  354. this.scrollIntoView = 'message-' + oldTopMessageId;
  355. });
  356. }
  357. },
  358. // 获取主机地址
  359. getHost() {
  360. // #ifdef H5
  361. return WSSHOST;
  362. // #endif
  363. // #ifdef MP-WEIXIN || APP-PLUS
  364. // 小程序和App环境下,需要配置具体的服务器地址
  365. //return 'ws://192.168.10.182:8080';
  366. return WSSHOST; // 替换为实际的服务器地址
  367. // #endif
  368. },
  369. // 连接WebSocket
  370. connectWebSocket() {
  371. if (!this.room) {
  372. uni.showToast({
  373. title: '房间号不能为空',
  374. icon: 'none'
  375. });
  376. return;
  377. }
  378. try {
  379. const wsUrl = `${this.getHost()}/room`;
  380. // 构建子协议参数(模拟POST body传输)
  381. const protocols = this.buildWebSocketProtocols();
  382. // 创建 WebSocket连接
  383. this.socket = uni.connectSocket({
  384. url: wsUrl,
  385. protocols: protocols,
  386. success: () => {
  387. console.log('创建WebSocket连接...');
  388. },
  389. fail: (error) => {
  390. console.error('WebSocket连接创建失败:', error);
  391. this.handleConnectionError();
  392. }
  393. });
  394. // 监听 WebSocket连接打开
  395. this.socket.onOpen(() => {
  396. console.log('成功连接WebSocket');
  397. this.isConnected = true;
  398. this.reconnectCount = 0;
  399. this.isManualDisconnect = false; // 连接成功时重置标志位
  400. // 启动心跳
  401. this.startHeartbeat();
  402. });
  403. // 监听WebSocket消息
  404. this.socket.onMessage((event) => {
  405. this.handleWebSocketMessage(event.data);
  406. });
  407. // 监听 WebSocket连接关闭
  408. this.socket.onClose(() => {
  409. console.log('WebSocket连接已关闭');
  410. // 只有在非主动断开的情况下才尝试重连
  411. if (!this.isManualDisconnect && this.reconnectCount < this.maxReconnectCount) {
  412. setTimeout(() => {
  413. if (!this.isManualDisconnect) {
  414. this.reconnectWebSocket();
  415. }
  416. }, 3000);
  417. }
  418. this.isConnected = false;
  419. // 尝试重新连接超过 maxReconnectCount 次才标记为未连接状态
  420. // if (!this.isManualDisconnect && this.reconnectCount >= this.maxReconnectCount) {
  421. // this.isConnected = false;
  422. // }
  423. });
  424. // 监听WebSocket错误
  425. this.socket.onError((error) => {
  426. console.error('WebSocket连接错误:', error);
  427. this.isConnected = false;
  428. this.handleConnectionError();
  429. });
  430. } catch (error) {
  431. console.error('创建WebSocket连接异常:', error);
  432. this.handleConnectionError();
  433. }
  434. },
  435. /**
  436. * 启动心跳
  437. */
  438. startHeartbeat() {
  439. if (this.heartbeatTimer) {
  440. clearInterval(this.heartbeatTimer);
  441. }
  442. this.heartbeatTimer = setInterval(() => {
  443. if (this.isConnected) {
  444. this.socket.send({
  445. data: '*hb*',
  446. success: () => {
  447. // console.log('发送ping');
  448. },
  449. fail: (error) => {
  450. console.error('发送ping失败:', error);
  451. }
  452. });
  453. }
  454. }, 30000); // 每30秒发送一次
  455. },
  456. /**
  457. * 停止心跳
  458. */
  459. stopHeartbeat() {
  460. if (this.heartbeatTimer) {
  461. clearInterval(this.heartbeatTimer);
  462. this.heartbeatTimer = null;
  463. }
  464. },
  465. // 构建WebSocket子协议参数(模拟POST body传输)
  466. buildWebSocketProtocols() {
  467. try {
  468. const userId = this.currentUser.userId; //客户登录帐户的id(不是 customId,是customer登录帐户的userId)
  469. if (isNaN(userId) || userId <= 0) {
  470. this.$msg('当前用户的id出错');
  471. return;
  472. }
  473. const customId = this.currentUser.customId;
  474. if (isNaN(customId) || customId <= 0) {
  475. this.$msg('当前用户的customId出错');
  476. return;
  477. }
  478. const shopId = this.currentUser.shopId;
  479. if (isNaN(shopId) || shopId <= 0) {
  480. this.$msg('当前用户的shopId出错');
  481. return;
  482. }
  483. const staffId = this.currentUser.staffId;
  484. if (isNaN(staffId) || staffId <= 0) {
  485. this.$msg('当前用户的staffId出错');
  486. return;
  487. }
  488. // 创建房间参数对象
  489. const roomParams = {
  490. u_id: userId, //不是 customId,是customer的: userId
  491. c_id: this.currentUser.customId,
  492. s_id: this.currentUser.shopId,
  493. u_name:
  494. this.currentUser.username && this.currentUser.username.length > 30
  495. ? this.currentUser.username.substring(0, 30)
  496. : this.currentUser.username,
  497. platform: this.getPlatformInfo(),
  498. type: 'shop', // 客户端类型:shop:门店端, customer:客户端
  499. staff_id: staffId //门店员工id
  500. };
  501. // 分段传输(如果参数过长)
  502. const segments = this.createSegmentedProtocols(roomParams);
  503. if (segments.length == 0) {
  504. this.$msg('分段传输失败');
  505. return;
  506. }
  507. // 根据调试选项和长度选择合适的方案
  508. let protocols;
  509. if (segments.length > 0) {
  510. protocols = ['chat', ...segments];
  511. }
  512. // 验证协议字符串的合法性
  513. const isValid = this.validateProtocols(protocols);
  514. if (!isValid) {
  515. this.$msg('协议验证失败');
  516. return;
  517. }
  518. return protocols;
  519. } catch (error) {
  520. console.error('构建WebSocket protocols失败:', error);
  521. return ['chat']; // 返回基础协议作为fallback
  522. }
  523. },
  524. // 获取平台信息
  525. getPlatformInfo() {
  526. // #ifdef MP-WEIXIN
  527. return 'MP-WEIXIN';
  528. // #endif
  529. // #ifdef APP-PLUS
  530. return 'APP-PLUS';
  531. // #endif
  532. // #ifdef MP-ALIPAY
  533. return 'MP-ALIPAY';
  534. // #endif
  535. // #ifdef H5
  536. return 'H5';
  537. // #endif
  538. return 'UNKNOWN';
  539. },
  540. // Base64编码方法(跨平台兼容)
  541. base64Encode(str) {
  542. // #ifdef H5
  543. // H5环境使用浏览器原生方法
  544. try {
  545. return btoa(str);
  546. } catch (error) {
  547. console.error('H5 Base64编码失败:', error);
  548. return this.customBase64Encode(str);
  549. }
  550. // #endif
  551. // #ifndef H5
  552. // 小程序和App环境使用自定义编码
  553. return this.customBase64Encode(str);
  554. // #endif
  555. },
  556. // 自定义Base64编码实现
  557. customBase64Encode(str) {
  558. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
  559. let output = '';
  560. let chr1, chr2, chr3, enc1, enc2, enc3, enc4;
  561. let i = 0;
  562. // 转换为UTF-8字节
  563. str = this.utf8Encode(str);
  564. while (i < str.length) {
  565. chr1 = str.charCodeAt(i++);
  566. chr2 = str.charCodeAt(i++);
  567. chr3 = str.charCodeAt(i++);
  568. enc1 = chr1 >> 2;
  569. enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
  570. enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
  571. enc4 = chr3 & 63;
  572. if (isNaN(chr2)) {
  573. enc3 = enc4 = 64;
  574. } else if (isNaN(chr3)) {
  575. enc4 = 64;
  576. }
  577. output = output + chars.charAt(enc1) + chars.charAt(enc2) + chars.charAt(enc3) + chars.charAt(enc4);
  578. }
  579. return output;
  580. },
  581. // UTF-8编码
  582. utf8Encode(str) {
  583. str = str.replace(/\r\n/g, '\n');
  584. let utftext = '';
  585. for (let n = 0; n < str.length; n++) {
  586. const c = str.charCodeAt(n);
  587. if (c < 128) {
  588. utftext += String.fromCharCode(c);
  589. } else if (c > 127 && c < 2048) {
  590. utftext += String.fromCharCode((c >> 6) | 192);
  591. utftext += String.fromCharCode((c & 63) | 128);
  592. } else {
  593. utftext += String.fromCharCode((c >> 12) | 224);
  594. utftext += String.fromCharCode(((c >> 6) & 63) | 128);
  595. utftext += String.fromCharCode((c & 63) | 128);
  596. }
  597. }
  598. return utftext;
  599. },
  600. // URL安全的Base64编码
  601. urlSafeBase64Encode(str) {
  602. // 先进行标准Base64编码
  603. const base64 = this.base64Encode(str);
  604. // 增加一个保护
  605. if (typeof base64 !== 'string') {
  606. console.error('Base64编码失败,返回非字符串值:', base64);
  607. return ''; // 返回一个空字符串或其他默认值
  608. }
  609. // 转换为URL安全格式:替换 +/= 字符
  610. return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
  611. },
  612. // 创建分段协议(用于长参数)- 每次取两个键值对,将参数分成多个小块
  613. createSegmentedProtocols(roomParams) {
  614. const segments = [];
  615. const MAX_SEGMENT_LENGTH = 64; // 每段最大长度
  616. try {
  617. // 将参数分成多个小段
  618. const paramEntries = Object.entries(roomParams);
  619. const chunks = [];
  620. // 每次取两个键值对,将参数分成多个小块
  621. for (let i = 0; i < paramEntries.length; i += 2) {
  622. const chunk = Object.fromEntries(paramEntries.slice(i, i + 2));
  623. chunks.push(chunk);
  624. }
  625. chunks.forEach((chunk, index) => {
  626. const chunkStr = JSON.stringify(chunk);
  627. const encoded = this.urlSafeBase64Encode(chunkStr);
  628. if (encoded.length <= MAX_SEGMENT_LENGTH) {
  629. segments.push(`p${index}-${encoded}`);
  630. }
  631. });
  632. return segments;
  633. } catch (error) {
  634. console.error('创建分段协议失败:', error);
  635. return [];
  636. }
  637. },
  638. // 验证协议字符串的合法性
  639. validateProtocols(protocols) {
  640. const MAX_SEGMENT_LENGTH = 64 + 30; // WebSocket 子协议名称按照 RFC 6455 规范,建议长度不超过 64 字符
  641. if (!protocols || !Array.isArray(protocols)) {
  642. return false;
  643. }
  644. for (const protocol of protocols) {
  645. // 检查协议名称是否符合规范(RFC 6455)
  646. // 只允许字母、数字、连字符、下划线、点
  647. if (!/^[a-zA-Z0-9\-._]+$/.test(protocol)) {
  648. console.error('协议包含非法字符:', protocol);
  649. return false;
  650. }
  651. // 检查长度限制
  652. if (protocol.length > MAX_SEGMENT_LENGTH) {
  653. console.error('协议长度超限:', protocol.length);
  654. return false;
  655. }
  656. // console.log('protocal -- ', protocol, protocol.length)
  657. }
  658. return true;
  659. },
  660. // 断开WebSocket连接
  661. disconnectWebSocket() {
  662. if (this.socket) {
  663. this.isManualDisconnect = true; // 标记为主动断开
  664. this.socket.close();
  665. this.socket = null;
  666. this.isConnected = false;
  667. }
  668. // 停止心跳
  669. this.stopHeartbeat();
  670. },
  671. // 重连WebSocket
  672. reconnectWebSocket() {
  673. if (this.reconnectCount < this.maxReconnectCount) {
  674. this.reconnectCount++;
  675. console.log(`正在尝试第${this.reconnectCount}次重连...`);
  676. this.connectWebSocket();
  677. } else {
  678. uni.showToast({
  679. title: '连接失败,请检查网络',
  680. icon: 'none',
  681. duration: 3000
  682. });
  683. }
  684. },
  685. // 处理 WebSocket 消息
  686. handleWebSocketMessage(data) {
  687. // console.log('---------- data: ', data);
  688. try {
  689. // 尝试解析JSON,如果失败则认为是纯文本消息
  690. let messageData;
  691. try {
  692. const temp = JSON.parse(data);
  693. const msg = temp.message ? temp.message : temp; // TODO 测试兼容
  694. messageData = JSON.parse(msg);
  695. } catch (parseError) {
  696. // 如果不是JSON格式,可能是纯文本消息,暂时忽略或创建简单消息对象
  697. console.log('收到非JSON格式消息:', data);
  698. try {
  699. messageData = JSON.parse(data);
  700. } catch (error) {
  701. console.error('解析WebSocket消息失败:', error, data);
  702. }
  703. }
  704. let isMine = false;
  705. // 检查是否是本店但是他人发送的消息
  706. const messageShopId = parseInt(messageData.shopId);
  707. const currentShopId = parseInt(this.currentUser.shopId);
  708. const isShopMessage = messageShopId === currentShopId && messageShopId > 0;
  709. if (isShopMessage) {
  710. isMine = true;
  711. }
  712. const messageType = messageData.messageType || 'text';
  713. // console.log('messageType -- ', messageType);
  714. // 创建消息对象
  715. const newMessage = {
  716. id: 'msg_receive_' + Math.floor(Date.now() / 1000) + '_' + Math.floor(Math.random() * 100000) + 1,
  717. isMine: isMine, // 标记是否是本店发送的消息
  718. //--------------------------------
  719. message: messageData.message || messageData || '',
  720. username: messageData.name || messageData.username || '未知用户',
  721. avatar: messageData.avatar || '',
  722. time: this.getCurrentTime(), // 收到消息时间点
  723. timestamp: messageData.timestamp, // 消息发送时间点
  724. messageType: messageType
  725. };
  726. if (newMessage.messageType === 'goods') {
  727. newMessage.goodsInfo = this.parseGoodsMessage(newMessage.message);
  728. }
  729. // console.log('收到 ---------- newMessage: ', newMessage);
  730. // 添加到消息列表
  731. this.messageList.push(newMessage);
  732. // 滚动到底部
  733. // this.$nextTick(() => {
  734. // this.scrollToBottom(); // 用户操作时使用动画
  735. // });
  736. setTimeout(() => {
  737. this.scrollToBottom();
  738. }, 100);
  739. } catch (error) {
  740. console.error('解析WebSocket消息失败:', error, data);
  741. }
  742. },
  743. // 发送消息
  744. sendMessage() {
  745. if (!this.inputText.trim()) {
  746. return;
  747. }
  748. if (!this.isConnected) {
  749. uni.showToast({
  750. title: '未连接到网络',
  751. icon: 'none'
  752. });
  753. return;
  754. }
  755. const messageContent = this.inputText.trim();
  756. const avatar = this.currentUser.avatar || '';
  757. const timestamp = Math.floor(Date.now() / 1000);
  758. try {
  759. // 立即在本地显示发送的消息(右侧)
  760. const myMessage = {
  761. id: 'msg_local_' + timestamp + '_' + Math.floor(Math.random() * 100000) + 1,
  762. isMine: true,
  763. //--------------------------------
  764. message: messageContent,
  765. username: this.currentUser.username,
  766. avatar: avatar,
  767. time: this.getCurrentTime(timestamp), // 收到消息时间点
  768. timestamp: timestamp, // 消息发送时间点
  769. messageType: 'text'
  770. };
  771. // 添加到消息列表
  772. this.messageList.push(myMessage);
  773. // 清空输入框
  774. this.inputText = '';
  775. // 重新聚焦
  776. // this.$nextTick(() => {
  777. // this.inputFocus = false
  778. // this.$nextTick(() => {
  779. // this.inputFocus = true
  780. // })
  781. // });
  782. // 构建要发送的消息数据
  783. const messageData = {
  784. receiver: this.currentUser.customId,
  785. message: messageContent,
  786. username: this.currentUser.username,
  787. avatar: avatar,
  788. shopId: this.currentUser.shopId, // 发送者:门店使用shopId,客户使用customId
  789. timestamp: timestamp,
  790. messageType: 'text'
  791. };
  792. const json = JSON.stringify(messageData);
  793. // console.log('send ---------- json: ', json);
  794. // 发送到WebSocket服务器
  795. this.socket.send({
  796. data: json,
  797. success: () => {
  798. // console.log('消息发送成功');
  799. },
  800. fail: (error) => {
  801. console.error('消息发送失败:', error);
  802. uni.showToast({
  803. title: '发送失败',
  804. icon: 'none'
  805. });
  806. // 发送失败时,可以考虑移除刚添加的消息或标记为失败
  807. }
  808. });
  809. // 滚动到底部
  810. // this.$nextTick(() => {
  811. // this.scrollToBottom(); // 用户操作时使用动画
  812. // });
  813. setTimeout(() => {
  814. this.scrollToBottom();
  815. }, 280);
  816. } catch (error) {
  817. console.error('发送消息异常:', error);
  818. uni.showToast({
  819. title: '发送异常',
  820. icon: 'none'
  821. });
  822. }
  823. },
  824. // 处理连接错误
  825. handleConnectionError() {
  826. console.log('连接失败--', this.reconnectCount);
  827. // uni.showToast({
  828. // title: '连接失败',
  829. // icon: 'none',
  830. // duration: 2000
  831. // });
  832. },
  833. // 滚动到底部
  834. scrollToBottom(enableAnimation = true) {
  835. this.scrollWithAnimation = enableAnimation;
  836. if (this.messageList.length > 0) {
  837. this.scrollIntoView = 'message-' + this.messageList[this.messageList.length - 1].id;
  838. }
  839. },
  840. // 获取当前时间
  841. getCurrentTime(timestamp = null) {
  842. let now;
  843. if (timestamp) {
  844. now = new Date(timestamp * 1000);
  845. } else {
  846. now = new Date();
  847. }
  848. const hours = String(now.getHours()).padStart(2, '0');
  849. const minutes = String(now.getMinutes()).padStart(2, '0');
  850. if (this.today == now.toLocaleDateString()) {
  851. return `${hours}:${minutes}`;
  852. } else {
  853. // return now.toLocaleDateString() + ' ' + `${hours}:${minutes}`;
  854. return `${now.getMonth() + 1}/${now.getDate()} ${hours}:${minutes}`;
  855. }
  856. },
  857. // 输入框聚焦事件
  858. onInputFocus() {
  859. // console.log('输入框聚焦');
  860. // 微信小程序中,聚焦时延迟滚动到底部
  861. setTimeout(() => {
  862. this.scrollToBottom();
  863. }, 450);
  864. this.isInputFocused = true;
  865. this.inputFocus = true;
  866. //this.hideExtraPanels() // 聚焦时隐藏表情和更多面板
  867. },
  868. // 输入框失焦事件
  869. onInputBlur() {
  870. // console.log('输入框失焦');
  871. this.isInputFocused = false;
  872. // this.inputFocus = false
  873. },
  874. // 输入内容变化事件
  875. onInputChange(event) {
  876. this.inputText = event.detail.value;
  877. // 可以在这里处理输入内容的变化,比如限制行数等
  878. const value = event.detail.value;
  879. // 限制最大行数为5行,避免输入框过高
  880. const lines = value.split('\n');
  881. if (lines.length > 5) {
  882. // 截取前5行
  883. this.inputText = lines.slice(0, 5).join('\n');
  884. }
  885. },
  886. // 解析商品消息
  887. parseGoodsMessage(messageStr) {
  888. try {
  889. // 首先检查 messageStr 是否已经是对象
  890. if (typeof messageStr === 'object' && messageStr !== null) {
  891. return messageStr;
  892. }
  893. // 如果是字符串,则尝试解析
  894. if (typeof messageStr === 'string') {
  895. let parsed = JSON.parse(messageStr);
  896. if(parsed.goodsPriceType == 0 || parsed.goodsPriceType == 1) { // 请求后端获取修改价格,再根据结果展示
  897. // 如果没有过期,请求后端获取修改价格
  898. const now = Math.floor(Date.now()/1000);
  899. const timeDiff = now - parsed.timestamp;
  900. const expireTime = 24 * 60 * 60;
  901. if (timeDiff > expireTime) {
  902. // console.log("parsed 过期:", parsed);
  903. } else {
  904. // console.log("parsed 没价格:", parsed);
  905. const key = parsed.key || '';
  906. if (key != '') {
  907. getQuotedPrice({
  908. key: key,
  909. }).then(res => {
  910. // console.log("res", res)
  911. if (res.code == 1) {
  912. if (res.data.price != -1) {
  913. parsed.resourcePrice = parseFloat(parsed.goodsPrice).toFixed(2);
  914. parsed.goodsPriceType = 2; // 价格类型
  915. parsed.goodsPrice = parseFloat(res.data.price).toFixed(2);
  916. // 通过 parsed.goodsPriceType 判断哪种类型
  917. }
  918. }
  919. }).catch(err => {
  920. console.log("getQuotedPrice error: ", key, err)
  921. })
  922. }
  923. }
  924. } else if(parsed.goodsPriceType == 1) {
  925. // console.log("parsed 有价格:", parsed);
  926. } else if(parsed.goodsPriceType == 2) {
  927. // console.log("parsed 重新改价:", parsed);
  928. } else {
  929. console.error("parsed 其他类型:", parsed);
  930. return {};
  931. }
  932. return parsed;
  933. }
  934. console.error("解析商品消息失败:", messageStr);
  935. // 如果两者都不是,返回空对象
  936. return {};
  937. } catch (error) {
  938. console.error("解析商品消息失败:", error, messageStr);
  939. // 在解析失败时返回一个包含默认值的对象,以避免模板渲染错误
  940. return {
  941. goodsImg: '',
  942. goodsName: '商品信息解析失败',
  943. goodsPrice: '0.00'
  944. };
  945. }
  946. },
  947. // 发送商品改价信息
  948. sendMessageOfGoodsPrice(currentGoodsInfo) {
  949. if (!this.isConnected) {
  950. uni.showToast({
  951. title: '未连接到网络',
  952. icon: 'none'
  953. });
  954. return;
  955. }
  956. const avatar = this.currentUser.avatar || '';
  957. const timestamp = Math.floor(Date.now() / 1000);
  958. // 构建商品信息消息体 -------------------------------- 所看到的消息体 -------------------------------
  959. const goodsMessage = {
  960. ...currentGoodsInfo
  961. };
  962. const messageContent = JSON.stringify(goodsMessage); // 所看到的消息体
  963. try {
  964. // 立即在本地显示发送的消息(右侧)
  965. const myMessage = {
  966. id: 'msg_local_' + timestamp + '_' + (Math.floor(Math.random() * 100000) + 1),
  967. isMine: true,
  968. // message: `[商品] ${this.goodsInfo.goodsName}`, // 显示简化信息
  969. message: messageContent,
  970. username: this.currentUser.username,
  971. avatar: avatar,
  972. time: this.getCurrentTime(timestamp),
  973. messageType: 'goods', // 附加一个字段,用于渲染时区分商品消息
  974. goodsInfo: goodsMessage
  975. };
  976. // 添加到消息列表
  977. this.messageList.push(myMessage);
  978. // 滚动到底部
  979. // this.$nextTick(() => {
  980. // this.scrollToBottom();
  981. // });
  982. setTimeout(() => {
  983. this.scrollToBottom();
  984. }, 100);
  985. // 构建要发送的消息数据
  986. const messageData = {
  987. receiver: this.currentUser.customId,
  988. message: messageContent,
  989. username: this.currentUser.username,
  990. avatar: avatar,
  991. shopId: this.currentUser.shopId, // 发送者:门店使用shopId,客户使用customId
  992. timestamp: timestamp,
  993. messageType: 'goods'
  994. };
  995. const json = JSON.stringify(messageData);
  996. // 发送到WebSocket服务器
  997. this.socket.send({
  998. data: json,
  999. success: () => {
  1000. // console.log("商品消息发送成功");
  1001. },
  1002. fail: (error) => {
  1003. console.error('商品消息发送失败:', error);
  1004. uni.showToast({
  1005. title: '发送失败',
  1006. icon: 'none'
  1007. });
  1008. }
  1009. });
  1010. } catch (error) {
  1011. console.error('发送商品消息异常:', error);
  1012. uni.showToast({
  1013. title: '发送异常',
  1014. icon: 'none'
  1015. });
  1016. }
  1017. },
  1018. createQrotedPrice(message) {
  1019. // console.log("createPrice", message)
  1020. // 表单验证
  1021. if (message.quotedPrice == '') {
  1022. this.$msg('请输入价格');
  1023. return;
  1024. }
  1025. if (isNaN(message.quotedPrice)) {
  1026. this.$msg('价格不对,请输入数字');
  1027. return;
  1028. }
  1029. if (message.quotedPrice < 0.01) {
  1030. this.$msg('价格不能小于0.01');
  1031. return;
  1032. }
  1033. const keyArr = message.goodsInfo.key.split('-');// shopId-userId-goodsId
  1034. if (keyArr.length != 3) {
  1035. //console.log("keyArr.length != 3", keyArr)
  1036. this.$msg('key格式不正确');
  1037. return;
  1038. }
  1039. const shopId = parseInt(keyArr[0]);
  1040. const userId = parseInt(keyArr[1]);
  1041. const goodsId = parseInt(keyArr[2]);
  1042. if (isNaN(shopId) || isNaN(userId) || isNaN(goodsId)) {
  1043. console.error("isNaN(shopId) || isNaN(userId) || isNaN(goodsId)", shopId, userId, goodsId)
  1044. this.$msg('key数据出错');
  1045. return;
  1046. }
  1047. createQuotedPriceApi({
  1048. key: message.goodsInfo.key,
  1049. price: parseFloat(message.quotedPrice).toFixed(2)
  1050. }).then((res) => {
  1051. // console.log("createQuotedPriceApi", res)
  1052. if (res.code == 1) {
  1053. this.$msg('改价成功');
  1054. message.goodsInfo.goodsPriceType = 2;
  1055. message.goodsInfo.goodsPrice = parseFloat(message.quotedPrice).toFixed(2);
  1056. message.goodsInfo.lastPrice = parseFloat(message.quotedPrice).toFixed(2);
  1057. message.quotedPrice = '';
  1058. this.sendMessageOfGoodsPrice(message.goodsInfo);
  1059. } else {
  1060. this.$msg(res.msg);
  1061. }
  1062. }).catch((err) => {
  1063. console.log('createQuotedPrice error', err);
  1064. });
  1065. },
  1066. isExpired(timestamp) {
  1067. if (isNaN(timestamp)) {
  1068. return false;
  1069. }
  1070. const now = Math.floor(Date.now() / 1000);
  1071. const timeDiff = now - timestamp;
  1072. return timeDiff < this.expireTime;
  1073. },
  1074. // 查看商品
  1075. toGoodsDetail(goodsInfo) {
  1076. // console.log("---------- goodsInfo: ", goodsInfo);
  1077. const goodsId = parseInt(goodsInfo.goodsId);
  1078. const categoryId = parseInt(goodsInfo.categoryId);
  1079. const hdId = parseInt(goodsInfo.hdId);
  1080. const account = parseInt(goodsInfo.account);
  1081. if (isNaN(goodsId) || isNaN(categoryId) || isNaN(hdId) || isNaN(account)) {
  1082. this.$msg('商品数据异常');
  1083. return;
  1084. }
  1085. this.$util.pageTo({
  1086. url: '/admin/goods/detail',
  1087. query: {
  1088. // id=2020&categoryId=1402&hdId=229&account=36548&shopId=36548&name=花仙子&avatar=https://img.theflorist.cn/hhb_small.png
  1089. id: goodsId,
  1090. account: goodsInfo.account,
  1091. hdId: goodsInfo.hdId,
  1092. categoryId: categoryId,
  1093. shopId: goodsInfo.shopId,
  1094. name: goodsInfo.name,
  1095. avatar: goodsInfo.avatar
  1096. }
  1097. });
  1098. this.disconnectWebSocket();
  1099. },
  1100. hideKeyboard() {
  1101. this.keyboardHeight = 0;
  1102. this.msgInputShow = false;
  1103. },
  1104. showKeyboard() {
  1105. this.msgInputShow = true;
  1106. },
  1107. // 补全头像地址:若不含 http(s):// 则加上 this.$constant.imgUrl + '/'
  1108. formatAvatarUrl(avatar) {
  1109. try {
  1110. const value = avatar || '';
  1111. if (!value) {
  1112. return this.$constant.imgUrl + '/retail/default-img.png';
  1113. }
  1114. // 已是 http/https 绝对地址
  1115. if (/^https?:\/\//i.test(value)) {
  1116. return value;
  1117. }
  1118. const base =this.$constant.imgUrl || '';
  1119. if (!base) {
  1120. return value;
  1121. }
  1122. const normalizedBase = base.replace(/\/+$/, '');
  1123. if (value.startsWith('/')) {
  1124. return normalizedBase + value;
  1125. }
  1126. return normalizedBase + '/' + value;
  1127. } catch (e) {
  1128. return avatar;
  1129. }
  1130. }
  1131. }
  1132. };
  1133. </script>
  1134. <style lang="scss" scoped>
  1135. .chat-container {
  1136. height: 100vh;
  1137. display: flex;
  1138. flex-direction: column;
  1139. background-color: #f5f5f5;
  1140. }
  1141. .connection-status {
  1142. background-color: #ff9800;
  1143. color: #ffffff;
  1144. text-align: center;
  1145. padding: 16upx;
  1146. font-size: 24upx;
  1147. .status-text {
  1148. color: #ffffff;
  1149. }
  1150. }
  1151. .message-list {
  1152. flex: 1;
  1153. padding: 20upx;
  1154. overflow-y: auto;
  1155. }
  1156. .load-more {
  1157. text-align: center;
  1158. padding: 20upx 0;
  1159. .load-more-text {
  1160. color: #007aff;
  1161. font-size: 28upx;
  1162. cursor: pointer;
  1163. }
  1164. }
  1165. .message-item {
  1166. margin-bottom: 30upx;
  1167. &:last-child {
  1168. margin-bottom: 0;
  1169. }
  1170. }
  1171. .message-left {
  1172. display: flex;
  1173. justify-content: flex-start;
  1174. align-items: flex-start;
  1175. }
  1176. .message-right {
  1177. display: flex;
  1178. justify-content: flex-end;
  1179. align-items: flex-start;
  1180. }
  1181. .avatar-wrapper {
  1182. flex-shrink: 0;
  1183. margin: 0 20upx;
  1184. }
  1185. .avatar {
  1186. width: 80upx;
  1187. height: 80upx;
  1188. border-radius: 50%;
  1189. background-color: #e0e0e0;
  1190. }
  1191. .message-content-wrapper {
  1192. max-width: 520upx;
  1193. min-width: 100upx;
  1194. }
  1195. .goods-content-wrapper {
  1196. width: 72%;
  1197. }
  1198. .message-bubble {
  1199. padding: 20upx 24upx;
  1200. border-radius: 16upx;
  1201. position: relative;
  1202. word-wrap: break-word;
  1203. word-break: break-all;
  1204. &.left-bubble {
  1205. background-color: #ffffff;
  1206. margin-left: 0;
  1207. border-top-left-radius: 8upx;
  1208. &::before {
  1209. content: '';
  1210. position: absolute;
  1211. left: -22upx;
  1212. top: 30upx;
  1213. width: 0;
  1214. height: 0;
  1215. border: 12upx solid transparent;
  1216. border-right-color: #ffffff;
  1217. z-index: 999999;
  1218. }
  1219. }
  1220. &.right-bubble {
  1221. background-color: rgb(27, 193, 66);
  1222. margin-right: 0;
  1223. border-top-right-radius: 8upx;
  1224. &::before {
  1225. content: '';
  1226. position: absolute;
  1227. right: -22upx;
  1228. top: 30upx;
  1229. width: 0;
  1230. height: 0;
  1231. border: 12upx solid transparent;
  1232. border-left-color: rgb(27, 193, 66);
  1233. z-index: 999999;
  1234. }
  1235. .message-text {
  1236. color: #ffffff;
  1237. }
  1238. }
  1239. }
  1240. .goods-info.right-bubble {
  1241. background-color: #fff !important;
  1242. color: #333;
  1243. &::before {
  1244. border-left-color: #fff !important;
  1245. }
  1246. }
  1247. .goods-info {
  1248. background-color: #fff !important;
  1249. color: #333;
  1250. padding: 20rpx;
  1251. border-radius: 16rpx;
  1252. &.right-bubble {
  1253. background-color: #fff !important; // 覆盖默认气泡颜色
  1254. &::before {
  1255. border-left-color: #fff; // 确保箭头颜色与背景一致
  1256. }
  1257. }
  1258. .goods-main {
  1259. display: flex;
  1260. position: relative;
  1261. }
  1262. .goods-image {
  1263. width: 180rpx;
  1264. height: 180rpx;
  1265. border-radius: 8rpx;
  1266. margin-right: 20rpx;
  1267. flex-shrink: 0;
  1268. }
  1269. .goods-details {
  1270. flex: 1;
  1271. display: flex;
  1272. flex-direction: column;
  1273. justify-content: space-between;
  1274. .goods-name-wrapper {
  1275. height: 80rpx;
  1276. }
  1277. .goods-name {
  1278. font-size: 30rpx;
  1279. color: #333;
  1280. font-weight: bold;
  1281. // 多行省略
  1282. text-overflow: -o-ellipsis-lastline;
  1283. overflow: hidden;
  1284. text-overflow: ellipsis;
  1285. display: -webkit-box;
  1286. -webkit-line-clamp: 2;
  1287. line-clamp: 2;
  1288. -webkit-box-orient: vertical;
  1289. }
  1290. .sales-info {
  1291. display: flex;
  1292. align-items: center;
  1293. margin-top: 10rpx;
  1294. }
  1295. .delivery-time {
  1296. background-color: #e8f5ff;
  1297. color: #007aff;
  1298. font-size: 20rpx;
  1299. padding: 4rpx 8rpx;
  1300. border-radius: 4rpx;
  1301. }
  1302. .goods-price {
  1303. font-size: 28rpx;
  1304. color: #ff5050;
  1305. font-weight: bold;
  1306. margin-top: 40rpx;
  1307. }
  1308. .goods-price--secondary {
  1309. font-size: 24rpx;
  1310. color: #999999;
  1311. font-weight: normal;
  1312. }
  1313. }
  1314. .goods-footer {
  1315. display: flex;
  1316. justify-content: space-between;
  1317. align-items: center;
  1318. margin-top: 20rpx;
  1319. padding-top: 10rpx;
  1320. border-top: 1rpx solid #f5f5f5;
  1321. }
  1322. .action-buttons {
  1323. display: flex;
  1324. justify-content: flex-end;
  1325. align-items: center;
  1326. margin-top: 20rpx;
  1327. form {
  1328. display: flex;
  1329. align-items: center;
  1330. }
  1331. .price-input {
  1332. display: inline-block;
  1333. vertical-align: middle;
  1334. width: 160rpx;
  1335. height: 60rpx;
  1336. border: 1px solid #666666;
  1337. border-radius: 8rpx;
  1338. padding: 0 15rpx;
  1339. font-size: 28rpx;
  1340. text-align: center;
  1341. color: #666666;
  1342. }
  1343. .price-input::placeholder {
  1344. color: #aaaaaa;
  1345. }
  1346. .action-btn {
  1347. display: inline-block;
  1348. vertical-align: middle;
  1349. padding: 10rpx 30rpx;
  1350. border-radius: 40rpx;
  1351. font-size: 28rpx;
  1352. margin-left: 20rpx;
  1353. line-height: 1.5;
  1354. &::after {
  1355. border: none;
  1356. }
  1357. }
  1358. .spec-btn {
  1359. background-color: #f5f5ff;
  1360. color: #333;
  1361. border: 1px solid #eee;
  1362. }
  1363. .buy-btn {
  1364. background-color: #ff9800;
  1365. color: white;
  1366. }
  1367. .goods-price {
  1368. font-size: 28rpx;
  1369. color: green;
  1370. font-weight: bold;
  1371. margin-right: 12rpx;
  1372. }
  1373. }
  1374. }
  1375. .message-text {
  1376. font-size: 32upx;
  1377. line-height: 1.4;
  1378. color: $fontColorMain;
  1379. }
  1380. .message-time {
  1381. font-size: 20upx;
  1382. color: $fontColor3;
  1383. margin-top: 8upx;
  1384. &.align-right {
  1385. text-align: right;
  1386. }
  1387. }
  1388. .empty-state {
  1389. display: flex;
  1390. justify-content: center;
  1391. align-items: center;
  1392. height: 300upx;
  1393. }
  1394. .empty-text {
  1395. font-size: 32upx;
  1396. color: $fontColor3;
  1397. }
  1398. .input-area {
  1399. background-color: #ffffff;
  1400. padding: 26upx;
  1401. // margin-bottom: 20upx;
  1402. border-top: 1upx solid $borderColor;
  1403. // 适配安全区域和键盘高度(通过内联样式动态设置)
  1404. transition: padding-bottom 0.3s ease;
  1405. }
  1406. .input-wrapper {
  1407. display: flex;
  1408. align-items: center;
  1409. background-color: #f8f8f8;
  1410. border-radius: 40upx;
  1411. padding: 14upx 18upx 14upx 18upx;
  1412. margin-bottom: 30upx;
  1413. }
  1414. .message-input {
  1415. flex: 1;
  1416. font-size: 32upx;
  1417. border: none;
  1418. background-color: transparent;
  1419. outline: none;
  1420. min-height: 28upx;
  1421. max-height: 140upx;
  1422. line-height: 1.4;
  1423. word-wrap: break-word;
  1424. word-break: break-all;
  1425. resize: none;
  1426. &::placeholder {
  1427. color: #cccccc;
  1428. }
  1429. }
  1430. .send-button {
  1431. margin-left: 20upx;
  1432. padding: 10upx 30upx;
  1433. background-color: $fontColor4;
  1434. color: #ffffff;
  1435. border: 1px solid #c9c9c9;
  1436. border-radius: 30upx;
  1437. font-size: 28upx;
  1438. transition: all 0.3s ease;
  1439. &.send-active {
  1440. background-color: $mainColor;
  1441. }
  1442. &:disabled {
  1443. background-color: $fontColor4;
  1444. color: #ffffff;
  1445. }
  1446. &::after {
  1447. border: none;
  1448. }
  1449. }
  1450. // 适配不同设备
  1451. /* #ifdef H5 */
  1452. .chat-container {
  1453. height: calc(100vh - 44px); // 减去导航栏高度
  1454. }
  1455. /* #endif */
  1456. /* #ifdef MP-WEIXIN */
  1457. .input-area {
  1458. // 微信小程序底部安全区域
  1459. padding-bottom: calc(20upx + env(safe-area-inset-bottom));
  1460. }
  1461. /* #endif */
  1462. /* #ifdef APP-PLUS */
  1463. .input-area {
  1464. // App端底部安全区域
  1465. padding-bottom: calc(20upx + env(safe-area-inset-bottom));
  1466. }
  1467. /* #endif */
  1468. </style>