expert-chat.vue 14 KB

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