chatPage.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041
  1. <template>
  2. <view class="chat-container">
  3. <!-- 连接状态指示器 -->
  4. <view class="connection-status" v-if="!isConnected">
  5. <text class="status-text">{{ reconnectCount > 0 ? '重连中...' : '连接中...' }}</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
  21. class="message-item"
  22. v-for="(message, index) in messageList"
  23. :key="index"
  24. :id="'message-' + message.id"
  25. >
  26. <!-- 左侧消息(商家/其他用户) -->
  27. <view class="message-left" v-if="!message.isMine && !message.shopId">
  28. <view class="avatar-wrapper">
  29. <image class="avatar" :src="message.avatar" mode="aspectFill" />
  30. </view>
  31. <view class="message-content-wrapper">
  32. <view class="username" v-if="isMultiUser">{{ message.username }}</view>
  33. <view class="message-bubble left-bubble">
  34. <text class="message-text">{{ message.message }}</text>
  35. </view>
  36. <view class="message-time">{{ message.time }}</view>
  37. </view>
  38. </view>
  39. <!-- 右侧消息(我的) -->
  40. <view class="message-right" v-if="message.isMine || message.shopId">
  41. <view class="message-content-wrapper">
  42. <view class="username align-right" v-if="isMultiUser">{{ message.username }}</view>
  43. <view class="message-bubble right-bubble">
  44. <text class="message-text">{{ message.message }}</text>
  45. </view>
  46. <view class="message-time align-right">{{ message.time }}</view>
  47. </view>
  48. <view class="avatar-wrapper">
  49. <image class="avatar" :src="message.avatar" mode="aspectFill" />
  50. </view>
  51. </view>
  52. </view>
  53. <!-- 空状态 -->
  54. <view class="empty-state" v-if="messageList.length === 0">
  55. <text class="empty-text">暂无聊天记录</text>
  56. </view>
  57. </scroll-view>
  58. <!-- 输入区域 -->
  59. <view class="input-area" :style="{ 'padding-bottom': keyboardHeight > 0 ? keyboardHeight + 'px' : 'calc(20upx + env(safe-area-inset-bottom))' }">
  60. <view class="input-wrapper">
  61. <textarea
  62. class="message-input"
  63. v-model="inputText"
  64. placeholder="请输入消息..."
  65. :placeholder-style="placeholderStyle"
  66. @confirm="sendMessage"
  67. @focus="onInputFocus"
  68. @blur="onInputBlur"
  69. @input="onInputChange"
  70. confirm-type="send"
  71. :adjust-position="false"
  72. :auto-height="true"
  73. :maxlength="500"
  74. :show-confirm-bar="false"
  75. :focus="inputFocus"
  76. />
  77. <button
  78. class="send-button"
  79. :class="{ 'send-active': inputText.trim() }"
  80. @click="sendMessage"
  81. :disabled="!inputText.trim()"
  82. >
  83. 发送
  84. </button>
  85. </view>
  86. </view>
  87. </view>
  88. </template>
  89. <script>
  90. import { getChatHistory } from '@/api/chat';
  91. import { WSSHOST } from '@/config.js';
  92. export default {
  93. data() {
  94. return {
  95. inputText: '',
  96. scrollTop: 0,
  97. scrollIntoView: '',
  98. placeholderStyle: 'color: #ccc; font-size: 28upx;',
  99. // 是否多人聊天
  100. isMultiUser: false,
  101. // 当前用户信息
  102. currentUser: {
  103. userId: 0, //客户登录帐户的id(不是 customId)
  104. staffId: 0, //门店员工id
  105. customId: 0, //客户id
  106. shopId: 0, //门店id
  107. username: '', //门店名称
  108. avatar: ''
  109. },
  110. // 聊天消息列表
  111. messageList: [],
  112. // WebSocket相关
  113. socket: null,
  114. isConnected: false,
  115. room: '',
  116. reconnectCount: 0,
  117. maxReconnectCount: 3,
  118. isManualDisconnect: false, // 添加标志位:是否主动断开连接
  119. scrollWithAnimation: false, // 控制滚动动画
  120. // 分页加载
  121. allMessages: [], // 存储所有从API获取的消息
  122. initialLoadSize: 50, // 首次加载数量
  123. loadMoreSize: 100, // 每次加载更多的数量
  124. showLoadMore: false, // 是否显示“加载更多”
  125. // 键盘相关
  126. keyboardHeight: 0, // 键盘高度
  127. isKeyboardShow: false, // 键盘是否显示
  128. inputFocus: false, // 输入框焦点
  129. isInputFocused: false, // 标记输入框是否聚焦
  130. };
  131. },
  132. onLoad(options) {
  133. console.log('---------- options: ', options);
  134. // 根据传入参数判断是否多人聊天
  135. if (options.isMultiUser) {
  136. this.isMultiUser = options.isMultiUser === 'true';
  137. }
  138. if (options.userId) {
  139. this.currentUser.userId = options.userId;
  140. }
  141. if (options.customId) {
  142. this.currentUser.customId = options.customId;
  143. }
  144. if (options.shopId) {
  145. this.currentUser.shopId = options.shopId;
  146. }
  147. if (options.name) {
  148. this.currentUser.username = options.name;
  149. }
  150. if (options.staffId) {
  151. this.currentUser.staffId = options.staffId;
  152. }
  153. if (options.avatar) {
  154. this.currentUser.avatar = options.avatar;
  155. }
  156. // 获取房间号
  157. if (this.currentUser.customId && this.currentUser.shopId) {
  158. this.room = 'custom_id-' + this.currentUser.customId + 'AND' + 'shop_id-' + this.currentUser.shopId;
  159. } else {
  160. // 如果没有传入房间号,使用默认值或生成一个
  161. this.room = 'default_room';
  162. }
  163. console.log('---------- this.room: ', this.room);
  164. // 加载聊天记录
  165. this.loadChatHistory();
  166. // 连接WebSocket
  167. this.connectWebSocket();
  168. },
  169. onReady() {
  170. // 页面加载完成后滚动到底部
  171. this.$nextTick(() => {
  172. this.scrollToBottom();
  173. });
  174. // 监听键盘弹起事件
  175. uni.onKeyboardHeightChange((res) => {
  176. this.keyboardHeight = res.height;
  177. this.isKeyboardShow = res.height > 0;
  178. // 键盘弹起时,延迟滚动到底部
  179. if (this.isKeyboardShow) {
  180. this.$nextTick(() => {
  181. setTimeout(() => {
  182. this.scrollToBottom();
  183. }, 300);
  184. });
  185. }
  186. });
  187. },
  188. onUnload() {
  189. // 页面卸载时关闭WebSocket连接
  190. this.isManualDisconnect = true; // 标记为主动断开
  191. this.disconnectWebSocket();
  192. },
  193. onHide() {
  194. // 页面隐藏时可以选择性地关闭连接(根据业务需求)
  195. // this.disconnectWebSocket()
  196. },
  197. onShow() {
  198. // 页面显示时重新连接(如果之前断开了)
  199. // if (!this.isConnected && this.room) {
  200. // this.connectWebSocket();
  201. // }
  202. },
  203. methods: {
  204. init() {},
  205. // 加载聊天历史记录
  206. loadChatHistory() {
  207. getChatHistory({
  208. roomName: this.room,
  209. userId: this.currentUser.id
  210. }).then((res) => {
  211. if (res.code == 1 && res.data) {
  212. this.allMessages = res.data.map((item, index) => ({
  213. ...item,
  214. id: `msg_${index}`, // 为每条消息添加唯一ID
  215. }));
  216. if (this.allMessages.length > this.initialLoadSize) {
  217. this.messageList = this.allMessages.slice(
  218. this.allMessages.length - this.initialLoadSize
  219. );
  220. this.showLoadMore = true;
  221. } else {
  222. this.messageList = this.allMessages;
  223. this.showLoadMore = false;
  224. }
  225. this.$nextTick(() => {
  226. this.scrollToBottom(false); // 初始加载时不使用动画
  227. });
  228. }
  229. });
  230. },
  231. // 加载更多历史记录
  232. loadMoreMessages() {
  233. if (!this.showLoadMore) {
  234. return;
  235. }
  236. const currentLoadedCount = this.messageList.length;
  237. const remainingMessagesCount = this.allMessages.length - currentLoadedCount;
  238. if (remainingMessagesCount <= 0) {
  239. this.showLoadMore = false;
  240. return;
  241. }
  242. const countToLoad = Math.min(this.loadMoreSize, remainingMessagesCount);
  243. const moreMessages = this.allMessages.slice(
  244. remainingMessagesCount - countToLoad,
  245. remainingMessagesCount
  246. );
  247. const oldTopMessageId = this.messageList.length > 0 ? this.messageList[0].id : null;
  248. this.messageList = [...moreMessages, ...this.messageList];
  249. if (this.allMessages.length - (currentLoadedCount + countToLoad) <= 0) {
  250. this.showLoadMore = false;
  251. }
  252. if (oldTopMessageId) {
  253. this.$nextTick(() => {
  254. this.scrollWithAnimation = false; // 加载更多时不使用动画
  255. this.scrollIntoView = "message-" + oldTopMessageId;
  256. });
  257. }
  258. },
  259. // 获取主机地址
  260. getHost() {
  261. // #ifdef H5
  262. return WSSHOST;
  263. // #endif
  264. // #ifdef MP-WEIXIN || APP-PLUS
  265. // 小程序和App环境下,需要配置具体的服务器地址
  266. //return 'ws://192.168.10.182:8080';
  267. return WSSHOST; // 替换为实际的服务器地址
  268. // #endif
  269. },
  270. // 连接WebSocket
  271. connectWebSocket() {
  272. if (!this.room) {
  273. console.error('房间号不能为空');
  274. uni.showToast({
  275. title: '房间号不能为空',
  276. icon: 'none'
  277. });
  278. return;
  279. }
  280. try {
  281. // 构建WebSocket URL
  282. const wsUrl = `${this.getHost()}/room`;
  283. // 构建子协议参数(模拟POST body传输)
  284. const protocols = this.buildWebSocketProtocols();
  285. console.log('protocols: ', protocols);
  286. // 创建WebSocket连接
  287. this.socket = uni.connectSocket({
  288. url: wsUrl,
  289. protocols: protocols,
  290. success: () => {
  291. console.log('WebSocket连接创建成功');
  292. },
  293. fail: (error) => {
  294. console.error('WebSocket连接创建失败:', error);
  295. this.handleConnectionError();
  296. }
  297. });
  298. // 监听WebSocket连接打开
  299. this.socket.onOpen(() => {
  300. console.log('WebSocket连接已打开');
  301. this.isConnected = true;
  302. this.reconnectCount = 0;
  303. this.isManualDisconnect = false; // 连接成功时重置标志位
  304. });
  305. // 监听WebSocket消息
  306. this.socket.onMessage((event) => {
  307. this.handleWebSocketMessage(event.data);
  308. });
  309. // 监听WebSocket连接关闭
  310. this.socket.onClose(() => {
  311. console.log('WebSocket连接已关闭');
  312. this.isConnected = false;
  313. // 只有在非主动断开的情况下才尝试重连
  314. if (!this.isManualDisconnect && this.reconnectCount < this.maxReconnectCount) {
  315. setTimeout(() => {
  316. this.reconnectWebSocket();
  317. }, 3000);
  318. }
  319. });
  320. // 监听WebSocket错误
  321. this.socket.onError((error) => {
  322. console.error('WebSocket连接错误:', error);
  323. this.isConnected = false;
  324. this.handleConnectionError();
  325. });
  326. } catch (error) {
  327. console.error('创建WebSocket连接异常:', error);
  328. this.handleConnectionError();
  329. }
  330. },
  331. // 构建WebSocket子协议参数(模拟POST body传输)
  332. buildWebSocketProtocols() {
  333. try {
  334. const userId = parseInt(this.currentUser.userId); //客户登录帐户的id(不是 customId,是customer登录帐户的userId)
  335. if (isNaN(userId) || userId <= 0) {
  336. this.$msg("当前用户的id出错");
  337. return;
  338. }
  339. const customId = parseInt(this.currentUser.customId);
  340. if (isNaN(customId) || customId <= 0) {
  341. this.$msg("当前用户的customId出错");
  342. return;
  343. }
  344. const shopId = parseInt(this.currentUser.shopId);
  345. if (isNaN(shopId) || shopId <= 0) {
  346. this.$msg("当前用户的shopId出错");
  347. return;
  348. }
  349. const staffId = parseInt(this.currentUser.staffId);
  350. if (isNaN(staffId) || staffId <= 0) {
  351. this.$msg("当前用户的staffId出错");
  352. return;
  353. }
  354. // 创建房间参数对象
  355. const roomParams = {
  356. u_id: userId, //不是 customId,是customer的: userId
  357. c_id: parseInt(this.currentUser.customId),
  358. s_id: parseInt(this.currentUser.shopId),
  359. u_name: this.currentUser.username && this.currentUser.username.length > 30 ? this.currentUser.username.substring(0, 30) : this.currentUser.username,
  360. platform: this.getPlatformInfo(),
  361. type: 'shop', // 客户端类型:shop:门店端, customer:客户端
  362. staff_id: staffId, //门店员工id
  363. };
  364. // 分段传输(如果参数过长)
  365. const segments = this.createSegmentedProtocols(roomParams);
  366. if (segments.length == 0) {
  367. this.$msg('分段传输失败');
  368. return;
  369. }
  370. // 根据调试选项和长度选择合适的方案
  371. let protocols;
  372. if (segments.length > 0) {
  373. protocols = ['chat', ...segments];
  374. }
  375. // 验证协议字符串的合法性
  376. const isValid = this.validateProtocols(protocols);
  377. console.log('协议验证结果:', isValid);
  378. if (!isValid) {
  379. this.$msg('协议验证失败');
  380. return;
  381. }
  382. return protocols;
  383. } catch (error) {
  384. console.error('构建WebSocket protocols失败:', error);
  385. return ['chat']; // 返回基础协议作为fallback
  386. }
  387. },
  388. // 获取平台信息
  389. getPlatformInfo() {
  390. // #ifdef MP-WEIXIN
  391. return 'MP-WEIXIN';
  392. // #endif
  393. // #ifdef APP-PLUS
  394. return 'APP-PLUS';
  395. // #endif
  396. // #ifdef MP-ALIPAY
  397. return 'MP-ALIPAY';
  398. // #endif
  399. // #ifdef H5
  400. return 'H5';
  401. // #endif
  402. return 'UNKNOWN';
  403. },
  404. // Base64编码方法(跨平台兼容)
  405. base64Encode(str) {
  406. // #ifdef H5
  407. // H5环境使用浏览器原生方法
  408. try {
  409. return btoa(str);
  410. } catch (error) {
  411. console.error('H5 Base64编码失败:', error);
  412. return this.customBase64Encode(str);
  413. }
  414. // #endif
  415. // #ifdef MP-WEIXIN || MP-ALIPAY || APP-PLUS
  416. // 小程序和App环境使用自定义编码
  417. return this.customBase64Encode(str);
  418. // #endif
  419. },
  420. // 自定义Base64编码实现
  421. customBase64Encode(str) {
  422. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
  423. let output = '';
  424. let chr1, chr2, chr3, enc1, enc2, enc3, enc4;
  425. let i = 0;
  426. // 转换为UTF-8字节
  427. str = this.utf8Encode(str);
  428. while (i < str.length) {
  429. chr1 = str.charCodeAt(i++);
  430. chr2 = str.charCodeAt(i++);
  431. chr3 = str.charCodeAt(i++);
  432. enc1 = chr1 >> 2;
  433. enc2 = ((chr1 & 3) << 4) | (chr2 >> 4);
  434. enc3 = ((chr2 & 15) << 2) | (chr3 >> 6);
  435. enc4 = chr3 & 63;
  436. if (isNaN(chr2)) {
  437. enc3 = enc4 = 64;
  438. } else if (isNaN(chr3)) {
  439. enc4 = 64;
  440. }
  441. output = output + chars.charAt(enc1) + chars.charAt(enc2) + chars.charAt(enc3) + chars.charAt(enc4);
  442. }
  443. return output;
  444. },
  445. // UTF-8编码
  446. utf8Encode(str) {
  447. str = str.replace(/\r\n/g, '\n');
  448. let utftext = '';
  449. for (let n = 0; n < str.length; n++) {
  450. const c = str.charCodeAt(n);
  451. if (c < 128) {
  452. utftext += String.fromCharCode(c);
  453. } else if (c > 127 && c < 2048) {
  454. utftext += String.fromCharCode((c >> 6) | 192);
  455. utftext += String.fromCharCode((c & 63) | 128);
  456. } else {
  457. utftext += String.fromCharCode((c >> 12) | 224);
  458. utftext += String.fromCharCode(((c >> 6) & 63) | 128);
  459. utftext += String.fromCharCode((c & 63) | 128);
  460. }
  461. }
  462. return utftext;
  463. },
  464. // URL安全的Base64编码
  465. urlSafeBase64Encode(str) {
  466. // 先进行标准Base64编码
  467. const base64 = this.base64Encode(str);
  468. // 转换为URL安全格式:替换 +/= 字符
  469. return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
  470. },
  471. // 创建分段协议(用于长参数)- 每次取两个键值对,将参数分成多个小块
  472. createSegmentedProtocols(roomParams) {
  473. const segments = [];
  474. const MAX_SEGMENT_LENGTH = 64; // 每段最大长度
  475. try {
  476. // 将参数分成多个小段
  477. const paramEntries = Object.entries(roomParams);
  478. const chunks = [];
  479. // 每次取两个键值对,将参数分成多个小块
  480. for (let i = 0; i < paramEntries.length; i += 2) {
  481. const chunk = Object.fromEntries(paramEntries.slice(i, i + 2));
  482. chunks.push(chunk);
  483. }
  484. chunks.forEach((chunk, index) => {
  485. const chunkStr = JSON.stringify(chunk);
  486. const encoded = this.urlSafeBase64Encode(chunkStr);
  487. if (encoded.length <= MAX_SEGMENT_LENGTH) {
  488. segments.push(`p${index}-${encoded}`);
  489. }
  490. });
  491. return segments;
  492. } catch (error) {
  493. console.error('创建分段协议失败:', error);
  494. return [];
  495. }
  496. },
  497. // 验证协议字符串的合法性
  498. validateProtocols(protocols) {
  499. const MAX_SEGMENT_LENGTH = 64 + 30; // WebSocket 子协议名称按照 RFC 6455 规范,建议长度不超过 64 字符
  500. if (!protocols || !Array.isArray(protocols)) {
  501. return false;
  502. }
  503. for (const protocol of protocols) {
  504. // 检查协议名称是否符合规范(RFC 6455)
  505. // 只允许字母、数字、连字符、下划线、点
  506. if (!/^[a-zA-Z0-9\-._]+$/.test(protocol)) {
  507. console.error('协议包含非法字符:', protocol);
  508. return false;
  509. }
  510. // 检查长度限制
  511. if (protocol.length > MAX_SEGMENT_LENGTH) {
  512. console.error('协议长度超限:', protocol.length);
  513. return false;
  514. }
  515. console.log('protocal -- ', protocol, protocol.length)
  516. }
  517. return true;
  518. },
  519. // 断开WebSocket连接
  520. disconnectWebSocket() {
  521. if (this.socket) {
  522. this.isManualDisconnect = true; // 标记为主动断开
  523. this.socket.close();
  524. this.socket = null;
  525. this.isConnected = false;
  526. }
  527. },
  528. // 重连WebSocket
  529. reconnectWebSocket() {
  530. if (this.reconnectCount < this.maxReconnectCount) {
  531. this.reconnectCount++;
  532. console.log(`正在尝试第${this.reconnectCount}次重连...`);
  533. // uni.showToast({
  534. // title: `重连中(${this.reconnectCount}/${this.maxReconnectCount})`,
  535. // icon: 'loading',
  536. // duration: 1500
  537. // });
  538. this.connectWebSocket();
  539. } else {
  540. uni.showToast({
  541. title: '连接失败,请检查网络',
  542. icon: 'none',
  543. duration: 3000
  544. });
  545. }
  546. },
  547. // 处理 WebSocket 消息
  548. handleWebSocketMessage(data) {
  549. console.log('---------- data: ', data);
  550. try {
  551. // 尝试解析JSON,如果失败则认为是纯文本消息
  552. let messageData;
  553. try {
  554. const temp = JSON.parse(data);
  555. const msg = temp.message ? temp.message : temp; // TODO 测试兼容
  556. messageData = JSON.parse(msg);
  557. } catch (parseError) {
  558. // 如果不是JSON格式,可能是纯文本消息,暂时忽略或创建简单消息对象
  559. console.log('收到非JSON格式消息:', data);
  560. try {
  561. messageData = JSON.parse(data);
  562. } catch (error) {
  563. console.error('解析WebSocket消息失败:', error, data);
  564. }
  565. }
  566. let isMine = false;
  567. // 检查是否是本店但是他人发送的消息
  568. const messageShopId = parseInt(messageData.shopId);
  569. const currentShopId = parseInt(this.currentUser.shopId);
  570. const isShopMessage = messageShopId === currentShopId && messageShopId > 0;
  571. if (isShopMessage) {
  572. isMine = true;
  573. }
  574. // 创建消息对象
  575. const newMessage = {
  576. id: 'msg_receive_' + Math.floor(Date.now() / 1000) + '_' + Math.floor(Math.random() * 100000) + 1,
  577. isMine: isMine, // 标记是否是本店发送的消息
  578. message: messageData.message || messageData || '',
  579. username: messageData.name || messageData.username || '未知用户',
  580. avatar: messageData.avatar || this.$constant.imgUrl + '/retail/default-img.png',
  581. time: this.getCurrentTime(),
  582. };
  583. console.log('收到 ---------- newMessage: ', newMessage);
  584. // 添加到消息列表
  585. this.messageList.push(newMessage);
  586. // 滚动到底部
  587. this.$nextTick(() => {
  588. this.scrollToBottom(); // 用户操作时使用动画
  589. });
  590. } catch (error) {
  591. console.error('解析WebSocket消息失败:', error, data);
  592. }
  593. },
  594. // 发送消息
  595. sendMessage() {
  596. if (!this.inputText.trim()) {
  597. return;
  598. }
  599. if (!this.isConnected) {
  600. uni.showToast({
  601. title: 'WebSocket未连接',
  602. icon: 'none'
  603. });
  604. return;
  605. }
  606. const messageContent = this.inputText.trim();
  607. const avatar = this.currentUser.avatar || this.$constant.imgUrl + '/retail/default-img.png';
  608. const timestamp = Math.floor(Date.now() / 1000);
  609. try {
  610. // 立即在本地显示发送的消息(右侧)
  611. const myMessage = {
  612. id: 'msg_local_' + timestamp + '_' + Math.floor(Math.random() * 100000) + 1,
  613. isMine: true,
  614. message: messageContent,
  615. username: this.currentUser.username,
  616. avatar: avatar,
  617. time: this.getCurrentTime(),
  618. };
  619. // 添加到消息列表
  620. this.messageList.push(myMessage);
  621. // 清空输入框
  622. this.inputText = '';
  623. // 重新聚焦
  624. this.$nextTick(() => {
  625. this.inputFocus = true
  626. })
  627. // 滚动到底部
  628. // this.$nextTick(() => {
  629. // this.scrollToBottom(); // 用户操作时使用动画
  630. // });
  631. // 构建要发送的消息数据
  632. const messageData = {
  633. receiver: this.currentUser.customId,
  634. message: messageContent,
  635. username: this.currentUser.username,
  636. avatar: avatar,
  637. shopId: this.currentUser.shopId, // 发送者:门店使用shopId,客户使用customId
  638. timestamp: timestamp
  639. };
  640. const json = JSON.stringify(messageData);
  641. console.log('send ---------- json: ', json);
  642. // 发送到WebSocket服务器
  643. this.socket.send({
  644. data: json,
  645. success: () => {
  646. console.log('消息发送成功');
  647. },
  648. fail: (error) => {
  649. console.error('消息发送失败:', error);
  650. uni.showToast({
  651. title: '发送失败',
  652. icon: 'none'
  653. });
  654. // 发送失败时,可以考虑移除刚添加的消息或标记为失败
  655. }
  656. });
  657. } catch (error) {
  658. console.error('发送消息异常:', error);
  659. uni.showToast({
  660. title: '发送异常',
  661. icon: 'none'
  662. });
  663. }
  664. },
  665. // 处理连接错误
  666. handleConnectionError() {
  667. uni.showToast({
  668. title: '连接失败',
  669. icon: 'none',
  670. duration: 2000
  671. });
  672. },
  673. // 滚动到底部
  674. scrollToBottom(enableAnimation = true) {
  675. this.scrollWithAnimation = enableAnimation;
  676. if (this.messageList.length > 0) {
  677. this.scrollIntoView = "message-" + this.messageList[this.messageList.length - 1].id;
  678. }
  679. },
  680. // 获取当前时间
  681. getCurrentTime() {
  682. const now = new Date();
  683. const hours = String(now.getHours()).padStart(2, '0');
  684. const minutes = String(now.getMinutes()).padStart(2, '0');
  685. return `${hours}:${minutes}`;
  686. },
  687. // 输入框聚焦事件
  688. onInputFocus() {
  689. // console.log('输入框聚焦');
  690. // // 微信小程序中,聚焦时延迟滚动到底部
  691. // setTimeout(() => {
  692. // this.scrollToBottom();
  693. // }, 300);
  694. this.isInputFocused = true
  695. this.inputFocus = true
  696. this.hideExtraPanels() // 聚焦时隐藏表情和更多面板
  697. },
  698. // 输入框失焦事件
  699. onInputBlur() {
  700. // console.log('输入框失焦');
  701. this.isInputFocused = false
  702. this.inputFocus = false
  703. },
  704. // 输入内容变化事件
  705. onInputChange(event) {
  706. this.inputText = event.detail.value
  707. // 可以在这里处理输入内容的变化,比如限制行数等
  708. const value = event.detail.value;
  709. // 限制最大行数为5行,避免输入框过高
  710. const lines = value.split('\n');
  711. if (lines.length > 5) {
  712. // 截取前5行
  713. this.inputText = lines.slice(0, 5).join('\n');
  714. }
  715. },
  716. }
  717. };
  718. </script>
  719. <style lang="scss" scoped>
  720. .chat-container {
  721. height: 100vh;
  722. display: flex;
  723. flex-direction: column;
  724. background-color: #f5f5f5;
  725. }
  726. .connection-status {
  727. background-color: #ff9800;
  728. color: #ffffff;
  729. text-align: center;
  730. padding: 16upx;
  731. font-size: 24upx;
  732. .status-text {
  733. color: #ffffff;
  734. }
  735. }
  736. .message-list {
  737. flex: 1;
  738. padding: 20upx;
  739. overflow-y: auto;
  740. }
  741. .load-more {
  742. text-align: center;
  743. padding: 20upx 0;
  744. .load-more-text {
  745. color: #007aff;
  746. font-size: 28upx;
  747. cursor: pointer;
  748. }
  749. }
  750. .message-item {
  751. margin-bottom: 30upx;
  752. &:last-child {
  753. margin-bottom: 0;
  754. }
  755. }
  756. .message-left {
  757. display: flex;
  758. justify-content: flex-start;
  759. align-items: flex-start;
  760. }
  761. .message-right {
  762. display: flex;
  763. justify-content: flex-end;
  764. align-items: flex-start;
  765. }
  766. .avatar-wrapper {
  767. flex-shrink: 0;
  768. margin: 0 20upx;
  769. }
  770. .avatar {
  771. width: 80upx;
  772. height: 80upx;
  773. border-radius: 50%;
  774. background-color: #e0e0e0;
  775. }
  776. .message-content-wrapper {
  777. max-width: 520upx;
  778. min-width: 100upx;
  779. }
  780. .username {
  781. font-size: 24upx;
  782. color: $fontColor3;
  783. margin-bottom: 8upx;
  784. &.align-right {
  785. text-align: right;
  786. }
  787. }
  788. .message-bubble {
  789. padding: 20upx 24upx;
  790. border-radius: 16upx;
  791. position: relative;
  792. word-wrap: break-word;
  793. word-break: break-all;
  794. &.left-bubble {
  795. background-color: #ffffff;
  796. margin-left: 0;
  797. border-top-left-radius: 8upx;
  798. &::before {
  799. content: '';
  800. position: absolute;
  801. left: -22upx;
  802. top: 30upx;
  803. width: 0;
  804. height: 0;
  805. border: 12upx solid transparent;
  806. border-right-color: #ffffff;
  807. z-index: 999999;
  808. }
  809. }
  810. &.right-bubble {
  811. background-color: rgb(27, 193, 66);
  812. margin-right: 0;
  813. border-top-right-radius: 8upx;
  814. &::before {
  815. content: '';
  816. position: absolute;
  817. right: -22upx;
  818. top: 30upx;
  819. width: 0;
  820. height: 0;
  821. border: 12upx solid transparent;
  822. border-left-color: rgb(27, 193, 66);
  823. z-index: 999999;
  824. }
  825. .message-text {
  826. color: #ffffff;
  827. }
  828. }
  829. }
  830. .message-text {
  831. font-size: 32upx;
  832. line-height: 1.4;
  833. color: $fontColorMain;
  834. }
  835. .message-time {
  836. font-size: 20upx;
  837. color: $fontColor3;
  838. margin-top: 8upx;
  839. &.align-right {
  840. text-align: right;
  841. }
  842. }
  843. .empty-state {
  844. display: flex;
  845. justify-content: center;
  846. align-items: center;
  847. height: 300upx;
  848. }
  849. .empty-text {
  850. font-size: 32upx;
  851. color: $fontColor3;
  852. }
  853. .input-area {
  854. background-color: #ffffff;
  855. padding: 26upx;
  856. // margin-bottom: 20upx;
  857. border-top: 1upx solid $borderColor;
  858. // 适配安全区域和键盘高度(通过内联样式动态设置)
  859. transition: padding-bottom 0.3s ease;
  860. }
  861. .input-wrapper {
  862. display: flex;
  863. align-items: center;
  864. background-color: #f8f8f8;
  865. border-radius: 40upx;
  866. padding: 14upx 18upx 14upx 18upx;
  867. margin-bottom: 30upx;
  868. }
  869. .message-input {
  870. flex: 1;
  871. font-size: 32upx;
  872. border: none;
  873. background-color: transparent;
  874. outline: none;
  875. min-height: 28upx;
  876. max-height: 140upx;
  877. line-height: 1.4;
  878. word-wrap: break-word;
  879. word-break: break-all;
  880. resize: none;
  881. &::placeholder {
  882. color: #ccc;
  883. }
  884. }
  885. .send-button {
  886. margin-left: 20upx;
  887. padding: 10upx 30upx;
  888. background-color: $fontColor4;
  889. color: #ffffff;
  890. border: 1px solid #c9c9c9;
  891. border-radius: 30upx;
  892. font-size: 28upx;
  893. transition: all 0.3s ease;
  894. &.send-active {
  895. background-color: $mainColor;
  896. }
  897. &:disabled {
  898. background-color: $fontColor4;
  899. color: #ffffff;
  900. }
  901. &::after {
  902. border: none;
  903. }
  904. }
  905. // 适配不同设备
  906. /* #ifdef H5 */
  907. .chat-container {
  908. height: calc(100vh - 44px); // 减去导航栏高度
  909. }
  910. /* #endif */
  911. /* #ifdef MP-WEIXIN */
  912. .input-area {
  913. // 微信小程序底部安全区域
  914. padding-bottom: calc(20upx + env(safe-area-inset-bottom));
  915. }
  916. /* #endif */
  917. /* #ifdef APP-PLUS */
  918. .input-area {
  919. // App端底部安全区域
  920. padding-bottom: calc(20upx + env(safe-area-inset-bottom));
  921. }
  922. /* #endif */
  923. </style>