西藏巴青项目

index.vue 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. <template>
  2. <!-- 预约服务:见 doc/app/预约服务/预约服务接口说明.md -->
  3. <view :class="pageRootClass" class="tab-page bs-page" :style="pageStyle">
  4. <view class="bs-top">
  5. <view class="bs-card bs-card--flush">
  6. <scroll-view scroll-x class="bs-date-scroll" :show-scrollbar="false" enable-flex>
  7. <view class="bs-date-row">
  8. <view
  9. v-for="chip in dateChips"
  10. :key="chip.key"
  11. class="bs-chip"
  12. :class="{ 'bs-chip--on': selectedDateKey === chip.key }"
  13. role="button"
  14. @tap="onDateChipTap(chip.key)"
  15. >
  16. <template v-if="chip.key === 'all'">
  17. <text class="bs-chip__all text-body">{{ $t('bookingServicePage.all') }}</text>
  18. </template>
  19. <view v-else class="bs-chip__stack">
  20. <text class="bs-chip__name text-body">{{ chip.weekdayLabel }}</text>
  21. <text class="bs-chip__date text-body">{{ chip.dateShort }}</text>
  22. </view>
  23. </view>
  24. </view>
  25. </scroll-view>
  26. </view>
  27. <view class="bs-card border-top">
  28. <up-search
  29. v-model="searchKeyword"
  30. shape="round"
  31. :placeholder="$t('bookingServicePage.searchPlaceholder')"
  32. :show-action="false"
  33. :clearabled="true"
  34. bg-color="#f5f5f5"
  35. border-color="#e8e8e8"
  36. />
  37. </view>
  38. <view class="bs-card bs-card--tabs">
  39. <up-tabs
  40. :current="tabIndex"
  41. class="bs-tabs"
  42. :list="tabsList"
  43. key-name="name"
  44. :scrollable="false"
  45. line-color="#22C55E"
  46. :line-height="2"
  47. :item-style="tabsItemStyle"
  48. :active-style="{ color: '#15803d', fontWeight: '600', fontSize: '13px' }"
  49. :inactive-style="{ color: '#78716c', fontSize: '13px' }"
  50. @update:current="onTabUpdate"
  51. />
  52. </view>
  53. </view>
  54. <view class="bs-body">
  55. <view v-if="listLoading && !resourceRows.length" class="bs-empty">
  56. <text class="text-body bs-empty__txt">{{ $t('bookingServicePage.loading') }}</text>
  57. </view>
  58. <view v-else-if="!resourceRows.length" class="bs-empty">
  59. <text class="text-body bs-empty__txt">{{ $t('bookingServicePage.empty') }}</text>
  60. </view>
  61. <view v-else class="bs-list-wrap">
  62. <up-virtual-list
  63. ref="bsVList"
  64. class="bs-vlist"
  65. :list-data="resourceRows"
  66. :item-height="slotHeightPx"
  67. :height="listHeightPx"
  68. :buffer="6"
  69. key-field="id"
  70. :scroll-top="vScrollTop"
  71. @update:scrollTop="vScrollTop = $event"
  72. @scroll="onVirtualListScroll"
  73. >
  74. <template #default="{ item }">
  75. <view class="bs-cell">
  76. <view class="bs-item" :style="{ height: rowBodyPx + 'px' }">
  77. <view class="bs-item__row1">
  78. <up-avatar
  79. shape="circle"
  80. :src="avatarUrl(item)"
  81. :text="avatarUrl(item) ? '' : item.avatarText"
  82. size="48"
  83. font-size="18"
  84. bg-color="#9ca3af"
  85. color="#ffffff"
  86. />
  87. <view class="bs-item__right">
  88. <text class="bs-item__title text-body">{{ titleFor(item) }}</text>
  89. <text class="bs-item__sub text-body">{{ subFor(item) }}</text>
  90. <text class="bs-item__intro text-body">{{ introFor(item) }}</text>
  91. </view>
  92. </view>
  93. <up-button
  94. type="success"
  95. :text="$t('bookingServicePage.btnBook')"
  96. :custom-style="{ width: '100%' }"
  97. @click="onBook(item)"
  98. />
  99. </view>
  100. </view>
  101. </template>
  102. </up-virtual-list>
  103. </view>
  104. </view>
  105. </view>
  106. </template>
  107. <script>
  108. import USearch from 'uview-plus/components/u-search/u-search.vue'
  109. import UTabs from 'uview-plus/components/u-tabs/u-tabs.vue'
  110. import UAvatar from 'uview-plus/components/u-avatar/u-avatar.vue'
  111. import UButton from 'uview-plus/components/u-button/u-button.vue'
  112. import UVirtualList from 'uview-plus/components/u-virtual-list/u-virtual-list.vue'
  113. import tabPage from '@/mixins/tabPage'
  114. import { resolveResourceUrl } from '@/utils/resourceUrl'
  115. import { ensureApiToken } from '@/utils/apiAuth'
  116. import {
  117. listBookingDates,
  118. listBookingResources,
  119. saveBookingResourceCache
  120. } from '@/api/bookingService'
  121. const ROW_BODY_RPX = 260
  122. const ROW_GAP_RPX = 20
  123. const LIST_PAGE_SIZE = 20
  124. const BS_BODY_PAD_RPX = 40
  125. const RESOURCE_TYPES = ['004001', '004005', '004003']
  126. const BOOKING_PATHS = {
  127. '004001': '/package-a/booking-vet/index',
  128. '004005': '/package-a/booking-expert/index',
  129. '004003': '/package-a/booking-org/index'
  130. }
  131. export default {
  132. components: {
  133. 'up-search': USearch,
  134. 'up-tabs': UTabs,
  135. 'up-avatar': UAvatar,
  136. 'up-button': UButton,
  137. 'up-virtual-list': UVirtualList
  138. },
  139. mixins: [tabPage],
  140. data() {
  141. return {
  142. navTitleKey: 'bookingServicePage.navTitle',
  143. searchKeyword: '',
  144. tabIndex: 0,
  145. selectedDateKey: 'all',
  146. apiDates: [],
  147. resourceRows: [],
  148. listLoading: false,
  149. listLoadingMore: false,
  150. listTotal: 0,
  151. pageNum: 1,
  152. pageSize: LIST_PAGE_SIZE,
  153. rowBodyPx: 120,
  154. marginPx: 10,
  155. slotHeightPx: 130,
  156. listHeightPx: 400,
  157. pageHeightPx: 0,
  158. vScrollTop: 0,
  159. loadMoreTimer: null,
  160. _searchTimer: null
  161. }
  162. },
  163. computed: {
  164. tabsItemStyle() {
  165. return { height: '32px', padding: '0 8px' }
  166. },
  167. currentResourceType() {
  168. return RESOURCE_TYPES[this.tabIndex] || RESOURCE_TYPES[0]
  169. },
  170. selectedWeekday() {
  171. if (this.selectedDateKey === 'all') return undefined
  172. const chip = this.dateChips.find((c) => c.key === this.selectedDateKey)
  173. return chip && chip.weekday != null ? chip.weekday : undefined
  174. },
  175. dateChips() {
  176. const chips = [
  177. {
  178. key: 'all',
  179. dateShort: '',
  180. weekdayLabel: '',
  181. weekday: null
  182. }
  183. ]
  184. for (const d of this.apiDates) {
  185. chips.push({
  186. key: d.appointDate || d.key,
  187. dateShort: d.dateMmDd || '',
  188. weekday: d.weekday,
  189. weekdayLabel: this.weekdayLabelFor(d)
  190. })
  191. }
  192. return chips
  193. },
  194. tabsList() {
  195. return [
  196. { name: this.$t('bookingServicePage.tabVet') },
  197. { name: this.$t('bookingServicePage.tabExpert') },
  198. { name: this.$t('bookingServicePage.tabOrg') }
  199. ]
  200. },
  201. listNoMore() {
  202. return this.listTotal > 0 && this.resourceRows.length >= this.listTotal
  203. },
  204. pageStyle() {
  205. if (this.pageHeightPx > 0) {
  206. return { height: `${this.pageHeightPx}px` }
  207. }
  208. return {}
  209. }
  210. },
  211. watch: {
  212. searchKeyword() {
  213. this.vScrollTop = 0
  214. clearTimeout(this._searchTimer)
  215. this._searchTimer = setTimeout(() => {
  216. this.loadResourceList(true)
  217. }, 300)
  218. },
  219. tabIndex() {
  220. this.vScrollTop = 0
  221. this.loadResourceList(true)
  222. },
  223. selectedDateKey() {
  224. this.vScrollTop = 0
  225. this.loadResourceList(true)
  226. }
  227. },
  228. onShow() {
  229. if (ensureApiToken(false)) {
  230. this.loadBookingDates()
  231. this.loadResourceList(true)
  232. }
  233. },
  234. onReady() {
  235. try {
  236. this.marginPx = Math.ceil(uni.upx2px(ROW_GAP_RPX))
  237. this.rowBodyPx = Math.max(100, Math.ceil(uni.upx2px(ROW_BODY_RPX)))
  238. this.slotHeightPx = this.rowBodyPx + this.marginPx
  239. } catch (e) {
  240. this.marginPx = 10
  241. this.rowBodyPx = 120
  242. this.slotHeightPx = 130
  243. }
  244. this.applyListHeightFallback()
  245. this.$nextTick(() => this.calcLayoutHeights())
  246. },
  247. onUnload() {
  248. if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
  249. if (this._searchTimer) clearTimeout(this._searchTimer)
  250. },
  251. methods: {
  252. weekdayLabelFor(d) {
  253. const w = d && d.weekday
  254. if (w != null && w >= 1 && w <= 7) {
  255. const dowKey = w === 7 ? 0 : w
  256. return this.$t(`bookingServicePage.wd${dowKey}`)
  257. }
  258. return (d && d.weekdayName) || ''
  259. },
  260. loadBookingDates() {
  261. if (!ensureApiToken(false)) return
  262. listBookingDates()
  263. .then((res) => {
  264. this.apiDates = Array.isArray(res.data) ? res.data : []
  265. })
  266. .catch(() => {
  267. this.apiDates = []
  268. })
  269. },
  270. mapResourceRow(row) {
  271. const name = row.resourceName || ''
  272. const type = row.resourceType || this.currentResourceType
  273. let kind = 'vet'
  274. if (type === '004005') kind = 'expert'
  275. else if (type === '004003') kind = 'org'
  276. return {
  277. id: String(row.id != null ? row.id : ''),
  278. kind,
  279. resourceType: type,
  280. resourceName: name,
  281. affiliatedUnit: row.affiliatedUnit || '',
  282. introduction: row.introduction || '',
  283. expertRating: row.expertRating,
  284. photoFileUrl: row.photoFileUrl || '',
  285. avatarText: name.slice(0, 1) || '?',
  286. raw: row
  287. }
  288. },
  289. loadResourceList(reset = false) {
  290. if (!ensureApiToken()) return Promise.resolve()
  291. if (!reset) {
  292. if (this.listLoading || this.listLoadingMore || this.listNoMore) {
  293. return Promise.resolve()
  294. }
  295. this.pageNum += 1
  296. this.listLoadingMore = true
  297. } else {
  298. this.pageNum = 1
  299. this.listLoading = true
  300. }
  301. const params = {
  302. resourceType: this.currentResourceType,
  303. pageNum: this.pageNum,
  304. pageSize: this.pageSize
  305. }
  306. const kw = (this.searchKeyword || '').trim()
  307. if (kw) {
  308. params.resourceName = kw
  309. }
  310. if (this.selectedWeekday != null) {
  311. params.weekday = this.selectedWeekday
  312. }
  313. return listBookingResources(params)
  314. .then((res) => {
  315. const rows = (res.rows || []).map((row) => this.mapResourceRow(row))
  316. this.listTotal = res.total != null ? Number(res.total) : 0
  317. this.resourceRows = reset ? rows : this.resourceRows.concat(rows)
  318. this.$nextTick(() => {
  319. this.calcLayoutHeights()
  320. })
  321. })
  322. .catch(() => {
  323. if (reset) {
  324. this.resourceRows = []
  325. this.listTotal = 0
  326. } else {
  327. this.pageNum -= 1
  328. }
  329. })
  330. .finally(() => {
  331. if (reset) {
  332. this.listLoading = false
  333. } else {
  334. this.listLoadingMore = false
  335. }
  336. })
  337. },
  338. tryAutoLoadMore() {
  339. if (this.listLoading || this.listLoadingMore || this.listNoMore) return
  340. const totalH = this.resourceRows.length * this.slotHeightPx
  341. const viewH = this.listHeightPx
  342. if (totalH > 0 && totalH <= viewH) {
  343. this.loadResourceList(false)
  344. }
  345. },
  346. onVirtualListScroll(scrollTop) {
  347. if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
  348. this.loadMoreTimer = setTimeout(() => {
  349. this.loadMoreTimer = null
  350. this.tryLoadMoreOnScroll(typeof scrollTop === 'number' ? scrollTop : 0)
  351. }, 180)
  352. },
  353. tryLoadMoreOnScroll(scrollTop) {
  354. if (this.listLoading || this.listLoadingMore || this.listNoMore) return
  355. const totalH = this.resourceRows.length * this.slotHeightPx
  356. const viewH = this.listHeightPx
  357. const threshold = Math.max(this.slotHeightPx * 2, 120)
  358. if (totalH <= viewH) {
  359. this.loadResourceList(false)
  360. return
  361. }
  362. if (scrollTop + viewH >= totalH - threshold) {
  363. this.loadResourceList(false)
  364. }
  365. },
  366. onDateChipTap(key) {
  367. this.selectedDateKey = key
  368. },
  369. applyListHeightFallback() {
  370. const contentH = this.getViewportContentHeight()
  371. const topFallback = Math.ceil(uni.upx2px(320))
  372. const bodyPad = Math.ceil(uni.upx2px(BS_BODY_PAD_RPX))
  373. this.pageHeightPx = contentH
  374. this.listHeightPx = Math.max(240, contentH - topFallback - bodyPad)
  375. },
  376. getNavigationBarHeight() {
  377. const sys = uni.getSystemInfoSync()
  378. const statusBar = sys.statusBarHeight || 0
  379. // #ifdef MP-WEIXIN
  380. try {
  381. const menu = uni.getMenuButtonBoundingClientRect()
  382. if (menu && menu.height) {
  383. return statusBar + (menu.top - statusBar) * 2 + menu.height
  384. }
  385. } catch (e) {
  386. /* noop */
  387. }
  388. // #endif
  389. return statusBar + 44
  390. },
  391. getViewportContentHeight() {
  392. const sys = uni.getSystemInfoSync()
  393. const navH = this.getNavigationBarHeight()
  394. // #ifdef H5
  395. if (typeof window !== 'undefined' && window.innerHeight) {
  396. return Math.max(320, window.innerHeight - navH)
  397. }
  398. // #endif
  399. const screenH = sys.screenHeight || sys.windowHeight || 600
  400. return Math.max(320, screenH - navH)
  401. },
  402. calcLayoutHeights() {
  403. const contentH = this.getViewportContentHeight()
  404. const bodyPad = Math.ceil(uni.upx2px(BS_BODY_PAD_RPX))
  405. this.pageHeightPx = contentH
  406. uni.createSelectorQuery()
  407. .in(this)
  408. .select('.bs-top')
  409. .boundingClientRect((topRect) => {
  410. const topH =
  411. topRect && topRect.height > 0 ? Math.ceil(topRect.height) : Math.ceil(uni.upx2px(320))
  412. this.listHeightPx = Math.max(240, contentH - topH - bodyPad)
  413. this.$nextTick(() => {
  414. this.$refs.bsVList?.measureContainerHeight?.()
  415. this.tryAutoLoadMore()
  416. })
  417. })
  418. .exec()
  419. },
  420. onTabUpdate(idx) {
  421. this.tabIndex = typeof idx === 'number' ? idx : 0
  422. },
  423. titleFor(item) {
  424. return item.resourceName || this.$t('bookingServicePage.noUnit')
  425. },
  426. subFor(item) {
  427. if (item.affiliatedUnit) {
  428. return item.affiliatedUnit
  429. }
  430. if (item.kind === 'expert' && item.expertRating != null && item.expertRating !== '') {
  431. return `${this.$t('bookingServicePage.tabExpert')} · ${item.expertRating}`
  432. }
  433. return this.$t('bookingServicePage.noUnit')
  434. },
  435. introFor(item) {
  436. const intro = (item.introduction || '').trim()
  437. return intro || this.$t('bookingServicePage.noIntro')
  438. },
  439. avatarUrl(item) {
  440. const url = item && (item.photoFileUrl || (item.raw && item.raw.photoFileUrl))
  441. const result = url ? resolveResourceUrl(url) : ''
  442. return result
  443. },
  444. onBook(item) {
  445. if (!item || !item.id) return
  446. if (!ensureApiToken()) return
  447. saveBookingResourceCache(item.resourceType, item.id, item.raw || item)
  448. const path = BOOKING_PATHS[item.resourceType] || BOOKING_PATHS['004001']
  449. const q = [
  450. `id=${encodeURIComponent(item.id)}`,
  451. `resourceType=${encodeURIComponent(item.resourceType || this.currentResourceType)}`
  452. ].join('&')
  453. uni.navigateTo({ url: `${path}?${q}` })
  454. }
  455. }
  456. }
  457. </script>
  458. <style lang="scss" scoped>
  459. @import '@/styles/morandi.scss';
  460. @import '@/styles/tab-page.scss';
  461. .bs-page {
  462. display: flex;
  463. flex-direction: column;
  464. min-width: 0;
  465. width: 100%;
  466. height: 100%;
  467. min-height: 100%;
  468. overflow: hidden;
  469. box-sizing: border-box;
  470. background: $morandi-bg-page;
  471. }
  472. .bs-top {
  473. flex-shrink: 0;
  474. display: flex;
  475. flex-direction: column;
  476. min-width: 0;
  477. }
  478. .border-top {
  479. border-top: 1rpx solid $morandi-border-soft;
  480. }
  481. .bs-card {
  482. background: #ffffff;
  483. padding: 16rpx 20rpx;
  484. box-sizing: border-box;
  485. }
  486. .bs-card--tabs {
  487. padding: 8rpx 12rpx 4rpx;
  488. }
  489. .bs-date-scroll {
  490. width: 100%;
  491. white-space: nowrap;
  492. }
  493. .bs-date-row {
  494. display: inline-flex;
  495. flex-direction: row;
  496. align-items: stretch;
  497. gap: 12rpx;
  498. padding: 4rpx 8rpx 8rpx;
  499. min-width: min-content;
  500. }
  501. .bs-chip {
  502. flex-shrink: 0;
  503. min-width: 96rpx;
  504. padding: 12rpx 16rpx;
  505. border-radius: 12rpx;
  506. border: 1rpx solid #e5e7eb;
  507. background: #ffffff;
  508. box-sizing: border-box;
  509. display: flex;
  510. flex-direction: row;
  511. align-items: center;
  512. justify-content: center;
  513. }
  514. .bs-chip--on {
  515. background: #22c55e;
  516. border-color: #16a34a;
  517. }
  518. .bs-chip__all {
  519. font-size: 26rpx;
  520. color: #111827;
  521. font-weight: 500;
  522. }
  523. .bs-chip--on .bs-chip__all {
  524. color: #ffffff;
  525. font-weight: 600;
  526. }
  527. .bs-chip__stack {
  528. display: flex;
  529. flex-direction: column;
  530. align-items: center;
  531. justify-content: center;
  532. gap: 4rpx;
  533. }
  534. .bs-chip__name {
  535. font-size: 24rpx;
  536. color: #111827;
  537. font-weight: 500;
  538. }
  539. .bs-chip__date {
  540. font-size: 22rpx;
  541. color: #374151;
  542. }
  543. .bs-chip--on .bs-chip__name,
  544. .bs-chip--on .bs-chip__date {
  545. color: #ffffff;
  546. font-weight: 600;
  547. }
  548. .bs-tabs {
  549. width: 100%;
  550. }
  551. .bs-tabs :deep(.u-tabs__wrapper__nav__item__text) {
  552. line-height: 1.2;
  553. }
  554. .bs-tabs :deep(.u-tabs__wrapper__nav__line) {
  555. bottom: 1px;
  556. }
  557. .bs-body {
  558. flex: 1;
  559. min-height: 0;
  560. height: 0;
  561. min-width: 0;
  562. display: flex;
  563. flex-direction: column;
  564. padding: 0 24rpx 16rpx;
  565. box-sizing: border-box;
  566. overflow: hidden;
  567. }
  568. .bs-list-wrap {
  569. flex: 1;
  570. min-height: 0;
  571. display: flex;
  572. flex-direction: column;
  573. min-width: 0;
  574. }
  575. .bs-empty {
  576. flex: 1;
  577. display: flex;
  578. align-items: center;
  579. justify-content: center;
  580. min-height: 0;
  581. padding: 48rpx 24rpx;
  582. box-sizing: border-box;
  583. }
  584. .bs-empty__txt {
  585. color: $morandi-text-muted;
  586. }
  587. .bs-vlist {
  588. flex: 1;
  589. min-height: 0;
  590. width: 100%;
  591. height: 100%;
  592. box-sizing: border-box;
  593. padding: 10rpx 0;
  594. }
  595. .bs-cell {
  596. box-sizing: border-box;
  597. padding-bottom: 10rpx;
  598. }
  599. .bs-item {
  600. display: flex;
  601. flex-direction: column;
  602. justify-content: space-between;
  603. gap: 16rpx;
  604. padding: 20rpx;
  605. box-sizing: border-box;
  606. background: #ffffff;
  607. border-radius: 12rpx;
  608. border: 1rpx solid $morandi-border-soft;
  609. min-width: 0;
  610. }
  611. .bs-item__row1 {
  612. display: flex;
  613. flex-direction: row;
  614. align-items: center;
  615. gap: 16rpx;
  616. min-width: 0;
  617. }
  618. .bs-item__right {
  619. flex: 1;
  620. min-width: 0;
  621. display: flex;
  622. flex-direction: column;
  623. gap: 6rpx;
  624. }
  625. .bs-item__title {
  626. font-size: 30rpx;
  627. font-weight: 600;
  628. color: #111827;
  629. line-height: 1.35;
  630. word-break: break-word;
  631. }
  632. .bs-item__sub {
  633. font-size: 24rpx;
  634. color: $morandi-text-secondary;
  635. line-height: 1.4;
  636. word-break: break-word;
  637. }
  638. .bs-item__intro {
  639. font-size: 22rpx;
  640. color: $morandi-text-muted;
  641. line-height: 1.45;
  642. word-break: break-word;
  643. }
  644. .bs-page.lang-bo {
  645. .bs-item__title {
  646. font-size: 26rpx;
  647. line-height: 1.75;
  648. }
  649. .bs-chip__name {
  650. font-size: 22rpx;
  651. line-height: 1.65;
  652. }
  653. }
  654. </style>