西藏巴青项目

index.vue 30KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114
  1. <template>
  2. <!-- 问诊详情:见 doc/app/在线问诊/在线问诊接口说明.md -->
  3. <view :key="layoutKey" :class="pageRootClass" class="cd-page">
  4. <component
  5. :is="feedScrollerTag"
  6. ref="feedScroll"
  7. id="cd-feed"
  8. class="cd-feed"
  9. :class="{ 'cd-feed--h5': useNativeFeedScroll }"
  10. scroll-y
  11. enable-back-to-top
  12. :scroll-top="feedScrollTop"
  13. :scroll-into-view="scrollAnchor"
  14. scroll-into-view-alignment="end"
  15. scroll-with-animation
  16. :upper-threshold="80"
  17. @scrolltoupper="onScrollToUpper"
  18. @scroll="onFeedScroll"
  19. >
  20. <view
  21. id="cd-feed-inner"
  22. class="cd-feed__inner"
  23. :class="{ 'cd-feed__inner--has-preview': !!pendingMedia }"
  24. >
  25. <view v-if="loadingOlder" class="feed-hint">
  26. <text class="text-body feed-hint__txt">{{ $t('consultDetailPage.loadingMore') }}</text>
  27. </view>
  28. <view v-if="noMoreOlder && messages.length" class="feed-hint">
  29. <text class="text-body feed-hint__txt">{{ $t('consultDetailPage.noMoreHistory') }}</text>
  30. </view>
  31. <view v-if="messagesLoading && !messages.length" class="feed-hint">
  32. <text class="text-body feed-hint__txt">{{ $t('consultDetailPage.loading') }}</text>
  33. </view>
  34. <block v-if="messageGroups.length">
  35. <block v-for="(group, gi) in messageGroups" :key="'g-' + gi">
  36. <view class="chat-date">
  37. <text class="text-body chat-date__txt">{{ group.dateLabel }}</text>
  38. </view>
  39. <view
  40. v-for="m in group.items"
  41. :key="m.id"
  42. :id="'cd-msg-' + m.id"
  43. class="cd-bubble-row"
  44. :class="isUserMessage(m) ? 'cd-bubble-row--user' : 'cd-bubble-row--bot'"
  45. >
  46. <view v-if="!isUserMessage(m)" class="cd-avatar cd-avatar--vet">
  47. <up-icon name="account-fill" color="#2563eb" :size="18" />
  48. </view>
  49. <view
  50. class="cd-bubble"
  51. :class="[
  52. isUserMessage(m) ? 'cd-bubble--user' : 'cd-bubble--bot',
  53. m.msgType === 2 ? 'cd-bubble--image' : ''
  54. ]"
  55. >
  56. <text v-if="!isUserMessage(m)" class="text-body cd-bubble__who">{{ peerName }}</text>
  57. <text v-else class="text-body cd-bubble__who">{{ $t('consultDetailPage.userBubble') }}</text>
  58. <text v-if="m.msgType === 1" class="text-body cd-bubble__txt">{{ m.content }}</text>
  59. <image
  60. v-else-if="m.msgType === 2"
  61. class="cd-bubble__img"
  62. :src="mediaUrl(m.content)"
  63. mode="widthFix"
  64. @load="onBubbleMediaLoad"
  65. @click="previewImage(m.content)"
  66. />
  67. <video
  68. v-else-if="m.msgType === 3"
  69. class="cd-bubble__video"
  70. :src="mediaUrl(m.content)"
  71. controls
  72. object-fit="contain"
  73. />
  74. <view
  75. v-else-if="m.msgType === 4"
  76. class="voice-msg"
  77. :class="{ 'voice-msg--on': playingVoiceId === m.id }"
  78. role="button"
  79. @click="toggleVoice(m)"
  80. >
  81. <text class="text-body voice-msg__dur">{{ formatVoiceDuration(m.mediaDuration) }}</text>
  82. <text class="voice-msg__label">{{ $t('consultDetailPage.msgVoice') }}</text>
  83. </view>
  84. <text v-else class="text-body cd-bubble__txt">{{ m.content }}</text>
  85. <text class="text-body cd-bubble__time">{{ formatMsgTime(m.sendTime) }}</text>
  86. </view>
  87. <view v-if="isUserMessage(m)" class="cd-avatar cd-avatar--user">
  88. <up-icon name="account-fill" color="#16a34a" :size="18" />
  89. </view>
  90. </view>
  91. </block>
  92. </block>
  93. <view v-else-if="sessionReady && !messagesLoading" class="feed-empty">
  94. <text class="text-body feed-empty__txt">{{ $t('consultDetailPage.emptyChat') }}</text>
  95. </view>
  96. <view id="cd-msg-tail" class="cd-feed__tail" />
  97. </view>
  98. </component>
  99. <view v-if="sessionReady" class="cd-composer">
  100. <view v-if="pendingMedia" class="cd-preview">
  101. <image
  102. v-if="pendingMedia.kind === 'image'"
  103. class="cd-preview__img"
  104. :src="pendingMedia.path"
  105. mode="widthFix"
  106. />
  107. <view v-else class="cd-preview__video-wrap">
  108. <image
  109. v-if="pendingMedia.thumb"
  110. class="cd-preview__img"
  111. :src="pendingMedia.thumb"
  112. mode="aspectFill"
  113. />
  114. <view v-else class="cd-preview__video-ph">
  115. <up-icon name="play-right" color="#ffffff" :size="28" />
  116. </view>
  117. <text class="cd-preview__tag text-body">{{ $t('consultDetailPage.videoTag') }}</text>
  118. </view>
  119. <view class="cd-preview__mask" role="button" @click="clearPendingMedia">
  120. <up-icon name="close" color="#ffffff" :size="14" />
  121. </view>
  122. </view>
  123. <view class="cd-composer-row">
  124. <!-- <view
  125. class="cd-mic"
  126. :class="{ 'cd-mic--rec': isRecording }"
  127. role="button"
  128. @touchstart.stop.prevent="onMicTouchStart"
  129. @touchend.stop.prevent="onMicTouchEnd"
  130. @touchcancel.stop.prevent="onMicTouchEnd"
  131. >
  132. <up-icon name="mic" :color="isRecording ? '#EF4444' : '#22C55E'" :size="26" />
  133. </view> -->
  134. <view class="cd-input-shell">
  135. <textarea
  136. v-model="draft"
  137. class="cd-textarea"
  138. :placeholder="$t('consultDetailPage.inputPlaceholder')"
  139. placeholder-class="cd-textarea-ph"
  140. maxlength="2000"
  141. :disabled="sending"
  142. :cursor-spacing="16"
  143. :show-confirm-bar="false"
  144. :fixed="true"
  145. auto-height
  146. />
  147. </view>
  148. <view class="cd-icon-btn" role="button" @click="onAttachTap">
  149. <up-icon name="photo" color="#22C55E" :size="26" />
  150. </view>
  151. <up-button
  152. type="primary"
  153. size="small"
  154. :text="sending ? $t('consultDetailPage.uploading') : $t('consultDetailPage.send')"
  155. :disabled="sendDisabled"
  156. :custom-style="sendBtnStyle"
  157. @click="send"
  158. />
  159. </view>
  160. </view>
  161. </view>
  162. </template>
  163. <script>
  164. import UButton from 'uview-plus/components/u-button/u-button.vue'
  165. import UIcon from 'uview-plus/components/u-icon/u-icon.vue'
  166. import tabPage from '@/mixins/tabPage'
  167. import { ensureApiToken } from '@/utils/apiAuth'
  168. import { uploadFile } from '@/utils/upload'
  169. import {
  170. MSG_TYPE_TEXT,
  171. MSG_TYPE_IMAGE,
  172. MSG_TYPE_VIDEO,
  173. MSG_TYPE_VOICE,
  174. MESSAGE_PAGE_SIZE,
  175. isUserMessage,
  176. mediaUrl,
  177. normalizeMessage,
  178. formatMsgTime,
  179. formatVoiceDuration,
  180. buildMessageGroups
  181. } from '@/utils/aiConsult'
  182. import { MEDIA_RULES, extOf } from '@/utils/aiLlmChat'
  183. import {
  184. openOnlineConsultSession,
  185. listOnlineConsultMessages,
  186. sendOnlineConsultMessage
  187. } from '@/api/onlineConsult'
  188. const POLL_INTERVAL_MS = 5000
  189. export default {
  190. components: {
  191. 'up-button': UButton,
  192. 'up-icon': UIcon
  193. },
  194. mixins: [tabPage],
  195. data() {
  196. return {
  197. navTitleKey: 'consultDetailPage.navTitle',
  198. sessionId: '',
  199. sessionReady: false,
  200. vetResourceId: '',
  201. vetN: 1,
  202. vetName: '',
  203. vetSub: '',
  204. vetIntro: '',
  205. avatarText: '',
  206. draft: '',
  207. messages: [],
  208. pendingMedia: null,
  209. messagesLoading: false,
  210. loadingOlder: false,
  211. noMoreOlder: false,
  212. sending: false,
  213. scrollAnchor: '',
  214. feedScrollTop: 0,
  215. scrollTopNonce: 0,
  216. scrollBottomTimer: null,
  217. pollTimer: null,
  218. useNativeFeedScroll: false,
  219. playingVoiceId: null,
  220. innerAudio: null,
  221. isRecording: false,
  222. micLongPressTimer: null,
  223. recordStartAt: 0,
  224. recorder: null,
  225. sendBtnStyle:
  226. 'max-width:64rpx;min-height:64rpx;height:auto;padding:12rpx 20rpx;display:flex;align-items:center;justify-content:center;border-radius:12rpx;'
  227. }
  228. },
  229. computed: {
  230. feedScrollerTag() {
  231. return this.useNativeFeedScroll ? 'view' : 'scroll-view'
  232. },
  233. peerName() {
  234. if (this.vetName) {
  235. return this.vetName
  236. }
  237. return this.$t('consultDetailPage.vetNameTpl', { n: this.vetN })
  238. },
  239. messageGroups() {
  240. return buildMessageGroups(this.messages, (k, p) => this.$t(k, p))
  241. },
  242. sendDisabled() {
  243. const t = (this.draft || '').trim()
  244. return this.sending || !this.sessionId || (!t && !this.pendingMedia)
  245. }
  246. },
  247. onLoad(query) {
  248. // #ifdef H5
  249. this.useNativeFeedScroll = true
  250. // #endif
  251. if (!ensureApiToken()) return
  252. const q = query || {}
  253. this.sessionId = this.decodeQuery(q, 'sessionId')
  254. this.vetResourceId = this.decodeQuery(q, 'vetResourceId') || this.decodeQuery(q, 'id')
  255. const n = parseInt(q.n, 10)
  256. this.vetN = Number.isFinite(n) && n > 0 ? n : 1
  257. this.vetName = this.decodeQuery(q, 'name')
  258. this.vetSub = this.decodeQuery(q, 'sub')
  259. this.vetIntro = this.decodeQuery(q, 'intro')
  260. this.avatarText = this.decodeQuery(q, 'avatar')
  261. if (this.sessionId) {
  262. this.sessionReady = true
  263. this.loadMessages(false)
  264. this.startPoll()
  265. return
  266. }
  267. if (this.vetResourceId) {
  268. this.openSession()
  269. return
  270. }
  271. uni.showToast({ title: this.$t('consultDetailPage.openSessionFail'), icon: 'none' })
  272. setTimeout(() => uni.navigateBack(), 1500)
  273. },
  274. onShow() {
  275. const p = uni.setNavigationBarTitle({
  276. title: this.peerName || this.$t(this.navTitleKey)
  277. })
  278. if (p && typeof p.catch === 'function') {
  279. p.catch(() => {})
  280. }
  281. if (this.sessionReady) {
  282. this.pollNewMessages()
  283. this.startPoll()
  284. if (this.messages.length && !this.messagesLoading && !this.loadingOlder) {
  285. this.scheduleScrollToBottom()
  286. }
  287. }
  288. },
  289. onHide() {
  290. this.stopPoll()
  291. },
  292. mounted() {
  293. if (typeof uni.getRecorderManager === 'function') {
  294. const rm = uni.getRecorderManager()
  295. rm.onStop((res) => this.onRecordStop(res))
  296. rm.onError(() => {
  297. this.isRecording = false
  298. uni.showToast({ title: this.$t('consultDetailPage.recordFail'), icon: 'none' })
  299. })
  300. this.recorder = rm
  301. }
  302. },
  303. onUnload() {
  304. this.stopPoll()
  305. if (this.scrollBottomTimer) {
  306. clearTimeout(this.scrollBottomTimer)
  307. this.scrollBottomTimer = null
  308. }
  309. if (this.micLongPressTimer) {
  310. clearTimeout(this.micLongPressTimer)
  311. this.micLongPressTimer = null
  312. }
  313. if (this.isRecording && this.recorder) {
  314. try {
  315. this.recorder.stop()
  316. } catch (e) {
  317. /* noop */
  318. }
  319. }
  320. this.stopVoice()
  321. },
  322. methods: {
  323. isUserMessage,
  324. mediaUrl,
  325. formatMsgTime,
  326. formatVoiceDuration,
  327. decodeQuery(q, key) {
  328. const raw = q && q[key]
  329. if (raw == null || raw === '') {
  330. return ''
  331. }
  332. try {
  333. return decodeURIComponent(String(raw))
  334. } catch (e) {
  335. return String(raw)
  336. }
  337. },
  338. openSession() {
  339. uni.showLoading({ title: this.$t('consultDetailPage.loading'), mask: true })
  340. const vetId = Number(this.vetResourceId) || this.vetResourceId
  341. openOnlineConsultSession({ vetResourceId: vetId })
  342. .then((res) => {
  343. const session = res.data || {}
  344. const sid = session.id != null ? String(session.id) : ''
  345. if (!sid) {
  346. uni.showToast({ title: this.$t('consultDetailPage.openSessionFail'), icon: 'none' })
  347. setTimeout(() => uni.navigateBack(), 1500)
  348. return
  349. }
  350. this.sessionId = sid
  351. if (session.receiverName && !this.vetName) {
  352. this.vetName = session.receiverName
  353. }
  354. this.sessionReady = true
  355. this.loadMessages(false)
  356. this.startPoll()
  357. })
  358. .catch(() => {
  359. uni.showToast({ title: this.$t('consultDetailPage.openSessionFail'), icon: 'none' })
  360. setTimeout(() => uni.navigateBack(), 1500)
  361. })
  362. .finally(() => {
  363. uni.hideLoading()
  364. })
  365. },
  366. loadMessages(older) {
  367. if (!this.sessionId) return
  368. const beforeId = older && this.messages.length ? this.messages[0].id : undefined
  369. if (older) {
  370. this.loadingOlder = true
  371. } else {
  372. this.messagesLoading = true
  373. }
  374. listOnlineConsultMessages(this.sessionId, {
  375. beforeId,
  376. pageSize: MESSAGE_PAGE_SIZE
  377. })
  378. .then((res) => {
  379. const batch = (res.data || []).map((m) => normalizeMessage(m))
  380. if (older) {
  381. if (!batch.length) {
  382. this.noMoreOlder = true
  383. } else {
  384. const feedEl = this.useNativeFeedScroll ? this.getFeedScrollEl() : null
  385. const prevScrollHeight = feedEl ? feedEl.scrollHeight : 0
  386. const prevScrollTop = feedEl ? feedEl.scrollTop : 0
  387. this.messages = batch.concat(this.messages)
  388. if (batch.length < MESSAGE_PAGE_SIZE) {
  389. this.noMoreOlder = true
  390. }
  391. if (feedEl && prevScrollHeight > 0) {
  392. this.$nextTick(() => {
  393. feedEl.scrollTop = feedEl.scrollHeight - prevScrollHeight + prevScrollTop
  394. })
  395. }
  396. }
  397. } else {
  398. this.messages = batch
  399. this.noMoreOlder = batch.length > 0 && batch.length < MESSAGE_PAGE_SIZE
  400. this.scheduleScrollToBottom()
  401. }
  402. })
  403. .finally(() => {
  404. this.messagesLoading = false
  405. this.loadingOlder = false
  406. if (!older) {
  407. this.scheduleScrollToBottom()
  408. }
  409. })
  410. },
  411. pollNewMessages() {
  412. if (!this.sessionId || this.messagesLoading || this.loadingOlder || this.sending) return
  413. listOnlineConsultMessages(this.sessionId, { pageSize: MESSAGE_PAGE_SIZE })
  414. .then((res) => {
  415. const batch = (res.data || []).map((m) => normalizeMessage(m))
  416. if (!batch.length) return
  417. const lastId = this.messages.length ? this.messages[this.messages.length - 1].id : 0
  418. const newer = batch.filter((m) => m.id && m.id > lastId)
  419. if (!newer.length) return
  420. this.messages = this.messages.concat(newer)
  421. this.scheduleScrollToBottom(false)
  422. })
  423. .catch(() => {
  424. /* silent poll */
  425. })
  426. },
  427. startPoll() {
  428. this.stopPoll()
  429. this.pollTimer = setInterval(() => this.pollNewMessages(), POLL_INTERVAL_MS)
  430. },
  431. stopPoll() {
  432. if (this.pollTimer) {
  433. clearInterval(this.pollTimer)
  434. this.pollTimer = null
  435. }
  436. },
  437. onScrollToUpper() {
  438. if (this.loadingOlder || this.noMoreOlder || !this.messages.length) return
  439. this.loadMessages(true)
  440. },
  441. onFeedScroll(e) {
  442. if (!this.useNativeFeedScroll) return
  443. const el = (e && e.target) || this.getFeedScrollEl()
  444. if (!el || el.scrollTop > 40) return
  445. this.onScrollToUpper()
  446. },
  447. getFeedScrollEl() {
  448. const ref = this.$refs.feedScroll
  449. if (!ref) return null
  450. if (ref.$el) return ref.$el
  451. return ref
  452. },
  453. scrollToBottomNative() {
  454. const el = this.getFeedScrollEl()
  455. if (!el) {
  456. return false
  457. }
  458. const tail = el.querySelector('#cd-msg-tail')
  459. if (tail && typeof tail.scrollIntoView === 'function') {
  460. tail.scrollIntoView({ block: 'end', inline: 'nearest', behavior: 'auto' })
  461. }
  462. if (typeof el.scrollHeight === 'number') {
  463. const top = Math.max(0, el.scrollHeight - el.clientHeight)
  464. el.scrollTop = top
  465. }
  466. return true
  467. },
  468. onBubbleMediaLoad() {
  469. this.scheduleScrollToBottom(false)
  470. },
  471. scheduleScrollToBottom(immediate = true) {
  472. if (this.scrollBottomTimer) {
  473. clearTimeout(this.scrollBottomTimer)
  474. this.scrollBottomTimer = null
  475. }
  476. const run = () => this.scrollToBottom()
  477. if (immediate) {
  478. run()
  479. }
  480. ;[80, 200, 450, 800, 1200].forEach((ms) => {
  481. setTimeout(run, ms)
  482. })
  483. },
  484. scrollByAnchor() {
  485. const last = this.messages[this.messages.length - 1]
  486. const anchor = last && last.id != null ? 'cd-msg-' + last.id : 'cd-msg-tail'
  487. this.scrollAnchor = ''
  488. this.$nextTick(() => {
  489. this.scrollAnchor = anchor
  490. })
  491. },
  492. scrollToBottom() {
  493. this.$nextTick(() => {
  494. if (this.useNativeFeedScroll) {
  495. const run = () => this.scrollToBottomNative()
  496. run()
  497. if (typeof requestAnimationFrame === 'function') {
  498. requestAnimationFrame(run)
  499. }
  500. return
  501. }
  502. uni.createSelectorQuery()
  503. .in(this)
  504. .select('#cd-feed-inner')
  505. .boundingClientRect()
  506. .select('#cd-feed')
  507. .boundingClientRect()
  508. .exec((res) => {
  509. const inner = res && res[0]
  510. const viewport = res && res[1]
  511. if (!inner || !viewport || inner.height <= 0 || viewport.height <= 0) {
  512. this.scrollByAnchor()
  513. return
  514. }
  515. const maxTop = Math.max(0, Math.ceil(inner.height - viewport.height))
  516. if (maxTop <= 0) {
  517. this.feedScrollTop = 0
  518. this.scrollByAnchor()
  519. return
  520. }
  521. const nextTop = maxTop + this.scrollTopNonce
  522. this.scrollTopNonce = this.scrollTopNonce ? 0 : 1
  523. if (this.feedScrollTop === nextTop) {
  524. this.feedScrollTop = 0
  525. this.$nextTick(() => {
  526. this.feedScrollTop = nextTop
  527. })
  528. } else {
  529. this.feedScrollTop = nextTop
  530. }
  531. setTimeout(() => this.scrollByAnchor(), 50)
  532. })
  533. })
  534. },
  535. clearPendingMedia() {
  536. this.pendingMedia = null
  537. },
  538. onAttachTap() {
  539. uni.showActionSheet({
  540. itemList: [this.$t('consultDetailPage.pickImage'), this.$t('consultDetailPage.pickVideo')],
  541. success: (res) => {
  542. if (res.tapIndex === 0) this.chooseImage()
  543. else if (res.tapIndex === 1) this.chooseVideo()
  544. }
  545. })
  546. },
  547. validateMediaFile(filePath, fileName, kind) {
  548. const rule = MEDIA_RULES[kind]
  549. const ext = extOf(fileName || filePath)
  550. if (!rule.exts.includes(ext)) {
  551. uni.showToast({ title: this.$t('aiOnlineConsult.' + rule.errFmt), icon: 'none' })
  552. return false
  553. }
  554. if ((fileName || filePath).includes(',')) {
  555. uni.showToast({ title: this.$t('aiOnlineConsult.errComma'), icon: 'none' })
  556. return false
  557. }
  558. return true
  559. },
  560. checkFileSize(filePath, kind) {
  561. const rule = MEDIA_RULES[kind]
  562. return new Promise((resolve) => {
  563. uni.getFileInfo({
  564. filePath,
  565. success: (res) => {
  566. if (res.size / 1024 / 1024 >= rule.maxMb) {
  567. uni.showToast({ title: this.$t('aiOnlineConsult.' + rule.errMb), icon: 'none' })
  568. resolve(false)
  569. return
  570. }
  571. resolve(true)
  572. },
  573. fail: () => resolve(true)
  574. })
  575. })
  576. },
  577. chooseImage() {
  578. uni.chooseImage({
  579. count: 1,
  580. sizeType: ['compressed'],
  581. sourceType: ['album', 'camera'],
  582. success: async (res) => {
  583. const path = res.tempFilePaths && res.tempFilePaths[0]
  584. if (!path) return
  585. const name = (res.tempFiles && res.tempFiles[0] && res.tempFiles[0].name) || path
  586. if (!this.validateMediaFile(path, name, 'image')) return
  587. if (!(await this.checkFileSize(path, 'image'))) return
  588. this.pendingMedia = { kind: 'image', path, msgType: MSG_TYPE_IMAGE }
  589. },
  590. fail: () => {
  591. uni.showToast({ title: this.$t('consultDetailPage.imagePickFail'), icon: 'none' })
  592. }
  593. })
  594. },
  595. chooseVideo() {
  596. uni.chooseVideo({
  597. sourceType: ['album', 'camera'],
  598. maxDuration: 120,
  599. compressed: true,
  600. success: async (res) => {
  601. const path = res.tempFilePath || ''
  602. if (!path) return
  603. if (!this.validateMediaFile(path, path, 'video')) return
  604. if (!(await this.checkFileSize(path, 'video'))) return
  605. this.pendingMedia = {
  606. kind: 'video',
  607. path,
  608. thumb: res.thumbTempFilePath || '',
  609. msgType: MSG_TYPE_VIDEO,
  610. mediaDuration: res.duration ? Math.round(res.duration) : undefined
  611. }
  612. },
  613. fail: () => {
  614. uni.showToast({ title: this.$t('consultDetailPage.videoPickFail'), icon: 'none' })
  615. }
  616. })
  617. },
  618. onMicTouchStart() {
  619. if (this.micLongPressTimer) {
  620. clearTimeout(this.micLongPressTimer)
  621. this.micLongPressTimer = null
  622. }
  623. this.micLongPressTimer = setTimeout(() => {
  624. this.micLongPressTimer = null
  625. this.startRecord()
  626. }, 480)
  627. },
  628. onMicTouchEnd() {
  629. if (this.micLongPressTimer) {
  630. clearTimeout(this.micLongPressTimer)
  631. this.micLongPressTimer = null
  632. return
  633. }
  634. if (this.isRecording) {
  635. this.stopRecord()
  636. }
  637. },
  638. startRecord() {
  639. if (!this.recorder) {
  640. uni.showToast({ title: this.$t('consultDetailPage.recordUnsupported'), icon: 'none' })
  641. return
  642. }
  643. this.isRecording = true
  644. this.recordStartAt = Date.now()
  645. try {
  646. this.recorder.start({
  647. duration: 60000,
  648. sampleRate: 16000,
  649. numberOfChannels: 1,
  650. encodeBitRate: 96000,
  651. format: 'mp3'
  652. })
  653. } catch (e) {
  654. this.isRecording = false
  655. uni.showToast({ title: this.$t('consultDetailPage.recordFail'), icon: 'none' })
  656. }
  657. },
  658. stopRecord() {
  659. if (!this.recorder || !this.isRecording) return
  660. try {
  661. this.recorder.stop()
  662. } catch (e) {
  663. this.isRecording = false
  664. }
  665. },
  666. async onRecordStop(res) {
  667. this.isRecording = false
  668. const ms = Date.now() - (this.recordStartAt || 0)
  669. if (ms < 500 || !res || !res.tempFilePath) return
  670. const duration = Math.max(1, Math.round(ms / 1000))
  671. await this.sendVoiceImmediately(res.tempFilePath, duration)
  672. },
  673. async sendVoiceImmediately(filePath, mediaDuration) {
  674. if (!this.sessionId || this.sending) return
  675. uni.showLoading({ title: this.$t('consultDetailPage.uploading'), mask: true })
  676. try {
  677. const url = await uploadFile(filePath)
  678. await this.sendMessage({
  679. msgType: MSG_TYPE_VOICE,
  680. content: url,
  681. mediaDuration
  682. })
  683. } catch (e) {
  684. uni.showToast({ title: this.$t('consultDetailPage.sendFail'), icon: 'none' })
  685. } finally {
  686. uni.hideLoading()
  687. }
  688. },
  689. async sendMessage(body) {
  690. const res = await sendOnlineConsultMessage(this.sessionId, body)
  691. const msg = normalizeMessage(res.data || {})
  692. if (msg.id) {
  693. const exists = this.messages.some((m) => m.id === msg.id)
  694. if (!exists) {
  695. this.messages.push(msg)
  696. }
  697. }
  698. this.scheduleScrollToBottom()
  699. },
  700. async send() {
  701. if (this.sendDisabled) return
  702. console.log(111)
  703. const text = (this.draft || '').trim()
  704. const pending = this.pendingMedia
  705. this.sending = true
  706. try {
  707. if (text) {
  708. await this.sendMessage({ msgType: MSG_TYPE_TEXT, content: text })
  709. this.draft = ''
  710. }
  711. if (pending) {
  712. uni.showLoading({ title: this.$t('consultDetailPage.uploading'), mask: true })
  713. const url = await uploadFile(pending.path)
  714. await this.sendMessage({
  715. msgType: pending.msgType,
  716. content: url,
  717. mediaDuration: pending.mediaDuration
  718. })
  719. this.pendingMedia = null
  720. }
  721. } catch (e) {
  722. uni.showToast({ title: this.$t('consultDetailPage.sendFail'), icon: 'none' })
  723. } finally {
  724. this.sending = false
  725. uni.hideLoading()
  726. this.scheduleScrollToBottom()
  727. }
  728. },
  729. previewImage(path) {
  730. const url = mediaUrl(path)
  731. if (url) {
  732. uni.previewImage({ urls: [url] })
  733. }
  734. },
  735. stopVoice() {
  736. if (this.innerAudio) {
  737. this.innerAudio.stop()
  738. this.innerAudio.destroy()
  739. this.innerAudio = null
  740. }
  741. this.playingVoiceId = null
  742. },
  743. toggleVoice(m) {
  744. if (!m || !m.content) return
  745. if (this.playingVoiceId === m.id) {
  746. this.stopVoice()
  747. return
  748. }
  749. this.stopVoice()
  750. const audio = uni.createInnerAudioContext()
  751. audio.src = mediaUrl(m.content)
  752. audio.onEnded(() => this.stopVoice())
  753. audio.onError(() => {
  754. uni.showToast({ title: this.$t('consultDetailPage.voicePlayFail'), icon: 'none' })
  755. this.stopVoice()
  756. })
  757. this.innerAudio = audio
  758. this.playingVoiceId = m.id
  759. audio.play()
  760. }
  761. }
  762. }
  763. </script>
  764. <style lang="scss" scoped>
  765. @import '@/styles/morandi.scss';
  766. @import '@/styles/tab-page.scss';
  767. .cd-page {
  768. display: flex;
  769. flex-direction: column;
  770. min-width: 0;
  771. min-height: 100vh;
  772. height: 100%;
  773. box-sizing: border-box;
  774. background: $morandi-bg-page;
  775. }
  776. .cd-feed {
  777. flex: 1;
  778. min-height: 0;
  779. height: 0;
  780. min-width: 0;
  781. box-sizing: border-box;
  782. background: $morandi-feed;
  783. }
  784. .cd-feed--h5 {
  785. overflow-x: hidden;
  786. overflow-y: auto;
  787. overscroll-behavior: contain;
  788. -webkit-overflow-scrolling: touch;
  789. }
  790. .cd-feed__inner {
  791. display: flex;
  792. flex-direction: column;
  793. gap: 20rpx;
  794. min-width: 0;
  795. padding: 16rpx 16rpx 24rpx;
  796. padding-bottom: calc(220rpx + env(safe-area-inset-bottom));
  797. box-sizing: border-box;
  798. }
  799. .cd-feed__inner--has-preview {
  800. padding-bottom: calc(340rpx + env(safe-area-inset-bottom));
  801. }
  802. .cd-feed__tail {
  803. height: 1px;
  804. width: 100%;
  805. }
  806. .feed-hint,
  807. .feed-empty {
  808. padding: 16rpx 0;
  809. display: flex;
  810. justify-content: center;
  811. }
  812. .feed-hint__txt,
  813. .feed-empty__txt {
  814. font-size: 24rpx;
  815. color: $morandi-text-muted;
  816. }
  817. .chat-date {
  818. display: flex;
  819. justify-content: center;
  820. padding: 8rpx 0;
  821. }
  822. .chat-date__txt {
  823. font-size: 22rpx;
  824. color: $morandi-text-muted;
  825. padding: 4rpx 16rpx;
  826. border-radius: 8rpx;
  827. background: rgba(255, 255, 255, 0.6);
  828. }
  829. .cd-bubble-row {
  830. display: flex;
  831. align-items: flex-start;
  832. gap: 12rpx;
  833. width: 100%;
  834. min-width: 0;
  835. }
  836. .cd-bubble-row--bot {
  837. justify-content: flex-start;
  838. }
  839. .cd-bubble-row--user {
  840. justify-content: flex-end;
  841. }
  842. .cd-avatar {
  843. flex-shrink: 0;
  844. width: 56rpx;
  845. height: 56rpx;
  846. border-radius: 50%;
  847. display: flex;
  848. align-items: center;
  849. justify-content: center;
  850. }
  851. .cd-avatar--vet {
  852. background: rgba(37, 99, 235, 0.12);
  853. }
  854. .cd-avatar--user {
  855. background: rgba(34, 197, 94, 0.22);
  856. }
  857. .cd-bubble {
  858. display: flex;
  859. flex-direction: column;
  860. min-width: 0;
  861. max-width: 72%;
  862. padding: 16rpx 18rpx;
  863. border-radius: 12rpx;
  864. gap: 10rpx;
  865. box-sizing: border-box;
  866. }
  867. .cd-bubble--bot {
  868. background: $morandi-bg-card-inner;
  869. border: 1rpx solid $morandi-border-soft;
  870. box-shadow: 0 2rpx 8rpx rgba(74, 69, 66, 0.05);
  871. }
  872. .cd-bubble--user {
  873. background: #8ef09e;
  874. border: 1rpx solid $morandi-border-soft;
  875. box-shadow: 0 2rpx 6rpx rgba(74, 69, 66, 0.05);
  876. }
  877. .cd-bubble--image {
  878. max-width: 85%;
  879. }
  880. .cd-bubble__who {
  881. font-size: 22rpx;
  882. font-weight: 600;
  883. color: $morandi-text-muted;
  884. }
  885. .cd-bubble--user .cd-bubble__who {
  886. color: #166534;
  887. }
  888. .cd-bubble__time {
  889. font-size: 20rpx;
  890. color: $morandi-text-muted;
  891. align-self: flex-end;
  892. }
  893. .cd-bubble__img {
  894. display: block;
  895. width: 200rpx;
  896. max-width: 100%;
  897. height: auto;
  898. border-radius: 8rpx;
  899. overflow: hidden;
  900. }
  901. .cd-bubble__video {
  902. width: 100%;
  903. max-width: 420rpx;
  904. max-height: 360rpx;
  905. border-radius: 8rpx;
  906. background: #000;
  907. }
  908. .cd-bubble__txt {
  909. font-size: 28rpx;
  910. line-height: 1.5;
  911. word-break: break-word;
  912. overflow-wrap: anywhere;
  913. color: $morandi-text;
  914. }
  915. .voice-msg {
  916. display: flex;
  917. align-items: center;
  918. gap: 12rpx;
  919. padding: 8rpx 12rpx;
  920. border-radius: 8rpx;
  921. background: rgba(34, 197, 94, 0.12);
  922. }
  923. .voice-msg--on {
  924. background: rgba(34, 197, 94, 0.22);
  925. }
  926. .voice-msg__dur {
  927. font-size: 26rpx;
  928. font-weight: 600;
  929. color: #166534;
  930. }
  931. .voice-msg__label {
  932. font-size: 24rpx;
  933. color: $morandi-text-muted;
  934. }
  935. .cd-composer {
  936. position: fixed;
  937. left: 0;
  938. right: 0;
  939. bottom: 0;
  940. z-index: 200;
  941. display: flex;
  942. flex-direction: column;
  943. gap: 12rpx;
  944. min-width: 0;
  945. padding: 12rpx 16rpx;
  946. padding-bottom: calc(12rpx + env(safe-area-inset-bottom));
  947. background: $morandi-composer;
  948. border-top: 1rpx solid $morandi-border-soft;
  949. box-shadow: 0 -8rpx 24rpx rgba(74, 69, 66, 0.06);
  950. }
  951. .cd-preview {
  952. position: relative;
  953. align-self: flex-start;
  954. width: 200rpx;
  955. border-radius: 12rpx;
  956. overflow: hidden;
  957. background: $morandi-bg-muted;
  958. }
  959. .cd-preview__img {
  960. display: block;
  961. width: 200rpx;
  962. max-width: 100%;
  963. height: auto;
  964. }
  965. .cd-preview__video-wrap {
  966. position: relative;
  967. width: 200rpx;
  968. height: 200rpx;
  969. }
  970. .cd-preview__video-ph {
  971. width: 100%;
  972. height: 100%;
  973. display: flex;
  974. align-items: center;
  975. justify-content: center;
  976. background: #374151;
  977. }
  978. .cd-preview__tag {
  979. position: absolute;
  980. left: 0;
  981. right: 0;
  982. bottom: 0;
  983. padding: 4rpx 8rpx;
  984. font-size: 20rpx;
  985. color: #ffffff;
  986. background: rgba(0, 0, 0, 0.45);
  987. text-align: center;
  988. }
  989. .cd-preview__mask {
  990. position: absolute;
  991. right: 0;
  992. top: 0;
  993. width: 44rpx;
  994. height: 44rpx;
  995. display: flex;
  996. align-items: center;
  997. justify-content: center;
  998. background: rgba(0, 0, 0, 0.45);
  999. border-bottom-left-radius: 12rpx;
  1000. }
  1001. .cd-composer-row {
  1002. display: flex;
  1003. flex-direction: row;
  1004. align-items: flex-end;
  1005. gap: 12rpx;
  1006. min-width: 0;
  1007. }
  1008. .cd-mic {
  1009. flex-shrink: 0;
  1010. width: 72rpx;
  1011. height: 72rpx;
  1012. display: flex;
  1013. align-items: center;
  1014. justify-content: center;
  1015. border-radius: 12rpx;
  1016. background: $morandi-bg-card-inner;
  1017. border: 1rpx solid $morandi-border-soft;
  1018. }
  1019. .cd-mic--rec {
  1020. background: #fef2f2;
  1021. border-color: #fecaca;
  1022. }
  1023. .cd-input-shell {
  1024. flex: 1;
  1025. min-width: 0;
  1026. min-height: 72rpx;
  1027. max-height: 200rpx;
  1028. padding: 10rpx 16rpx;
  1029. box-sizing: border-box;
  1030. background: $morandi-bg-card-inner;
  1031. border-radius: 12rpx;
  1032. border: 1rpx solid $morandi-border-soft;
  1033. }
  1034. .cd-textarea {
  1035. width: 100%;
  1036. min-height: 48rpx;
  1037. max-height: 180rpx;
  1038. font-size: 28rpx;
  1039. line-height: 1.45;
  1040. color: $morandi-text;
  1041. }
  1042. .cd-textarea-ph {
  1043. color: $morandi-text-muted;
  1044. font-size: 26rpx;
  1045. }
  1046. .cd-icon-btn {
  1047. flex-shrink: 0;
  1048. width: 72rpx;
  1049. height: 72rpx;
  1050. display: flex;
  1051. align-items: center;
  1052. justify-content: center;
  1053. border-radius: 12rpx;
  1054. background: $morandi-bg-card-inner;
  1055. border: 1rpx solid $morandi-border-soft;
  1056. }
  1057. </style>