| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751 |
- <template>
- <!-- 我的预约:见 doc/app/我的预约/我的预约接口说明.md -->
- <view :class="pageRootClass" class="tab-page mb-page" :style="pageStyle">
- <view class="mb-top">
- <view class="mb-card mb-card--tabs">
- <up-tabs
- :current="mainTabIndex"
- class="mb-tabs"
- :list="mainTabsList"
- key-name="name"
- :scrollable="false"
- line-color="#22C55E"
- :line-height="2"
- :item-style="tabsItemStyle"
- :active-style="{ color: '#15803d', fontWeight: '600', fontSize: '13px' }"
- :inactive-style="{ color: '#78716c', fontSize: '13px' }"
- @update:current="onMainTabUpdate"
- />
- </view>
- <scroll-view scroll-x class="mb-chip-scroll" :show-scrollbar="false" enable-flex>
- <view class="mb-chip-row">
- <view
- v-for="chip in subChips"
- :key="chip.key"
- class="mb-chip"
- :class="{ 'mb-chip--on': currentSubKey === chip.key }"
- role="button"
- @tap="setSubKey(chip.key)"
- >
- <text class="mb-chip__txt text-body">{{ $t(chip.labelKey) }}</text>
- </view>
- </view>
- </scroll-view>
- </view>
- <view class="mb-body">
- <view v-if="listLoading && !rows.length" class="mb-empty">
- <text class="text-body mb-empty__txt">{{ $t('myBookingPage.loading') }}</text>
- </view>
- <view v-else-if="!rows.length" class="mb-empty">
- <text class="text-body mb-empty__txt">{{ $t('myBookingPage.empty') }}</text>
- </view>
- <view v-else class="mb-list-wrap">
- <up-virtual-list
- ref="mbVList"
- class="mb-vlist"
- :list-data="rows"
- :item-height="slotHeightPx"
- :height="listHeightPx"
- :buffer="6"
- key-field="id"
- :scroll-top="vScrollTop"
- @update:scrollTop="vScrollTop = $event"
- @scroll="onVirtualListScroll"
- >
- <template #default="{ item }">
- <view class="mb-cell">
- <view class="mb-item" :style="{ height: rowBodyPx + 'px' }">
- <text class="mb-item__title text-body">{{ titleFor(item) }}</text>
- <text class="mb-item__line text-body">{{ timeLine(item) }}</text>
- <template v-if="item.kind !== 'org'">
- <text class="mb-item__line text-body">
- <text class="mb-item__k">{{ $t('myBookingPage.labelAddress') }}</text>
- {{ addressLine(item) }}
- </text>
- <text class="mb-item__line text-body">
- <text class="mb-item__k">{{ $t('myBookingPage.labelNeed') }}</text>
- {{ needLine(item) }}
- </text>
- </template>
- <view class="mb-item__row mb-item__row--status">
- <text class="mb-item__line text-body">
- <text class="mb-item__k">{{ $t('myBookingPage.labelStatus') }}</text>
- </text>
- <up-tag
- :text="statusLabel(item)"
- :type="statusTagType(item)"
- size="mini"
- plain
- plain-fill
- />
- </view>
- <view class="mb-item__row mb-item__row--end">
- <!-- :custom-style="cancelBtnStyle" -->
- <up-button
- v-if="showCancelBtn(item)"
- shape="circle"
- size="small"
- plain
- hairline
- type="primary"
- :text="$t('myBookingPage.btnCancelBook')"
- :custom-style="cancelBtnStyle"
- @click="onCancelBook(item)"
- />
- <template v-if="showExpertRateRow(item)">
- <up-button
- v-if="!item.hasReview"
- shape="circle"
- size="small"
- type="success"
- :custom-style="cancelBtnStyle"
- :text="$t('myBookingPage.btnReview')"
- @click="onReview(item)"
- />
- <up-button
- v-else
- shape="circle"
- size="small"
- plain
- hairline
- :custom-style="cancelBtnStyle"
- type="success"
- :text="$t('myBookingPage.btnViewReview')"
- @click="onViewReview(item)"
- />
- </template>
- </view>
- </view>
- </view>
- </template>
- </up-virtual-list>
- </view>
- </view>
- </view>
- </template>
- <script>
- import UTabs from 'uview-plus/components/u-tabs/u-tabs.vue'
- import UButton from 'uview-plus/components/u-button/u-button.vue'
- import UTag from 'uview-plus/components/u-tag/u-tag.vue'
- import UVirtualList from 'uview-plus/components/u-virtual-list/u-virtual-list.vue'
- import tabPage from '@/mixins/tabPage'
- import { listMyAppointments, cancelMyAppointment } from '@/api/myAppointment'
- const ROW_GAP_RPX = 20
- const LIST_PAGE_SIZE = 20
- const MB_BODY_PAD_RPX = 40
- /** 主 Tab 索引 → providerType:1 兽医 / 3 专家 / 2 机构 */
- const PROVIDER_TYPES = [1, 3, 2]
- const STATUS_TAB_MAP = {
- all: 'all',
- upcoming: 'notStarted',
- todo_review: 'pendingReview',
- done: 'completed',
- cancelled: 'cancelled'
- }
- /** 状态码 → up-tag type:0 待确认 / 1 已确认 / 2 已拒绝 / 3 已取消 / 4 已完成 / 5 已预约 */
- const STATUS_TAG_TYPE = {
- 0: 'warning',
- 1: 'primary',
- 2: 'error',
- 3: 'info',
- 4: 'success',
- 5: 'primary'
- }
- function pad2(n) {
- return `${n}`.padStart(2, '0')
- }
- /** 按主 tab 取列表项内容区高度(rpx),与虚拟列表单项高度一致 */
- function rowBodyRpxForTab(mainTabIndex) {
- if (mainTabIndex === 2) return 280
- if (mainTabIndex === 1) return 400
- return 340
- }
- export default {
- components: {
- 'up-tabs': UTabs,
- 'up-button': UButton,
- 'up-tag': UTag,
- 'up-virtual-list': UVirtualList
- },
- mixins: [tabPage],
- data() {
- return {
- navTitleKey: 'myBookingPage.navTitle',
- mainTabIndex: 0,
- vetSubKey: 'all',
- expertSubKey: 'all',
- orgSubKey: 'all',
- rows: [],
- listLoading: false,
- listLoadingMore: false,
- listTotal: 0,
- pageNum: 1,
- pageSize: LIST_PAGE_SIZE,
- loadMoreTimer: null,
- rowBodyPx: 170,
- marginPx: 10,
- slotHeightPx: 180,
- listHeightPx: 400,
- pageHeightPx: 0,
- vScrollTop: 0,
- cancelBtnStyle: 'width:150rpx;flex-shrink:0;padding:0 20rpx;min-width:0;'
- }
- },
- computed: {
- tabsItemStyle() {
- return { height: '32px', padding: '0 8px' }
- },
- mainTabsList() {
- return [
- { name: this.$t('myBookingPage.tabVet') },
- { name: this.$t('myBookingPage.tabExpert') },
- { name: this.$t('myBookingPage.tabOrg') }
- ]
- },
- currentSubKey() {
- if (this.mainTabIndex === 0) return this.vetSubKey
- if (this.mainTabIndex === 1) return this.expertSubKey
- return this.orgSubKey
- },
- subChips() {
- if (this.mainTabIndex === 0) {
- return [
- { key: 'all', labelKey: 'myBookingPage.subAll' },
- { key: 'upcoming', labelKey: 'myBookingPage.subUpcoming' },
- { key: 'done', labelKey: 'myBookingPage.subDone' },
- { key: 'cancelled', labelKey: 'myBookingPage.subCancelled' }
- ]
- }
- if (this.mainTabIndex === 1) {
- return [
- { key: 'all', labelKey: 'myBookingPage.subAll' },
- { key: 'upcoming', labelKey: 'myBookingPage.subUpcoming' },
- { key: 'todo_review', labelKey: 'myBookingPage.subTodoReview' },
- { key: 'done', labelKey: 'myBookingPage.subDone' },
- { key: 'cancelled', labelKey: 'myBookingPage.subCancelled' }
- ]
- }
- return [
- { key: 'all', labelKey: 'myBookingPage.subAll' },
- { key: 'upcoming', labelKey: 'myBookingPage.subUpcoming' },
- { key: 'done', labelKey: 'myBookingPage.subDone' },
- { key: 'cancelled', labelKey: 'myBookingPage.subCancelled' }
- ]
- },
- listNoMore() {
- return this.listTotal > 0 && this.rows.length >= this.listTotal
- },
- pageStyle() {
- if (this.pageHeightPx > 0) {
- return { height: `${this.pageHeightPx}px` }
- }
- return {}
- }
- },
- watch: {
- mainTabIndex() {
- this.vScrollTop = 0
- this.applyRowMetrics()
- this.loadList(true)
- this.$nextTick(() => {
- this.calcLayoutHeights()
- })
- }
- },
- onShow() {
- this.loadList(true)
- },
- onReady() {
- this.applyRowMetrics()
- this.applyListHeightFallback()
- this.$nextTick(() => this.calcLayoutHeights())
- },
- onUnload() {
- if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
- },
- methods: {
- providerTypeForTab() {
- return PROVIDER_TYPES[this.mainTabIndex] || 1
- },
- statusTabForSub() {
- return STATUS_TAB_MAP[this.currentSubKey] || 'all'
- },
- mapRow(row) {
- const pt = Number(row.providerType)
- let kind = 'vet'
- if (pt === 2) kind = 'org'
- else if (pt === 3) kind = 'expert'
- return {
- id: row.id,
- kind,
- providerType: pt,
- providerName: row.providerName || '',
- appointDate: row.appointDate || '',
- timeStart: row.timeStart || '',
- timeEnd: row.timeEnd || '',
- serviceAddress: row.serviceAddress || '',
- serviceRequirement: row.serviceRequirement || '',
- status: row.status,
- statusLabel: row.statusLabel || '',
- canCancel: !!row.canCancel,
- hasReview: !!row.hasReview,
- rejectReason: row.rejectReason || ''
- }
- },
- loadList(reset = false) {
- if (!reset) {
- if (this.listLoading || this.listLoadingMore || this.listNoMore) {
- return Promise.resolve()
- }
- this.pageNum += 1
- this.listLoadingMore = true
- } else {
- this.pageNum = 1
- this.listLoading = true
- }
- return listMyAppointments({
- providerType: this.providerTypeForTab(),
- statusTab: this.statusTabForSub(),
- pageNum: this.pageNum,
- pageSize: this.pageSize
- })
- .then((res) => {
- const raw = res.rows || []
- this.listTotal = res.total != null ? Number(res.total) : 0
- const mapped = raw.map((row) => this.mapRow(row))
- this.rows = reset ? mapped : this.rows.concat(mapped)
- this.$nextTick(() => {
- this.calcLayoutHeights()
- })
- })
- .catch(() => {
- if (reset) {
- this.rows = []
- this.listTotal = 0
- } else {
- this.pageNum -= 1
- }
- })
- .finally(() => {
- if (reset) {
- this.listLoading = false
- } else {
- this.listLoadingMore = false
- }
- })
- },
- tryAutoLoadMore() {
- if (this.listLoading || this.listLoadingMore || this.listNoMore) return
- const totalH = this.rows.length * this.slotHeightPx
- const viewH = this.listHeightPx
- if (totalH > 0 && totalH <= viewH) {
- this.loadList(false)
- }
- },
- onVirtualListScroll(scrollTop) {
- if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
- this.loadMoreTimer = setTimeout(() => {
- this.loadMoreTimer = null
- this.tryLoadMoreOnScroll(typeof scrollTop === 'number' ? scrollTop : 0)
- }, 180)
- },
- tryLoadMoreOnScroll(scrollTop) {
- if (this.listLoading || this.listLoadingMore || this.listNoMore) return
- const totalH = this.rows.length * this.slotHeightPx
- const viewH = this.listHeightPx
- const threshold = Math.max(this.slotHeightPx * 2, 120)
- if (totalH <= viewH) {
- this.loadList(false)
- return
- }
- if (scrollTop + viewH >= totalH - threshold) {
- this.loadList(false)
- }
- },
- applyRowMetrics() {
- try {
- const rpx = rowBodyRpxForTab(this.mainTabIndex)
- this.marginPx = Math.ceil(uni.upx2px(ROW_GAP_RPX))
- this.rowBodyPx = Math.max(120, Math.ceil(uni.upx2px(rpx)))
- this.slotHeightPx = this.rowBodyPx + this.marginPx
- } catch (e) {
- this.marginPx = 10
- this.rowBodyPx = 170
- this.slotHeightPx = 180
- }
- },
- applyListHeightFallback() {
- const contentH = this.getViewportContentHeight()
- const topFallback = Math.ceil(uni.upx2px(200))
- const bodyPad = Math.ceil(uni.upx2px(MB_BODY_PAD_RPX))
- this.pageHeightPx = contentH
- this.listHeightPx = Math.max(240, contentH - topFallback - bodyPad)
- },
- getNavigationBarHeight() {
- const sys = uni.getSystemInfoSync()
- const statusBar = sys.statusBarHeight || 0
- // #ifdef MP-WEIXIN
- try {
- const menu = uni.getMenuButtonBoundingClientRect()
- if (menu && menu.height) {
- return statusBar + (menu.top - statusBar) * 2 + menu.height
- }
- } catch (e) {
- /* noop */
- }
- // #endif
- return statusBar + 44
- },
- getViewportContentHeight() {
- const sys = uni.getSystemInfoSync()
- const navH = this.getNavigationBarHeight()
- // #ifdef H5
- if (typeof window !== 'undefined' && window.innerHeight) {
- return Math.max(320, window.innerHeight - navH)
- }
- // #endif
- const screenH = sys.screenHeight || sys.windowHeight || 600
- return Math.max(320, screenH - navH)
- },
- calcLayoutHeights() {
- const contentH = this.getViewportContentHeight()
- const bodyPad = Math.ceil(uni.upx2px(MB_BODY_PAD_RPX))
- this.pageHeightPx = contentH
- uni.createSelectorQuery()
- .in(this)
- .select('.mb-top')
- .boundingClientRect((topRect) => {
- const topH =
- topRect && topRect.height > 0 ? Math.ceil(topRect.height) : Math.ceil(uni.upx2px(200))
- this.listHeightPx = Math.max(240, contentH - topH - bodyPad)
- this.$nextTick(() => {
- this.$refs.mbVList?.measureContainerHeight?.()
- this.tryAutoLoadMore()
- })
- })
- .exec()
- },
- onMainTabUpdate(idx) {
- this.mainTabIndex = typeof idx === 'number' ? idx : 0
- },
- setSubKey(key) {
- if (this.mainTabIndex === 0) this.vetSubKey = key
- else if (this.mainTabIndex === 1) this.expertSubKey = key
- else this.orgSubKey = key
- this.vScrollTop = 0
- this.loadList(true)
- },
- titleFor(item) {
- return item.providerName || '—'
- },
- parseAppointDate(dateStr) {
- if (!dateStr) return null
- const d = new Date(String(dateStr).replace(/-/g, '/'))
- return isNaN(d.getTime()) ? null : d
- },
- timeLine(item) {
- const d = this.parseAppointDate(item.appointDate)
- if (!d) {
- return this.$t('myBookingPage.timeTpl', {
- md: '—',
- wd: '',
- start: item.timeStart || '--:--',
- end: item.timeEnd || '--:--'
- })
- }
- const m = pad2(d.getMonth() + 1)
- const day = pad2(d.getDate())
- const wd = this.$t(`bookingServicePage.wd${d.getDay()}`)
- return this.$t('myBookingPage.timeTpl', {
- md: `${m}-${day}`,
- wd,
- start: item.timeStart || '--:--',
- end: item.timeEnd || '--:--'
- })
- },
- addressLine(item) {
- const s = (item.serviceAddress || '').trim()
- return s || this.$t('myBookingPage.noAddress')
- },
- needLine(item) {
- const s = (item.serviceRequirement || '').trim()
- return s || this.$t('myBookingPage.noNeed')
- },
- statusLabel(item) {
- if (item.statusLabel) return item.statusLabel
- const st = Number(item.status)
- const map = {
- 0: 'myBookingPage.vetStatusPending',
- 1: 'myBookingPage.vetStatusConfirmed',
- 2: 'myBookingPage.vetStatusRejected',
- 3: 'myBookingPage.vetStatusCancelled',
- 4: 'myBookingPage.vetStatusCompleted',
- 5: 'myBookingPage.orgStatusBooked'
- }
- return this.$t(map[st] || map[0])
- },
- statusTagType(item) {
- const st = Number(item.status)
- return STATUS_TAG_TYPE[st] || 'info'
- },
- showCancelBtn(item) {
- return !!item.canCancel
- },
- showExpertRateRow(item) {
- return item.kind === 'expert' && Number(item.status) === 4
- },
- onCancelBook(item) {
- uni.showModal({
- title: this.$t('myBookingPage.confirmCancelTitle'),
- content: this.$t('myBookingPage.confirmCancelBody'),
- confirmText: this.$t('myBookingPage.confirmOk'),
- cancelText: this.$t('myBookingPage.confirmNo'),
- success: (res) => {
- if (!res.confirm) return
- cancelMyAppointment(item.id)
- .then(() => {
- uni.showToast({ title: this.$t('myBookingPage.toastCancelled'), icon: 'none' })
- this.loadList(true)
- })
- .catch(() => {
- uni.showToast({ title: this.$t('myBookingPage.cancelFail'), icon: 'none' })
- })
- }
- })
- },
- reviewNavUrl(item, mode) {
- const q = [
- `appointmentId=${encodeURIComponent(item.id)}`,
- `mode=${encodeURIComponent(mode)}`,
- `providerName=${encodeURIComponent(item.providerName || '')}`
- ].join('&')
- return `/package-a/review-history/index?${q}`
- },
- onReview(item) {
- uni.navigateTo({ url: this.reviewNavUrl(item, 'submit') })
- },
- onViewReview(item) {
- uni.navigateTo({ url: this.reviewNavUrl(item, 'view') })
- }
- }
- }
- </script>
- <style lang="scss" scoped>
- @import '@/styles/morandi.scss';
- @import '@/styles/tab-page.scss';
- .mb-page {
- display: flex;
- flex-direction: column;
- min-width: 0;
- width: 100%;
- height: 100%;
- min-height: 100%;
- overflow: hidden;
- box-sizing: border-box;
- background: $morandi-bg-page;
- }
- .mb-top {
- flex-shrink: 0;
- min-width: 0;
- }
- .mb-card {
- background: #ffffff;
- padding: 16rpx 20rpx;
- box-sizing: border-box;
- }
- .mb-card--tabs {
- padding: 8rpx 12rpx 4rpx;
- }
- .mb-tabs {
- width: 100%;
- }
- .mb-tabs :deep(.u-tabs__wrapper__nav__item__text) {
- line-height: 1.2;
- }
- .mb-chip-scroll {
- width: 100%;
- white-space: nowrap;
- background: #ffffff;
- border-bottom: 1rpx solid $morandi-border-soft;
- }
- .mb-chip-row {
- display: inline-flex;
- flex-direction: row;
- align-items: center;
- gap: 16rpx;
- padding: 12rpx 20rpx 16rpx;
- min-width: min-content;
- }
- .mb-chip {
- flex-shrink: 0;
- padding: 10rpx 22rpx;
- border-radius: 999rpx;
- border: 1rpx solid #d6d3d1;
- background: #fafaf9;
- }
- .mb-chip--on {
- border-color: #22c55e;
- background: rgba(34, 197, 94, 0.08);
- }
- .mb-chip__txt {
- font-size: 24rpx;
- color: #78716c;
- font-weight: 500;
- }
- .mb-chip--on .mb-chip__txt {
- color: #15803d;
- font-weight: 600;
- }
- .mb-body {
- flex: 1;
- min-height: 0;
- height: 0;
- min-width: 0;
- display: flex;
- flex-direction: column;
- padding: 0 24rpx 16rpx;
- box-sizing: border-box;
- overflow: hidden;
- }
- .mb-list-wrap {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- min-width: 0;
- }
- .mb-empty {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 0;
- padding: 48rpx 24rpx;
- box-sizing: border-box;
- }
- .mb-empty__txt {
- color: $morandi-text-muted;
- }
- .mb-vlist {
- flex: 1;
- min-height: 0;
- width: 100%;
- height: 100%;
- box-sizing: border-box;
- padding: 10rpx 0 0;
- }
- .mb-cell {
- box-sizing: border-box;
- padding-bottom: 10rpx;
- }
- .mb-item {
- display: flex;
- flex-direction: column;
- gap: 8rpx;
- padding: 20rpx;
- box-sizing: border-box;
- background: #ffffff;
- border-radius: 12rpx;
- border: 1rpx solid $morandi-border-soft;
- min-width: 0;
- }
- .mb-item__title {
- font-size: 30rpx;
- font-weight: 600;
- color: #111827;
- line-height: 1.35;
- word-break: break-word;
- }
- .mb-item__line {
- font-size: 24rpx;
- color: $morandi-text-muted;
- line-height: 1.45;
- word-break: break-word;
- }
- .mb-item__line--grow {
- flex: 1;
- min-width: 0;
- }
- .mb-item__k {
- color: $morandi-text-muted;
- font-weight: 500;
- }
- .mb-item__row {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
- gap: 12rpx;
- margin-top: 4rpx;
- min-width: 0;
- }
- .mb-item__row--status {
- justify-content: flex-start;
- gap: 8rpx;
- }
- .mb-item__row--status :deep(.u-tag) {
- flex-shrink: 0;
- }
- .mb-item__row--end {
- width: 100%;
- justify-content: flex-end;
- flex-wrap: wrap;
- margin-top: 8rpx;
- }
- /* up-button 根节点默认易撑满行宽,需收窄后 flex-end 才能靠右 */
- .mb-item__row--end :deep(.u-button) {
- width: auto !important;
- flex-shrink: 0;
- }
- .mb-page.lang-bo {
- .mb-item__title {
- font-size: 26rpx;
- line-height: 1.65;
- }
- .mb-item__line {
- font-size: 22rpx;
- }
- }
- </style>
|