西藏巴青项目

index.vue 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. <template>
  2. <!-- 在线问诊:见 doc/app/在线问诊/在线问诊接口说明.md -->
  3. <view :class="pageRootClass" class="tab-page oc-page" :style="pageStyle">
  4. <view class="oc-top">
  5. <view class="oc-card">
  6. <up-search
  7. v-model="searchKeyword"
  8. shape="round"
  9. :placeholder="$t('onlineClinicPage.searchPlaceholder')"
  10. :show-action="false"
  11. :clearabled="true"
  12. bg-color="#f5f5f5"
  13. border-color="#e8e8e8"
  14. />
  15. </view>
  16. </view>
  17. <view class="oc-body">
  18. <view v-if="listLoading && !vetRows.length" class="oc-empty">
  19. <text class="text-body oc-empty__txt">{{ $t('onlineClinicPage.loading') }}</text>
  20. </view>
  21. <view v-else-if="!vetRows.length" class="oc-empty">
  22. <text class="text-body oc-empty__txt">{{ $t('onlineClinicPage.empty') }}</text>
  23. </view>
  24. <view v-else class="oc-list-wrap">
  25. <up-virtual-list
  26. ref="ocVList"
  27. class="oc-vlist"
  28. :list-data="vetRows"
  29. :item-height="slotHeightPx"
  30. :height="listHeightPx"
  31. :buffer="6"
  32. key-field="id"
  33. :scroll-top="vScrollTop"
  34. @update:scrollTop="vScrollTop = $event"
  35. @scroll="onVirtualListScroll"
  36. >
  37. <template #default="{ item }">
  38. <view class="oc-cell">
  39. <view class="oc-item" :style="{ height: rowBodyPx + 'px' }">
  40. <view class="oc-item__row1">
  41. <up-avatar
  42. shape="circle"
  43. :src="avatarUrl(item)"
  44. :text="avatarUrl(item) ? '' : item.avatarText"
  45. size="48"
  46. font-size="18"
  47. bg-color="#9ca3af"
  48. color="#ffffff"
  49. />
  50. <view class="oc-item__right">
  51. <view class="oc-item__head">
  52. <view class="oc-item__titles">
  53. <text class="oc-item__name text-body">{{ nameFor(item) }}</text>
  54. <text v-if="subFor(item)" class="oc-item__sub text-body">{{ subFor(item) }}</text>
  55. </view>
  56. <up-button
  57. class="oc-item__btn"
  58. type="success"
  59. shape="circle"
  60. plain
  61. hairline
  62. size="small"
  63. :text="$t('onlineClinicPage.btnProfile')"
  64. :custom-style="profileBtnStyle"
  65. @click="onProfile(item)"
  66. />
  67. </view>
  68. <text class="oc-item__intro text-body">
  69. <text class="oc-item__intro-k">{{ $t('onlineClinicPage.introLabel') }}</text>
  70. {{ introFor(item) }}
  71. </text>
  72. </view>
  73. </view>
  74. <up-button
  75. type="success"
  76. shape="circle"
  77. :text="$t('onlineClinicPage.btnConsult')"
  78. :custom-style="{ width: '100%' }"
  79. :loading="consultingId === item.id"
  80. @click="onConsult(item)"
  81. />
  82. </view>
  83. </view>
  84. </template>
  85. </up-virtual-list>
  86. </view>
  87. </view>
  88. </view>
  89. </template>
  90. <script>
  91. import USearch from 'uview-plus/components/u-search/u-search.vue'
  92. import UAvatar from 'uview-plus/components/u-avatar/u-avatar.vue'
  93. import UButton from 'uview-plus/components/u-button/u-button.vue'
  94. import UVirtualList from 'uview-plus/components/u-virtual-list/u-virtual-list.vue'
  95. import tabPage from '@/mixins/tabPage'
  96. import { resolveResourceUrl } from '@/utils/resourceUrl'
  97. import { ensureApiToken } from '@/utils/apiAuth'
  98. import {
  99. listOnlineConsultVets,
  100. openOnlineConsultSession,
  101. saveVetProfileCache
  102. } from '@/api/onlineConsult'
  103. const ROW_BODY_RPX = 320
  104. const ROW_GAP_RPX = 20
  105. const OC_BODY_PAD_RPX = 32
  106. const LIST_PAGE_SIZE = 20
  107. const CONSULT_DETAIL_PATH = '/package-a/consult-detail/index'
  108. const VET_PROFILE_PATH = '/package-a/vet-profile/index'
  109. export default {
  110. components: {
  111. 'up-search': USearch,
  112. 'up-avatar': UAvatar,
  113. 'up-button': UButton,
  114. 'up-virtual-list': UVirtualList
  115. },
  116. mixins: [tabPage],
  117. data() {
  118. return {
  119. navTitleKey: 'homeGrid.onlineClinic',
  120. searchKeyword: '',
  121. vetRows: [],
  122. listLoading: false,
  123. listLoadingMore: false,
  124. listTotal: 0,
  125. pageNum: 1,
  126. pageSize: LIST_PAGE_SIZE,
  127. consultingId: '',
  128. _searchTimer: null,
  129. loadMoreTimer: null,
  130. rowBodyPx: 150,
  131. marginPx: 5,
  132. slotHeightPx: 155,
  133. listHeightPx: 400,
  134. pageHeightPx: 0,
  135. vScrollTop: 0,
  136. profileBtnStyle: { flexShrink: 0, marginLeft: '8px' }
  137. }
  138. },
  139. computed: {
  140. listNoMore() {
  141. return this.listTotal > 0 && this.vetRows.length >= this.listTotal
  142. },
  143. pageStyle() {
  144. if (this.pageHeightPx > 0) {
  145. return { height: `${this.pageHeightPx}px` }
  146. }
  147. return {}
  148. }
  149. },
  150. watch: {
  151. searchKeyword() {
  152. this.vScrollTop = 0
  153. clearTimeout(this._searchTimer)
  154. this._searchTimer = setTimeout(() => {
  155. this.loadVetList(true)
  156. }, 300)
  157. }
  158. },
  159. onShow() {
  160. if (ensureApiToken(false)) {
  161. this.loadVetList(true)
  162. }
  163. this.$nextTick(() => this.calcLayoutHeights())
  164. },
  165. onReady() {
  166. try {
  167. this.marginPx = Math.ceil(uni.upx2px(ROW_GAP_RPX))
  168. this.rowBodyPx = Math.max(120, Math.ceil(uni.upx2px(ROW_BODY_RPX)))
  169. this.slotHeightPx = this.rowBodyPx + this.marginPx
  170. } catch (e) {
  171. this.marginPx = 5
  172. this.rowBodyPx = 150
  173. this.slotHeightPx = 155
  174. }
  175. this.applyListHeightFallback()
  176. this.$nextTick(() => this.calcLayoutHeights())
  177. },
  178. onUnload() {
  179. if (this._searchTimer) clearTimeout(this._searchTimer)
  180. if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
  181. },
  182. methods: {
  183. mapVetRow(row) {
  184. const name = row.resourceName || ''
  185. return {
  186. id: String(row.id != null ? row.id : ''),
  187. resourceName: name,
  188. introduction: row.introduction || '',
  189. contactPhone: row.contactPhone || '',
  190. affiliatedUnit: row.affiliatedUnit || '',
  191. detailAddress: row.detailAddress || '',
  192. serviceStartTime: row.serviceStartTime || '',
  193. serviceEndTime: row.serviceEndTime || '',
  194. photoFileUrl: row.photoFileUrl || '',
  195. avatarText: name.slice(0, 1) || '兽',
  196. raw: row
  197. }
  198. },
  199. loadVetList(reset = false) {
  200. if (!ensureApiToken()) return Promise.resolve()
  201. if (!reset) {
  202. if (this.listLoading || this.listLoadingMore || this.listNoMore) {
  203. return Promise.resolve()
  204. }
  205. this.pageNum += 1
  206. this.listLoadingMore = true
  207. } else {
  208. this.pageNum = 1
  209. this.listLoading = true
  210. }
  211. const params = {
  212. pageNum: this.pageNum,
  213. pageSize: this.pageSize
  214. }
  215. const kw = (this.searchKeyword || '').trim()
  216. if (kw) {
  217. params.resourceName = kw
  218. }
  219. return listOnlineConsultVets(params)
  220. .then((res) => {
  221. const rows = (res.rows || []).map((row) => this.mapVetRow(row))
  222. this.listTotal = res.total != null ? Number(res.total) : 0
  223. this.vetRows = reset ? rows : this.vetRows.concat(rows)
  224. this.$nextTick(() => {
  225. this.calcLayoutHeights()
  226. })
  227. })
  228. .catch(() => {
  229. if (reset) {
  230. this.vetRows = []
  231. this.listTotal = 0
  232. } else {
  233. this.pageNum -= 1
  234. }
  235. })
  236. .finally(() => {
  237. if (reset) {
  238. this.listLoading = false
  239. } else {
  240. this.listLoadingMore = false
  241. }
  242. })
  243. },
  244. tryAutoLoadMore() {
  245. if (this.listLoading || this.listLoadingMore || this.listNoMore) return
  246. const totalH = this.vetRows.length * this.slotHeightPx
  247. const viewH = this.listHeightPx
  248. if (totalH > 0 && totalH <= viewH) {
  249. this.loadVetList(false)
  250. }
  251. },
  252. onVirtualListScroll(scrollTop) {
  253. if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
  254. this.loadMoreTimer = setTimeout(() => {
  255. this.loadMoreTimer = null
  256. this.tryLoadMoreOnScroll(typeof scrollTop === 'number' ? scrollTop : 0)
  257. }, 180)
  258. },
  259. tryLoadMoreOnScroll(scrollTop) {
  260. if (this.listLoading || this.listLoadingMore || this.listNoMore) return
  261. const totalH = this.vetRows.length * this.slotHeightPx
  262. const viewH = this.listHeightPx
  263. const threshold = Math.max(this.slotHeightPx * 2, 120)
  264. if (totalH <= viewH) {
  265. this.loadVetList(false)
  266. return
  267. }
  268. if (scrollTop + viewH >= totalH - threshold) {
  269. this.loadVetList(false)
  270. }
  271. },
  272. getNavigationBarHeight() {
  273. const sys = uni.getSystemInfoSync()
  274. const statusBar = sys.statusBarHeight || 0
  275. // #ifdef MP-WEIXIN
  276. try {
  277. const menu = uni.getMenuButtonBoundingClientRect()
  278. if (menu && menu.height) {
  279. return statusBar + (menu.top - statusBar) * 2 + menu.height
  280. }
  281. } catch (e) {
  282. /* noop */
  283. }
  284. // #endif
  285. return statusBar + 44
  286. },
  287. getViewportContentHeight() {
  288. const sys = uni.getSystemInfoSync()
  289. const navH = this.getNavigationBarHeight()
  290. // #ifdef H5
  291. if (typeof window !== 'undefined' && window.innerHeight) {
  292. return Math.max(320, window.innerHeight - navH)
  293. }
  294. // #endif
  295. const screenH = sys.screenHeight || sys.windowHeight || 600
  296. return Math.max(320, screenH - navH)
  297. },
  298. applyListHeightFallback() {
  299. const contentH = this.getViewportContentHeight()
  300. const topBlock = Math.ceil(uni.upx2px(100))
  301. const bodyPad = Math.ceil(uni.upx2px(OC_BODY_PAD_RPX))
  302. this.pageHeightPx = contentH
  303. this.listHeightPx = Math.max(240, contentH - topBlock - bodyPad)
  304. },
  305. calcLayoutHeights() {
  306. const contentH = this.getViewportContentHeight()
  307. this.pageHeightPx = contentH
  308. uni.createSelectorQuery()
  309. .in(this)
  310. .select('.oc-body')
  311. .boundingClientRect((bodyRect) => {
  312. if (bodyRect && bodyRect.height > 0) {
  313. this.listHeightPx = Math.max(240, Math.floor(bodyRect.height))
  314. } else {
  315. const topFallback = Math.ceil(uni.upx2px(100))
  316. const bodyPad = Math.ceil(uni.upx2px(OC_BODY_PAD_RPX))
  317. this.listHeightPx = Math.max(240, contentH - topFallback - bodyPad)
  318. }
  319. this.$nextTick(() => {
  320. this.$refs.ocVList?.measureContainerHeight?.()
  321. this.tryAutoLoadMore()
  322. })
  323. })
  324. .exec()
  325. },
  326. avatarUrl(item) {
  327. if (item && item.photoFileUrl) {
  328. return resolveResourceUrl(item.photoFileUrl)
  329. }
  330. return ''
  331. },
  332. nameFor(item) {
  333. return item.resourceName || this.$t('onlineClinicPage.nameFallback')
  334. },
  335. subFor(item) {
  336. if (item.affiliatedUnit) {
  337. return item.affiliatedUnit
  338. }
  339. if (item.serviceStartTime && item.serviceEndTime) {
  340. return this.$t('onlineClinicPage.serviceHoursTpl', {
  341. start: item.serviceStartTime,
  342. end: item.serviceEndTime
  343. })
  344. }
  345. return ''
  346. },
  347. introFor(item) {
  348. return item.introduction || this.$t('onlineClinicPage.noIntro')
  349. },
  350. onProfile(item) {
  351. if (!item || !item.id) return
  352. saveVetProfileCache(item.id, item.raw || item)
  353. uni.navigateTo({
  354. url: `${VET_PROFILE_PATH}?id=${encodeURIComponent(item.id)}`
  355. })
  356. },
  357. onConsult(item) {
  358. if (!item || !item.id || this.consultingId) return
  359. if (!ensureApiToken()) return
  360. saveVetProfileCache(item.id, item.raw || item)
  361. this.consultingId = item.id
  362. uni.showLoading({ title: this.$t('onlineClinicPage.openingSession'), mask: true })
  363. openOnlineConsultSession({ vetResourceId: Number(item.id) || item.id })
  364. .then((res) => {
  365. const session = res.data || {}
  366. const sessionId = session.id != null ? String(session.id) : ''
  367. if (!sessionId) {
  368. return
  369. }
  370. const receiverName = session.receiverName || item.resourceName || ''
  371. const q = [
  372. `sessionId=${encodeURIComponent(sessionId)}`,
  373. `vetResourceId=${encodeURIComponent(item.id)}`,
  374. `id=${encodeURIComponent(item.id)}`,
  375. `name=${encodeURIComponent(receiverName)}`,
  376. `sub=${encodeURIComponent(this.subFor(item))}`,
  377. `intro=${encodeURIComponent(item.introduction || '')}`,
  378. `avatar=${encodeURIComponent(item.avatarText || '')}`
  379. ].join('&')
  380. uni.navigateTo({ url: `${CONSULT_DETAIL_PATH}?${q}` })
  381. })
  382. .finally(() => {
  383. this.consultingId = ''
  384. uni.hideLoading()
  385. })
  386. }
  387. }
  388. }
  389. </script>
  390. <style lang="scss" scoped>
  391. @import '@/styles/morandi.scss';
  392. @import '@/styles/tab-page.scss';
  393. .oc-page {
  394. display: flex;
  395. flex-direction: column;
  396. min-width: 0;
  397. width: 100%;
  398. height: 100%;
  399. min-height: 100%;
  400. overflow: hidden;
  401. box-sizing: border-box;
  402. background: $morandi-bg-page;
  403. }
  404. .oc-top {
  405. flex-shrink: 0;
  406. display: flex;
  407. flex-direction: column;
  408. min-width: 0;
  409. }
  410. .oc-card {
  411. background: #ffffff;
  412. padding: 16rpx 20rpx;
  413. box-sizing: border-box;
  414. border-bottom: 1rpx solid $morandi-border-soft;
  415. }
  416. .oc-body {
  417. flex: 1;
  418. min-height: 0;
  419. height: 0;
  420. width: 100%;
  421. min-width: 0;
  422. display: flex;
  423. flex-direction: column;
  424. padding: 0 24rpx 16rpx;
  425. box-sizing: border-box;
  426. overflow: hidden;
  427. background: $morandi-bg-page;
  428. }
  429. .oc-list-wrap {
  430. flex: 1;
  431. min-height: 0;
  432. display: flex;
  433. flex-direction: column;
  434. min-width: 0;
  435. }
  436. .oc-empty {
  437. flex: 1;
  438. display: flex;
  439. align-items: center;
  440. justify-content: center;
  441. min-height: 0;
  442. padding: 48rpx 24rpx;
  443. box-sizing: border-box;
  444. }
  445. .oc-empty__txt {
  446. color: $morandi-text-muted;
  447. }
  448. .oc-vlist {
  449. flex: 1;
  450. min-height: 0;
  451. width: 100%;
  452. height: 100%;
  453. box-sizing: border-box;
  454. padding: 10rpx 0 0;
  455. }
  456. .oc-cell {
  457. height: 100%;
  458. box-sizing: border-box;
  459. display: flex;
  460. flex-direction: column;
  461. justify-content: flex-start;
  462. padding-bottom: 10rpx;
  463. }
  464. .oc-item {
  465. display: flex;
  466. flex-direction: column;
  467. justify-content: space-between;
  468. gap: 16rpx;
  469. padding: 20rpx;
  470. box-sizing: border-box;
  471. background: #ffffff;
  472. border-radius: 12rpx;
  473. border: 1rpx solid $morandi-border-soft;
  474. min-width: 0;
  475. flex-shrink: 0;
  476. overflow: hidden;
  477. }
  478. .oc-item__row1 {
  479. display: flex;
  480. flex-direction: row;
  481. align-items: center;
  482. gap: 16rpx;
  483. min-width: 0;
  484. }
  485. .oc-item__right {
  486. flex: 1;
  487. min-width: 0;
  488. display: flex;
  489. flex-direction: column;
  490. gap: 10rpx;
  491. }
  492. .oc-item__head {
  493. display: flex;
  494. flex-direction: row;
  495. align-items: center;
  496. gap: 12rpx;
  497. min-width: 0;
  498. }
  499. .oc-item__titles {
  500. flex: 1;
  501. min-width: 0;
  502. display: flex;
  503. flex-direction: column;
  504. gap: 4rpx;
  505. }
  506. .oc-item__btn {
  507. width: 86rpx;
  508. }
  509. .oc-item__name {
  510. font-size: 30rpx;
  511. font-weight: 600;
  512. color: #111827;
  513. line-height: 1.35;
  514. overflow: hidden;
  515. text-overflow: ellipsis;
  516. white-space: nowrap;
  517. }
  518. .oc-item__sub {
  519. font-size: 24rpx;
  520. color: $morandi-text-secondary;
  521. line-height: 1.35;
  522. overflow: hidden;
  523. text-overflow: ellipsis;
  524. white-space: nowrap;
  525. }
  526. .oc-item__intro {
  527. font-size: 22rpx;
  528. color: $morandi-text-muted;
  529. line-height: 1.45;
  530. display: -webkit-box;
  531. -webkit-box-orient: vertical;
  532. -webkit-line-clamp: 2;
  533. overflow: hidden;
  534. word-break: break-word;
  535. }
  536. .oc-item__intro-k {
  537. color: $morandi-text-muted;
  538. font-weight: 500;
  539. }
  540. .oc-page.lang-bo {
  541. .oc-item__name {
  542. font-size: 26rpx;
  543. }
  544. .oc-item__sub {
  545. font-size: 22rpx;
  546. }
  547. }
  548. </style>