expert-chat.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654
  1. <template>
  2. <view class="chat-container">
  3. <!-- 聊天消息列表 -->
  4. <scroll-view
  5. class="message-list"
  6. scroll-y
  7. :scroll-top="scrollTop"
  8. :scroll-with-animation="true"
  9. @scroll="onScroll"
  10. >
  11. <view class="message-wrapper">
  12. <!-- 日期分隔符 -->
  13. <view class="date-divider" v-if="messageList.length > 0">
  14. <text class="date-text">{{ getCurrentDate() }}</text>
  15. </view>
  16. <!-- 消息列表 -->
  17. <view
  18. class="message-item"
  19. :class="{ 'message-right': message.type === 'user', 'message-left': message.type === 'expert' }"
  20. v-for="(message, index) in messageList"
  21. :key="index"
  22. >
  23. <!-- 专家消息(左侧) -->
  24. <template v-if="message.type === 'expert'">
  25. <view class="avatar-container">
  26. <image class="message-avatar" :src="expertInfo.avatar" mode="aspectFill"></image>
  27. </view>
  28. <view class="message-content-wrapper">
  29. <view class="message-bubble expert-bubble">
  30. <!-- 文本消息 -->
  31. <text class="message-text" v-if="message.contentType === 'text'">{{ message.content }}</text>
  32. <!-- 图片消息 -->
  33. <image
  34. v-if="message.contentType === 'image'"
  35. class="message-image"
  36. :src="message.content"
  37. mode="aspectFit"
  38. @click="previewImage(message.content)"
  39. ></image>
  40. </view>
  41. <view class="message-time">{{ formatTime(message.timestamp) }}</view>
  42. </view>
  43. </template>
  44. <!-- 用户消息(右侧) -->
  45. <template v-else>
  46. <view class="message-content-wrapper user-content">
  47. <view class="message-bubble user-bubble">
  48. <!-- 文本消息 -->
  49. <text class="message-text" v-if="message.contentType === 'text'">{{ message.content }}</text>
  50. <!-- 图片消息 -->
  51. <image
  52. v-if="message.contentType === 'image'"
  53. class="message-image"
  54. :src="message.content"
  55. mode="aspectFit"
  56. @click="previewImage(message.content)"
  57. ></image>
  58. </view>
  59. <view class="message-time user-time">{{ formatTime(message.timestamp) }}</view>
  60. </view>
  61. <view class="avatar-container">
  62. <image class="message-avatar" src="/static/icons/user icon.png" mode="aspectFill"></image>
  63. </view>
  64. </template>
  65. </view>
  66. <!-- 打字中状态 -->
  67. <view class="typing-indicator" v-if="isTyping">
  68. <view class="avatar-container">
  69. <image class="message-avatar" :src="expertInfo.avatar" mode="aspectFill"></image>
  70. </view>
  71. <view class="typing-bubble">
  72. <view class="typing-dots">
  73. <view class="dot"></view>
  74. <view class="dot"></view>
  75. <view class="dot"></view>
  76. </view>
  77. </view>
  78. </view>
  79. </view>
  80. </scroll-view>
  81. <!-- 输入区域 -->
  82. <view class="input-area">
  83. <!-- 图片预览 -->
  84. <view class="image-preview" v-if="selectedImages.length > 0">
  85. <view class="preview-list">
  86. <view
  87. class="preview-item"
  88. v-for="(image, index) in selectedImages"
  89. :key="index"
  90. >
  91. <image class="preview-image" :src="image" mode="aspectFill"></image>
  92. <view class="remove-btn" @click="removeImage(index)">
  93. <text class="remove-icon">×</text>
  94. </view>
  95. </view>
  96. </view>
  97. </view>
  98. <!-- 输入工具栏 -->
  99. <view class="input-toolbar">
  100. <!-- 添加图片按钮 -->
  101. <view class="tool-btn" @click="chooseImage">
  102. <image class="tool-icon" src="/static/icons/camera.png" mode="aspectFit"></image>
  103. </view>
  104. <!-- 输入框 -->
  105. <view class="input-wrapper">
  106. <textarea
  107. class="message-input"
  108. v-model="inputMessage"
  109. placeholder="请输入您的问题..."
  110. placeholder-style="color: #999;"
  111. :auto-height="true"
  112. :maxlength="500"
  113. @focus="onInputFocus"
  114. @blur="onInputBlur"
  115. ></textarea>
  116. </view>
  117. <!-- 发送按钮 -->
  118. <view
  119. class="send-btn"
  120. :class="{ active: canSend }"
  121. @click="sendMessage"
  122. >
  123. <text class="send-text">发送</text>
  124. </view>
  125. </view>
  126. </view>
  127. </view>
  128. </template>
  129. <script>
  130. export default {
  131. data() {
  132. return {
  133. expertId: '',
  134. expertName: '',
  135. inputMessage: '',
  136. selectedImages: [],
  137. scrollTop: 0,
  138. isTyping: false,
  139. expertInfo: {
  140. id: '',
  141. name: '',
  142. avatar: '/static/icons/professor.png'
  143. },
  144. // 消息列表
  145. messageList: [
  146. {
  147. id: 1,
  148. type: 'expert',
  149. contentType: 'text',
  150. content: '您好!我是张教授,很高兴为您提供农业技术咨询服务。请问您遇到了什么问题?',
  151. timestamp: Date.now() - 3600000
  152. },
  153. {
  154. id: 2,
  155. type: 'user',
  156. contentType: 'text',
  157. content: '教授您好,我想咨询一下水稻种植的相关问题',
  158. timestamp: Date.now() - 3500000
  159. },
  160. {
  161. id: 3,
  162. type: 'expert',
  163. contentType: 'text',
  164. content: '好的,请您详细描述一下具体是什么问题?比如是关于播种、施肥、病虫害防治还是其他方面的?',
  165. timestamp: Date.now() - 3400000
  166. }
  167. ]
  168. }
  169. },
  170. computed: {
  171. canSend() {
  172. return this.inputMessage.trim().length > 0 || this.selectedImages.length > 0;
  173. }
  174. },
  175. onLoad(options) {
  176. if (options.expertId) {
  177. this.expertId = options.expertId;
  178. this.expertName = decodeURIComponent(options.expertName || '专家');
  179. this.loadExpertInfo();
  180. }
  181. // 设置页面标题
  182. uni.setNavigationBarTitle({
  183. title: this.expertName
  184. });
  185. // 滚动到底部
  186. this.$nextTick(() => {
  187. this.scrollToBottom();
  188. });
  189. },
  190. methods: {
  191. // 加载专家信息
  192. loadExpertInfo() {
  193. this.expertInfo = {
  194. id: this.expertId,
  195. name: this.expertName,
  196. avatar: '/static/icons/professor.png'
  197. };
  198. },
  199. // 发送消息
  200. sendMessage() {
  201. if (!this.canSend) return;
  202. // 发送文本消息
  203. if (this.inputMessage.trim()) {
  204. const textMessage = {
  205. id: Date.now(),
  206. type: 'user',
  207. contentType: 'text',
  208. content: this.inputMessage.trim(),
  209. timestamp: Date.now()
  210. };
  211. this.messageList.push(textMessage);
  212. this.inputMessage = '';
  213. }
  214. // 发送图片消息
  215. if (this.selectedImages.length > 0) {
  216. this.selectedImages.forEach(image => {
  217. const imageMessage = {
  218. id: Date.now() + Math.random(),
  219. type: 'user',
  220. contentType: 'image',
  221. content: image,
  222. timestamp: Date.now()
  223. };
  224. this.messageList.push(imageMessage);
  225. });
  226. this.selectedImages = [];
  227. }
  228. // 滚动到底部
  229. this.$nextTick(() => {
  230. this.scrollToBottom();
  231. });
  232. // 模拟专家回复
  233. this.simulateExpertReply();
  234. },
  235. // 模拟专家回复
  236. simulateExpertReply() {
  237. this.isTyping = true;
  238. setTimeout(() => {
  239. this.isTyping = false;
  240. const replies = [
  241. '根据您的描述,我建议您可以从以下几个方面来处理...',
  242. '这个问题比较常见,通常是由于以下原因造成的...',
  243. '从您提供的信息来看,建议您先检查一下土壤情况...',
  244. '我理解您的担心,这种情况下最好的解决方案是...'
  245. ];
  246. const randomReply = replies[Math.floor(Math.random() * replies.length)];
  247. const expertMessage = {
  248. id: Date.now(),
  249. type: 'expert',
  250. contentType: 'text',
  251. content: randomReply,
  252. timestamp: Date.now()
  253. };
  254. this.messageList.push(expertMessage);
  255. this.$nextTick(() => {
  256. this.scrollToBottom();
  257. });
  258. }, 2000);
  259. },
  260. // 选择图片
  261. chooseImage() {
  262. const maxImages = 3 - this.selectedImages.length;
  263. if (maxImages <= 0) {
  264. uni.showToast({
  265. title: '最多可选择3张图片',
  266. icon: 'none'
  267. });
  268. return;
  269. }
  270. uni.chooseImage({
  271. count: maxImages,
  272. sizeType: ['compressed'],
  273. sourceType: ['album', 'camera'],
  274. success: (res) => {
  275. this.selectedImages = this.selectedImages.concat(res.tempFilePaths);
  276. }
  277. });
  278. },
  279. // 移除图片
  280. removeImage(index) {
  281. this.selectedImages.splice(index, 1);
  282. },
  283. // 预览图片
  284. previewImage(current) {
  285. // 收集所有图片消息的URL
  286. const imageUrls = this.messageList
  287. .filter(msg => msg.contentType === 'image')
  288. .map(msg => msg.content);
  289. uni.previewImage({
  290. current: current,
  291. urls: imageUrls
  292. });
  293. },
  294. // 滚动到底部
  295. scrollToBottom() {
  296. const query = uni.createSelectorQuery().in(this);
  297. query.select('.message-wrapper').boundingClientRect();
  298. query.exec((res) => {
  299. if (res[0]) {
  300. this.scrollTop = res[0].height;
  301. }
  302. });
  303. },
  304. // 滚动事件
  305. onScroll(e) {
  306. // 可以在这里处理滚动相关逻辑
  307. },
  308. // 输入框聚焦
  309. onInputFocus() {
  310. setTimeout(() => {
  311. this.scrollToBottom();
  312. }, 300);
  313. },
  314. // 输入框失焦
  315. onInputBlur() {
  316. // 可以在这里处理失焦逻辑
  317. },
  318. // 格式化时间
  319. formatTime(timestamp) {
  320. const date = new Date(timestamp);
  321. const now = new Date();
  322. const diff = now - date;
  323. if (diff < 60000) { // 1分钟内
  324. return '刚刚';
  325. } else if (diff < 3600000) { // 1小时内
  326. return `${Math.floor(diff / 60000)}分钟前`;
  327. } else if (date.toDateString() === now.toDateString()) { // 今天
  328. return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' });
  329. } else {
  330. return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
  331. }
  332. },
  333. // 获取当前日期
  334. getCurrentDate() {
  335. return new Date().toLocaleDateString('zh-CN', {
  336. year: 'numeric',
  337. month: 'long',
  338. day: 'numeric'
  339. });
  340. }
  341. }
  342. }
  343. </script>
  344. <style lang="scss">
  345. .chat-container {
  346. height: 100vh;
  347. display: flex;
  348. flex-direction: column;
  349. background-color: #f5f5f5;
  350. }
  351. // 消息列表
  352. .message-list {
  353. flex: 1;
  354. padding: 20rpx 0;
  355. }
  356. .message-wrapper {
  357. padding: 0 20rpx;
  358. }
  359. // 日期分隔符
  360. .date-divider {
  361. text-align: center;
  362. margin: 20rpx 0;
  363. }
  364. .date-text {
  365. padding: 8rpx 20rpx;
  366. background-color: rgba(0, 0, 0, 0.1);
  367. color: #666;
  368. font-size: 22rpx;
  369. border-radius: 16rpx;
  370. }
  371. // 消息项
  372. .message-item {
  373. display: flex;
  374. margin-bottom: 20rpx;
  375. &.message-left {
  376. justify-content: flex-start;
  377. }
  378. &.message-right {
  379. justify-content: flex-end;
  380. }
  381. }
  382. .avatar-container {
  383. flex-shrink: 0;
  384. margin: 0 16rpx;
  385. }
  386. .message-avatar {
  387. width: 80rpx;
  388. height: 80rpx;
  389. border-radius: 40rpx;
  390. border: 2rpx solid #f0f0f0;
  391. }
  392. // 消息内容
  393. .message-content-wrapper {
  394. max-width: 500rpx;
  395. &.user-content {
  396. display: flex;
  397. flex-direction: column;
  398. align-items: flex-end;
  399. }
  400. }
  401. .message-bubble {
  402. padding: 16rpx 20rpx;
  403. border-radius: 20rpx;
  404. margin-bottom: 8rpx;
  405. word-wrap: break-word;
  406. &.expert-bubble {
  407. background-color: #fff;
  408. border-top-left-radius: 8rpx;
  409. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
  410. }
  411. &.user-bubble {
  412. background-color: #4CAF50;
  413. border-top-right-radius: 8rpx;
  414. .message-text {
  415. color: #fff;
  416. }
  417. }
  418. }
  419. .message-text {
  420. font-size: 28rpx;
  421. line-height: 1.4;
  422. color: #333;
  423. }
  424. .message-image {
  425. max-width: 300rpx;
  426. max-height: 300rpx;
  427. border-radius: 12rpx;
  428. }
  429. .message-time {
  430. font-size: 22rpx;
  431. color: #999;
  432. &.user-time {
  433. text-align: right;
  434. }
  435. }
  436. // 打字中指示器
  437. .typing-indicator {
  438. display: flex;
  439. align-items: center;
  440. margin-bottom: 20rpx;
  441. }
  442. .typing-bubble {
  443. background-color: #fff;
  444. padding: 16rpx 20rpx;
  445. border-radius: 20rpx;
  446. border-top-left-radius: 8rpx;
  447. margin-left: 16rpx;
  448. box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
  449. }
  450. .typing-dots {
  451. display: flex;
  452. gap: 6rpx;
  453. }
  454. .dot {
  455. width: 8rpx;
  456. height: 8rpx;
  457. background-color: #999;
  458. border-radius: 50%;
  459. animation: typing 1.4s infinite;
  460. &:nth-child(2) {
  461. animation-delay: 0.2s;
  462. }
  463. &:nth-child(3) {
  464. animation-delay: 0.4s;
  465. }
  466. }
  467. @keyframes typing {
  468. 0%, 60%, 100% {
  469. transform: translateY(0);
  470. opacity: 0.5;
  471. }
  472. 30% {
  473. transform: translateY(-10rpx);
  474. opacity: 1;
  475. }
  476. }
  477. // 输入区域
  478. .input-area {
  479. background-color: #fff;
  480. border-top: 1rpx solid #e5e5e5;
  481. padding-bottom: env(safe-area-inset-bottom);
  482. }
  483. // 图片预览
  484. .image-preview {
  485. padding: 20rpx;
  486. border-bottom: 1rpx solid #f0f0f0;
  487. }
  488. .preview-list {
  489. display: flex;
  490. gap: 16rpx;
  491. }
  492. .preview-item {
  493. position: relative;
  494. width: 120rpx;
  495. height: 120rpx;
  496. }
  497. .preview-image {
  498. width: 100%;
  499. height: 100%;
  500. border-radius: 12rpx;
  501. }
  502. .remove-btn {
  503. position: absolute;
  504. top: -8rpx;
  505. right: -8rpx;
  506. width: 32rpx;
  507. height: 32rpx;
  508. background-color: #ff4757;
  509. border-radius: 16rpx;
  510. display: flex;
  511. align-items: center;
  512. justify-content: center;
  513. }
  514. .remove-icon {
  515. color: #fff;
  516. font-size: 20rpx;
  517. font-weight: bold;
  518. }
  519. // 输入工具栏
  520. .input-toolbar {
  521. display: flex;
  522. align-items: flex-end;
  523. padding: 20rpx;
  524. gap: 16rpx;
  525. }
  526. .tool-btn {
  527. width: 60rpx;
  528. height: 60rpx;
  529. display: flex;
  530. align-items: center;
  531. justify-content: center;
  532. background-color: #f5f5f5;
  533. border-radius: 30rpx;
  534. }
  535. .tool-icon {
  536. width: 32rpx;
  537. height: 32rpx;
  538. }
  539. .input-wrapper {
  540. flex: 1;
  541. background-color: #f8f8f8;
  542. border-radius: 24rpx;
  543. padding: 16rpx 20rpx;
  544. min-height: 48rpx;
  545. max-height: 200rpx;
  546. }
  547. .message-input {
  548. width: 100%;
  549. font-size: 28rpx;
  550. line-height: 1.4;
  551. min-height: 48rpx;
  552. }
  553. .send-btn {
  554. padding: 16rpx 24rpx;
  555. background-color: #e0e0e0;
  556. border-radius: 24rpx;
  557. transition: background-color 0.2s ease;
  558. &.active {
  559. background-color: #4CAF50;
  560. .send-text {
  561. color: #fff;
  562. }
  563. }
  564. }
  565. .send-text {
  566. font-size: 28rpx;
  567. color: #999;
  568. font-weight: bold;
  569. }
  570. </style>