西藏巴青项目

index.vue 31KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127
  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. // #ifndef H5
  294. this.initRecorderManager()
  295. // #endif
  296. },
  297. onUnload() {
  298. this.stopPoll()
  299. if (this.scrollBottomTimer) {
  300. clearTimeout(this.scrollBottomTimer)
  301. this.scrollBottomTimer = null
  302. }
  303. if (this.micLongPressTimer) {
  304. clearTimeout(this.micLongPressTimer)
  305. this.micLongPressTimer = null
  306. }
  307. if (this.isRecording && this.recorder) {
  308. try {
  309. this.recorder.stop()
  310. } catch (e) {
  311. /* noop */
  312. }
  313. }
  314. this.stopVoice()
  315. },
  316. methods: {
  317. isUserMessage,
  318. mediaUrl,
  319. formatMsgTime,
  320. formatVoiceDuration,
  321. decodeQuery(q, key) {
  322. const raw = q && q[key]
  323. if (raw == null || raw === '') {
  324. return ''
  325. }
  326. try {
  327. return decodeURIComponent(String(raw))
  328. } catch (e) {
  329. return String(raw)
  330. }
  331. },
  332. initRecorderManager() {
  333. // #ifdef H5
  334. return
  335. // #endif
  336. if (typeof uni.getRecorderManager !== 'function') {
  337. return
  338. }
  339. const rm = uni.getRecorderManager()
  340. if (!rm || typeof rm.onStop !== 'function') {
  341. return
  342. }
  343. rm.onStop((res) => this.onRecordStop(res))
  344. if (typeof rm.onError === 'function') {
  345. rm.onError(() => {
  346. this.isRecording = false
  347. uni.showToast({ title: this.$t('consultDetailPage.recordFail'), icon: 'none' })
  348. })
  349. }
  350. this.recorder = rm
  351. },
  352. openSession() {
  353. uni.showLoading({ title: this.$t('consultDetailPage.loading'), mask: true })
  354. const vetId = Number(this.vetResourceId) || this.vetResourceId
  355. openOnlineConsultSession({ vetResourceId: vetId })
  356. .then((res) => {
  357. const session = res.data || {}
  358. const sid = session.id != null ? String(session.id) : ''
  359. if (!sid) {
  360. uni.showToast({ title: this.$t('consultDetailPage.openSessionFail'), icon: 'none' })
  361. setTimeout(() => uni.navigateBack(), 1500)
  362. return
  363. }
  364. this.sessionId = sid
  365. if (session.receiverName && !this.vetName) {
  366. this.vetName = session.receiverName
  367. }
  368. this.sessionReady = true
  369. this.loadMessages(false)
  370. this.startPoll()
  371. })
  372. .catch(() => {
  373. uni.showToast({ title: this.$t('consultDetailPage.openSessionFail'), icon: 'none' })
  374. setTimeout(() => uni.navigateBack(), 1500)
  375. })
  376. .finally(() => {
  377. uni.hideLoading()
  378. })
  379. },
  380. loadMessages(older) {
  381. if (!this.sessionId) return
  382. const beforeId = older && this.messages.length ? this.messages[0].id : undefined
  383. if (older) {
  384. this.loadingOlder = true
  385. } else {
  386. this.messagesLoading = true
  387. }
  388. listOnlineConsultMessages(this.sessionId, {
  389. beforeId,
  390. pageSize: MESSAGE_PAGE_SIZE
  391. })
  392. .then((res) => {
  393. const batch = (res.data || []).map((m) => normalizeMessage(m))
  394. if (older) {
  395. if (!batch.length) {
  396. this.noMoreOlder = true
  397. } else {
  398. const feedEl = this.useNativeFeedScroll ? this.getFeedScrollEl() : null
  399. const prevScrollHeight = feedEl ? feedEl.scrollHeight : 0
  400. const prevScrollTop = feedEl ? feedEl.scrollTop : 0
  401. this.messages = batch.concat(this.messages)
  402. if (batch.length < MESSAGE_PAGE_SIZE) {
  403. this.noMoreOlder = true
  404. }
  405. if (feedEl && prevScrollHeight > 0) {
  406. this.$nextTick(() => {
  407. feedEl.scrollTop = feedEl.scrollHeight - prevScrollHeight + prevScrollTop
  408. })
  409. }
  410. }
  411. } else {
  412. this.messages = batch
  413. this.noMoreOlder = batch.length > 0 && batch.length < MESSAGE_PAGE_SIZE
  414. this.scheduleScrollToBottom()
  415. }
  416. })
  417. .finally(() => {
  418. this.messagesLoading = false
  419. this.loadingOlder = false
  420. if (!older) {
  421. this.scheduleScrollToBottom()
  422. }
  423. })
  424. },
  425. pollNewMessages() {
  426. if (!this.sessionId || this.messagesLoading || this.loadingOlder || this.sending) return
  427. listOnlineConsultMessages(this.sessionId, { pageSize: MESSAGE_PAGE_SIZE })
  428. .then((res) => {
  429. const batch = (res.data || []).map((m) => normalizeMessage(m))
  430. if (!batch.length) return
  431. const lastId = this.messages.length ? this.messages[this.messages.length - 1].id : 0
  432. const newer = batch.filter((m) => m.id && m.id > lastId)
  433. if (!newer.length) return
  434. this.messages = this.messages.concat(newer)
  435. this.scheduleScrollToBottom(false)
  436. })
  437. .catch(() => {
  438. /* silent poll */
  439. })
  440. },
  441. startPoll() {
  442. this.stopPoll()
  443. this.pollTimer = setInterval(() => this.pollNewMessages(), POLL_INTERVAL_MS)
  444. },
  445. stopPoll() {
  446. if (this.pollTimer) {
  447. clearInterval(this.pollTimer)
  448. this.pollTimer = null
  449. }
  450. },
  451. onScrollToUpper() {
  452. if (this.loadingOlder || this.noMoreOlder || !this.messages.length) return
  453. this.loadMessages(true)
  454. },
  455. onFeedScroll(e) {
  456. if (!this.useNativeFeedScroll) return
  457. const el = (e && e.target) || this.getFeedScrollEl()
  458. if (!el || el.scrollTop > 40) return
  459. this.onScrollToUpper()
  460. },
  461. getFeedScrollEl() {
  462. const ref = this.$refs.feedScroll
  463. if (!ref) return null
  464. if (ref.$el) return ref.$el
  465. return ref
  466. },
  467. scrollToBottomNative() {
  468. const el = this.getFeedScrollEl()
  469. if (!el) {
  470. return false
  471. }
  472. const tail = el.querySelector('#cd-msg-tail')
  473. if (tail && typeof tail.scrollIntoView === 'function') {
  474. tail.scrollIntoView({ block: 'end', inline: 'nearest', behavior: 'auto' })
  475. }
  476. if (typeof el.scrollHeight === 'number') {
  477. const top = Math.max(0, el.scrollHeight - el.clientHeight)
  478. el.scrollTop = top
  479. }
  480. return true
  481. },
  482. onBubbleMediaLoad() {
  483. this.scheduleScrollToBottom(false)
  484. },
  485. scheduleScrollToBottom(immediate = true) {
  486. if (this.scrollBottomTimer) {
  487. clearTimeout(this.scrollBottomTimer)
  488. this.scrollBottomTimer = null
  489. }
  490. const run = () => this.scrollToBottom()
  491. if (immediate) {
  492. run()
  493. }
  494. ;[80, 200, 450, 800, 1200].forEach((ms) => {
  495. setTimeout(run, ms)
  496. })
  497. },
  498. scrollByAnchor() {
  499. const last = this.messages[this.messages.length - 1]
  500. const anchor = last && last.id != null ? 'cd-msg-' + last.id : 'cd-msg-tail'
  501. this.scrollAnchor = ''
  502. this.$nextTick(() => {
  503. this.scrollAnchor = anchor
  504. })
  505. },
  506. scrollToBottom() {
  507. this.$nextTick(() => {
  508. if (this.useNativeFeedScroll) {
  509. const run = () => this.scrollToBottomNative()
  510. run()
  511. if (typeof requestAnimationFrame === 'function') {
  512. requestAnimationFrame(run)
  513. }
  514. return
  515. }
  516. uni.createSelectorQuery()
  517. .in(this)
  518. .select('#cd-feed-inner')
  519. .boundingClientRect()
  520. .select('#cd-feed')
  521. .boundingClientRect()
  522. .exec((res) => {
  523. const inner = res && res[0]
  524. const viewport = res && res[1]
  525. if (!inner || !viewport || inner.height <= 0 || viewport.height <= 0) {
  526. this.scrollByAnchor()
  527. return
  528. }
  529. const maxTop = Math.max(0, Math.ceil(inner.height - viewport.height))
  530. if (maxTop <= 0) {
  531. this.feedScrollTop = 0
  532. this.scrollByAnchor()
  533. return
  534. }
  535. const nextTop = maxTop + this.scrollTopNonce
  536. this.scrollTopNonce = this.scrollTopNonce ? 0 : 1
  537. if (this.feedScrollTop === nextTop) {
  538. this.feedScrollTop = 0
  539. this.$nextTick(() => {
  540. this.feedScrollTop = nextTop
  541. })
  542. } else {
  543. this.feedScrollTop = nextTop
  544. }
  545. setTimeout(() => this.scrollByAnchor(), 50)
  546. })
  547. })
  548. },
  549. clearPendingMedia() {
  550. this.pendingMedia = null
  551. },
  552. onAttachTap() {
  553. uni.showActionSheet({
  554. itemList: [this.$t('consultDetailPage.pickImage'), this.$t('consultDetailPage.pickVideo')],
  555. success: (res) => {
  556. if (res.tapIndex === 0) this.chooseImage()
  557. else if (res.tapIndex === 1) this.chooseVideo()
  558. }
  559. })
  560. },
  561. validateMediaFile(filePath, fileName, kind) {
  562. const rule = MEDIA_RULES[kind]
  563. const ext = extOf(fileName || filePath)
  564. if (!rule.exts.includes(ext)) {
  565. uni.showToast({ title: this.$t('aiOnlineConsult.' + rule.errFmt), icon: 'none' })
  566. return false
  567. }
  568. if ((fileName || filePath).includes(',')) {
  569. uni.showToast({ title: this.$t('aiOnlineConsult.errComma'), icon: 'none' })
  570. return false
  571. }
  572. return true
  573. },
  574. checkFileSize(filePath, kind) {
  575. const rule = MEDIA_RULES[kind]
  576. return new Promise((resolve) => {
  577. uni.getFileInfo({
  578. filePath,
  579. success: (res) => {
  580. if (res.size / 1024 / 1024 >= rule.maxMb) {
  581. uni.showToast({ title: this.$t('aiOnlineConsult.' + rule.errMb), icon: 'none' })
  582. resolve(false)
  583. return
  584. }
  585. resolve(true)
  586. },
  587. fail: () => resolve(true)
  588. })
  589. })
  590. },
  591. chooseImage() {
  592. uni.chooseImage({
  593. count: 1,
  594. sizeType: ['compressed'],
  595. sourceType: ['album', 'camera'],
  596. success: async (res) => {
  597. const path = res.tempFilePaths && res.tempFilePaths[0]
  598. if (!path) return
  599. const name = (res.tempFiles && res.tempFiles[0] && res.tempFiles[0].name) || path
  600. if (!this.validateMediaFile(path, name, 'image')) return
  601. if (!(await this.checkFileSize(path, 'image'))) return
  602. this.pendingMedia = { kind: 'image', path, msgType: MSG_TYPE_IMAGE }
  603. },
  604. fail: () => {
  605. uni.showToast({ title: this.$t('consultDetailPage.imagePickFail'), icon: 'none' })
  606. }
  607. })
  608. },
  609. chooseVideo() {
  610. uni.chooseVideo({
  611. sourceType: ['album', 'camera'],
  612. maxDuration: 120,
  613. compressed: true,
  614. success: async (res) => {
  615. const path = res.tempFilePath || ''
  616. if (!path) return
  617. if (!this.validateMediaFile(path, path, 'video')) return
  618. if (!(await this.checkFileSize(path, 'video'))) return
  619. this.pendingMedia = {
  620. kind: 'video',
  621. path,
  622. thumb: res.thumbTempFilePath || '',
  623. msgType: MSG_TYPE_VIDEO,
  624. mediaDuration: res.duration ? Math.round(res.duration) : undefined
  625. }
  626. },
  627. fail: () => {
  628. uni.showToast({ title: this.$t('consultDetailPage.videoPickFail'), icon: 'none' })
  629. }
  630. })
  631. },
  632. onMicTouchStart() {
  633. if (this.micLongPressTimer) {
  634. clearTimeout(this.micLongPressTimer)
  635. this.micLongPressTimer = null
  636. }
  637. this.micLongPressTimer = setTimeout(() => {
  638. this.micLongPressTimer = null
  639. this.startRecord()
  640. }, 480)
  641. },
  642. onMicTouchEnd() {
  643. if (this.micLongPressTimer) {
  644. clearTimeout(this.micLongPressTimer)
  645. this.micLongPressTimer = null
  646. return
  647. }
  648. if (this.isRecording) {
  649. this.stopRecord()
  650. }
  651. },
  652. startRecord() {
  653. if (!this.recorder) {
  654. uni.showToast({ title: this.$t('consultDetailPage.recordUnsupported'), icon: 'none' })
  655. return
  656. }
  657. this.isRecording = true
  658. this.recordStartAt = Date.now()
  659. try {
  660. this.recorder.start({
  661. duration: 60000,
  662. sampleRate: 16000,
  663. numberOfChannels: 1,
  664. encodeBitRate: 96000,
  665. format: 'mp3'
  666. })
  667. } catch (e) {
  668. this.isRecording = false
  669. uni.showToast({ title: this.$t('consultDetailPage.recordFail'), icon: 'none' })
  670. }
  671. },
  672. stopRecord() {
  673. if (!this.recorder || !this.isRecording) return
  674. try {
  675. this.recorder.stop()
  676. } catch (e) {
  677. this.isRecording = false
  678. }
  679. },
  680. async onRecordStop(res) {
  681. this.isRecording = false
  682. const ms = Date.now() - (this.recordStartAt || 0)
  683. if (ms < 500 || !res || !res.tempFilePath) return
  684. const duration = Math.max(1, Math.round(ms / 1000))
  685. await this.sendVoiceImmediately(res.tempFilePath, duration)
  686. },
  687. async sendVoiceImmediately(filePath, mediaDuration) {
  688. if (!this.sessionId || this.sending) return
  689. uni.showLoading({ title: this.$t('consultDetailPage.uploading'), mask: true })
  690. try {
  691. const url = await uploadFile(filePath)
  692. await this.sendMessage({
  693. msgType: MSG_TYPE_VOICE,
  694. content: url,
  695. mediaDuration
  696. })
  697. } catch (e) {
  698. uni.showToast({ title: this.$t('consultDetailPage.sendFail'), icon: 'none' })
  699. } finally {
  700. uni.hideLoading()
  701. }
  702. },
  703. async sendMessage(body) {
  704. const res = await sendOnlineConsultMessage(this.sessionId, body)
  705. const msg = normalizeMessage(res.data || {})
  706. if (msg.id) {
  707. const exists = this.messages.some((m) => m.id === msg.id)
  708. if (!exists) {
  709. this.messages.push(msg)
  710. }
  711. }
  712. this.scheduleScrollToBottom()
  713. },
  714. async send() {
  715. if (this.sendDisabled) return
  716. const text = (this.draft || '').trim()
  717. const pending = this.pendingMedia
  718. this.sending = true
  719. try {
  720. if (text) {
  721. await this.sendMessage({ msgType: MSG_TYPE_TEXT, content: text })
  722. this.draft = ''
  723. }
  724. if (pending) {
  725. uni.showLoading({ title: this.$t('consultDetailPage.uploading'), mask: true })
  726. const url = await uploadFile(pending.path)
  727. await this.sendMessage({
  728. msgType: pending.msgType,
  729. content: url,
  730. mediaDuration: pending.mediaDuration
  731. })
  732. this.pendingMedia = null
  733. }
  734. } catch (e) {
  735. uni.showToast({ title: this.$t('consultDetailPage.sendFail'), icon: 'none' })
  736. } finally {
  737. this.sending = false
  738. uni.hideLoading()
  739. this.scheduleScrollToBottom()
  740. }
  741. },
  742. previewImage(path) {
  743. const url = mediaUrl(path)
  744. if (url) {
  745. uni.previewImage({ urls: [url] })
  746. }
  747. },
  748. stopVoice() {
  749. if (this.innerAudio) {
  750. this.innerAudio.stop()
  751. this.innerAudio.destroy()
  752. this.innerAudio = null
  753. }
  754. this.playingVoiceId = null
  755. },
  756. toggleVoice(m) {
  757. if (!m || !m.content) return
  758. if (this.playingVoiceId === m.id) {
  759. this.stopVoice()
  760. return
  761. }
  762. this.stopVoice()
  763. const audio = uni.createInnerAudioContext()
  764. audio.src = mediaUrl(m.content)
  765. audio.onEnded(() => this.stopVoice())
  766. audio.onError(() => {
  767. uni.showToast({ title: this.$t('consultDetailPage.voicePlayFail'), icon: 'none' })
  768. this.stopVoice()
  769. })
  770. this.innerAudio = audio
  771. this.playingVoiceId = m.id
  772. audio.play()
  773. }
  774. }
  775. }
  776. </script>
  777. <style lang="scss" scoped>
  778. @import '@/styles/morandi.scss';
  779. @import '@/styles/tab-page.scss';
  780. .cd-page {
  781. display: flex;
  782. flex-direction: column;
  783. min-width: 0;
  784. min-height: 100vh;
  785. height: 100%;
  786. box-sizing: border-box;
  787. background: $morandi-bg-page;
  788. }
  789. .cd-feed {
  790. flex: 1;
  791. min-height: 0;
  792. height: 0;
  793. min-width: 0;
  794. box-sizing: border-box;
  795. background: $morandi-feed;
  796. }
  797. .cd-feed--h5 {
  798. overflow-x: hidden;
  799. overflow-y: auto;
  800. overscroll-behavior: contain;
  801. -webkit-overflow-scrolling: touch;
  802. }
  803. .cd-feed__inner {
  804. display: flex;
  805. flex-direction: column;
  806. gap: 20rpx;
  807. min-width: 0;
  808. padding: 16rpx 16rpx 24rpx;
  809. padding-bottom: calc(220rpx + env(safe-area-inset-bottom));
  810. box-sizing: border-box;
  811. }
  812. .cd-feed__inner--has-preview {
  813. padding-bottom: calc(340rpx + env(safe-area-inset-bottom));
  814. }
  815. .cd-feed__tail {
  816. height: 1px;
  817. width: 100%;
  818. }
  819. .feed-hint,
  820. .feed-empty {
  821. padding: 16rpx 0;
  822. display: flex;
  823. justify-content: center;
  824. }
  825. .feed-hint__txt,
  826. .feed-empty__txt {
  827. font-size: 24rpx;
  828. color: $morandi-text-muted;
  829. }
  830. .chat-date {
  831. display: flex;
  832. justify-content: center;
  833. padding: 8rpx 0;
  834. }
  835. .chat-date__txt {
  836. font-size: 22rpx;
  837. color: $morandi-text-muted;
  838. padding: 4rpx 16rpx;
  839. border-radius: 8rpx;
  840. background: rgba(255, 255, 255, 0.6);
  841. }
  842. .cd-bubble-row {
  843. display: flex;
  844. align-items: flex-start;
  845. gap: 12rpx;
  846. width: 100%;
  847. min-width: 0;
  848. }
  849. .cd-bubble-row--bot {
  850. justify-content: flex-start;
  851. }
  852. .cd-bubble-row--user {
  853. justify-content: flex-end;
  854. }
  855. .cd-avatar {
  856. flex-shrink: 0;
  857. width: 56rpx;
  858. height: 56rpx;
  859. border-radius: 50%;
  860. display: flex;
  861. align-items: center;
  862. justify-content: center;
  863. }
  864. .cd-avatar--vet {
  865. background: rgba(37, 99, 235, 0.12);
  866. }
  867. .cd-avatar--user {
  868. background: rgba(34, 197, 94, 0.22);
  869. }
  870. .cd-bubble {
  871. display: flex;
  872. flex-direction: column;
  873. min-width: 0;
  874. max-width: 72%;
  875. padding: 16rpx 18rpx;
  876. border-radius: 12rpx;
  877. gap: 10rpx;
  878. box-sizing: border-box;
  879. }
  880. .cd-bubble--bot {
  881. background: $morandi-bg-card-inner;
  882. border: 1rpx solid $morandi-border-soft;
  883. box-shadow: 0 2rpx 8rpx rgba(74, 69, 66, 0.05);
  884. }
  885. .cd-bubble--user {
  886. background: #8ef09e;
  887. border: 1rpx solid $morandi-border-soft;
  888. box-shadow: 0 2rpx 6rpx rgba(74, 69, 66, 0.05);
  889. }
  890. .cd-bubble--image {
  891. max-width: 85%;
  892. }
  893. .cd-bubble__who {
  894. font-size: 22rpx;
  895. font-weight: 600;
  896. color: $morandi-text-muted;
  897. }
  898. .cd-bubble--user .cd-bubble__who {
  899. color: #166534;
  900. }
  901. .cd-bubble__time {
  902. font-size: 20rpx;
  903. color: $morandi-text-muted;
  904. align-self: flex-end;
  905. }
  906. .cd-bubble__img {
  907. display: block;
  908. width: 200rpx;
  909. max-width: 100%;
  910. height: auto;
  911. border-radius: 8rpx;
  912. overflow: hidden;
  913. }
  914. .cd-bubble__video {
  915. width: 100%;
  916. max-width: 420rpx;
  917. max-height: 360rpx;
  918. border-radius: 8rpx;
  919. background: #000;
  920. }
  921. .cd-bubble__txt {
  922. font-size: 28rpx;
  923. line-height: 1.5;
  924. word-break: break-word;
  925. overflow-wrap: anywhere;
  926. color: $morandi-text;
  927. }
  928. .voice-msg {
  929. display: flex;
  930. align-items: center;
  931. gap: 12rpx;
  932. padding: 8rpx 12rpx;
  933. border-radius: 8rpx;
  934. background: rgba(34, 197, 94, 0.12);
  935. }
  936. .voice-msg--on {
  937. background: rgba(34, 197, 94, 0.22);
  938. }
  939. .voice-msg__dur {
  940. font-size: 26rpx;
  941. font-weight: 600;
  942. color: #166534;
  943. }
  944. .voice-msg__label {
  945. font-size: 24rpx;
  946. color: $morandi-text-muted;
  947. }
  948. .cd-composer {
  949. position: fixed;
  950. left: 0;
  951. right: 0;
  952. bottom: 0;
  953. z-index: 200;
  954. display: flex;
  955. flex-direction: column;
  956. gap: 12rpx;
  957. min-width: 0;
  958. padding: 12rpx 16rpx;
  959. padding-bottom: calc(12rpx + env(safe-area-inset-bottom));
  960. background: $morandi-composer;
  961. border-top: 1rpx solid $morandi-border-soft;
  962. box-shadow: 0 -8rpx 24rpx rgba(74, 69, 66, 0.06);
  963. }
  964. .cd-preview {
  965. position: relative;
  966. align-self: flex-start;
  967. width: 200rpx;
  968. border-radius: 12rpx;
  969. overflow: hidden;
  970. background: $morandi-bg-muted;
  971. }
  972. .cd-preview__img {
  973. display: block;
  974. width: 200rpx;
  975. max-width: 100%;
  976. height: auto;
  977. }
  978. .cd-preview__video-wrap {
  979. position: relative;
  980. width: 200rpx;
  981. height: 200rpx;
  982. }
  983. .cd-preview__video-ph {
  984. width: 100%;
  985. height: 100%;
  986. display: flex;
  987. align-items: center;
  988. justify-content: center;
  989. background: #374151;
  990. }
  991. .cd-preview__tag {
  992. position: absolute;
  993. left: 0;
  994. right: 0;
  995. bottom: 0;
  996. padding: 4rpx 8rpx;
  997. font-size: 20rpx;
  998. color: #ffffff;
  999. background: rgba(0, 0, 0, 0.45);
  1000. text-align: center;
  1001. }
  1002. .cd-preview__mask {
  1003. position: absolute;
  1004. right: 0;
  1005. top: 0;
  1006. width: 44rpx;
  1007. height: 44rpx;
  1008. display: flex;
  1009. align-items: center;
  1010. justify-content: center;
  1011. background: rgba(0, 0, 0, 0.45);
  1012. border-bottom-left-radius: 12rpx;
  1013. }
  1014. .cd-composer-row {
  1015. display: flex;
  1016. flex-direction: row;
  1017. align-items: flex-end;
  1018. gap: 12rpx;
  1019. min-width: 0;
  1020. }
  1021. .cd-mic {
  1022. flex-shrink: 0;
  1023. width: 72rpx;
  1024. height: 72rpx;
  1025. display: flex;
  1026. align-items: center;
  1027. justify-content: center;
  1028. border-radius: 12rpx;
  1029. background: $morandi-bg-card-inner;
  1030. border: 1rpx solid $morandi-border-soft;
  1031. }
  1032. .cd-mic--rec {
  1033. background: #fef2f2;
  1034. border-color: #fecaca;
  1035. }
  1036. .cd-input-shell {
  1037. flex: 1;
  1038. min-width: 0;
  1039. min-height: 72rpx;
  1040. max-height: 200rpx;
  1041. padding: 10rpx 16rpx;
  1042. box-sizing: border-box;
  1043. background: $morandi-bg-card-inner;
  1044. border-radius: 12rpx;
  1045. border: 1rpx solid $morandi-border-soft;
  1046. }
  1047. .cd-textarea {
  1048. width: 100%;
  1049. min-height: 48rpx;
  1050. max-height: 180rpx;
  1051. font-size: 28rpx;
  1052. line-height: 1.45;
  1053. color: $morandi-text;
  1054. }
  1055. .cd-textarea-ph {
  1056. color: $morandi-text-muted;
  1057. font-size: 26rpx;
  1058. }
  1059. .cd-icon-btn {
  1060. flex-shrink: 0;
  1061. width: 72rpx;
  1062. height: 72rpx;
  1063. display: flex;
  1064. align-items: center;
  1065. justify-content: center;
  1066. border-radius: 12rpx;
  1067. background: $morandi-bg-card-inner;
  1068. border: 1rpx solid $morandi-border-soft;
  1069. }
  1070. </style>