| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733 |
- <template>
- <!-- 预约机构:见 doc/app/预约服务/预约服务接口说明.md -->
- <view :class="pageRootClass" class="tab-page bgo-page" :style="pageStyle">
- <scroll-view scroll-y class="bgo-scroll" enable-back-to-top :show-scrollbar="false">
- <view class="bgo-card bgo-card--top">
- <view class="bgo-hero">
- <up-avatar
- v-if="photoSrc"
- shape="circle"
- :src="photoSrc"
- size="72"
- font-size="26"
- />
- <up-avatar
- v-else
- shape="circle"
- :text="avatarText"
- size="72"
- font-size="26"
- bg-color="#9ca3af"
- color="#ffffff"
- />
- <view class="bgo-hero__right">
- <text class="bgo-hero__title text-body">{{ orgTitle }}</text>
- <text class="bgo-hero__sub text-body">{{ orgSub }}</text>
- <text class="bgo-hero__contact text-body">{{ contactLine }}</text>
- </view>
- </view>
- <text class="bgo-line text-body"><text class="bgo-line__k">{{ $t('bookingOrgPage.labelIntro') }}</text>{{ introBody }}</text>
- <text class="bgo-line text-body"><text class="bgo-line__k">{{ $t('bookingOrgPage.labelAddress') }}</text>{{ detailAddress }}</text>
- <text class="bgo-line text-body"><text class="bgo-line__k">{{ $t('bookingOrgPage.labelHours') }}</text>{{ serviceHours }}</text>
- </view>
- <view class="bgo-card bgo-card--bottom">
- <text class="bgo-section-title text-body">{{ $t('bookingOrgPage.pickTimeTitle') }}</text>
- <scroll-view scroll-x class="bgo-date-scroll" :show-scrollbar="false" enable-flex>
- <view class="bgo-date-row">
- <view
- v-for="chip in dateChips"
- :key="chip.key"
- class="bgo-chip"
- :class="{
- 'bgo-chip--on': !chip.weekdayDisabled && selectedDateKey === chip.key,
- 'bgo-chip--disabled': chip.weekdayDisabled
- }"
- role="button"
- @tap="onChipTap(chip)"
- >
- <view class="bgo-chip__stack">
- <text class="bgo-chip__name text-body">{{ chip.weekdayLabel }}</text>
- <text v-if="!chip.weekdayDisabled" class="bgo-chip__date text-body">{{ chip.dateShort }}</text>
- <text v-else class="bgo-chip__full text-body">{{ $t('bookingOrgPage.dayNotAvailable') }}</text>
- </view>
- </view>
- </view>
- </scroll-view>
- <up-divider :hairline="true" line-color="#e5e7eb" margin="12rpx 0 8rpx" />
- <up-button
- type="success"
- shape="circle"
- :disabled="bookBtnDisabled"
- :text="bookBtnText"
- @click="openBookPopup"
- />
- </view>
- <view class="bgo-footer-spacer" />
- </scroll-view>
- <up-popup
- :show="bookPopupShow"
- mode="center"
- :round="16"
- :close-on-click-overlay="true"
- :safe-area-inset-bottom="true"
- :custom-style="bookPopupStyle"
- @update:show="bookPopupShow = $event"
- >
- <view class="bgo-popup">
- <scroll-view scroll-y class="bgo-popup__scroll" :show-scrollbar="false">
- <text class="bgo-popup__h1 text-body">{{ $t('bookingOrgPage.popupBookTitle') }}</text>
- <text class="bgo-popup__row text-body">{{ $t('bookingOrgPage.popupOrgName') }}{{ orgTitle }}</text>
- <text class="bgo-popup__row text-body">{{ $t('bookingOrgPage.popupBookDate') }}{{ popupDateLine }}</text>
- <text class="bgo-popup__h2 text-body">{{ $t('bookingOrgPage.popupServiceTitle') }}</text>
- <up-form
- ref="bookFormRef"
- label-position="top"
- :model="formModel"
- :rules="formRules"
- label-width="100%"
- :border-bottom="false"
- error-type="toast"
- >
- <up-form-item :label="$t('bookingOrgPage.formBooker')" prop="bookerName" required>
- <up-input v-model="formModel.bookerName" border="surround" clearable />
- </up-form-item>
- <up-form-item :label="$t('bookingOrgPage.formPhone')" prop="phone" required>
- <up-input v-model="formModel.phone" type="number" maxlength="11" border="surround" clearable />
- </up-form-item>
- </up-form>
- </scroll-view>
- <view class="bgo-popup__actions">
- <up-button
- class="bgo-popup__btn bgo-popup__btn--cancel"
- :text="$t('bookingOrgPage.btnCancel')"
- shape="circle"
- plain
- hairline
- @click="closeBookPopup"
- />
- <up-button
- class="bgo-popup__btn"
- type="success"
- :loading="submitting"
- :text="submitting ? $t('bookingOrgPage.submitting') : $t('bookingOrgPage.btnSubmit')"
- shape="circle"
- @click="onSubmitBook"
- />
- </view>
- </view>
- </up-popup>
- </view>
- </template>
- <script>
- import UAvatar from 'uview-plus/components/u-avatar/u-avatar.vue'
- import UButton from 'uview-plus/components/u-button/u-button.vue'
- import UDivider from 'uview-plus/components/u-divider/u-divider.vue'
- import UForm from 'uview-plus/components/u-form/u-form.vue'
- import UFormItem from 'uview-plus/components/u-form-item/u-form-item.vue'
- import UInput from 'uview-plus/components/u-input/u-input.vue'
- import UPopup from 'uview-plus/components/u-popup/u-popup.vue'
- import tabPage from '@/mixins/tabPage'
- import pageViewport from '@/mixins/pageViewport'
- import { resolveResourceUrl } from '@/utils/resourceUrl'
- import { ensureApiToken } from '@/utils/apiAuth'
- import { useUserStore } from '@/store/user'
- import {
- BOOKING_PROVIDER_TYPE,
- checkAppointmentBooked,
- listBookingDates,
- loadBookingResourceCache,
- submitBookingAppointment
- } from '@/api/bookingService'
- const ORG_RESOURCE_TYPE = '004003'
- function parseServiceWeekdays(raw) {
- if (raw == null || String(raw).trim() === '') return null
- const set = new Set()
- for (const part of String(raw).split(',')) {
- const n = parseInt(part.trim(), 10)
- if (n >= 1 && n <= 7) set.add(n)
- }
- return set.size ? set : null
- }
- export default {
- components: {
- 'up-avatar': UAvatar,
- 'up-button': UButton,
- 'up-divider': UDivider,
- 'up-form': UForm,
- 'up-form-item': UFormItem,
- 'up-input': UInput,
- 'up-popup': UPopup
- },
- mixins: [tabPage, pageViewport],
- data() {
- return {
- navTitleKey: 'bookingOrgPage.navTitle',
- orgId: '',
- resourceType: ORG_RESOURCE_TYPE,
- resource: null,
- apiDates: [],
- bookedDateMap: {},
- selectedDateKey: '',
- bookPopupShow: false,
- submitting: false,
- formModel: {
- bookerName: '',
- phone: ''
- },
- bookPopupStyle: {
- width: '90%',
- maxWidth: '680px',
- maxHeight: '72vh',
- overflow: 'hidden',
- display: 'flex',
- flexDirection: 'column'
- }
- }
- },
- computed: {
- allowedWeekdays() {
- return parseServiceWeekdays(this.resource && this.resource.serviceWeekdays)
- },
- dateChips() {
- return (this.apiDates || []).map((d) => {
- const key = d.appointDate || ''
- const weekday = d.weekday
- const allowed = this.allowedWeekdays
- const weekdayDisabled =
- allowed != null && weekday != null && !allowed.has(Number(weekday))
- return {
- key,
- dateShort: d.dateMmDd || '',
- weekday,
- weekdayLabel: this.weekdayLabelFor(d),
- weekdayDisabled
- }
- })
- },
- photoSrc() {
- const url = this.resource && this.resource.photoFileUrl
- return url ? resolveResourceUrl(url) : ''
- },
- selectedDateBooked() {
- return !!this.bookedDateMap[this.selectedDateKey]
- },
- bookBtnDisabled() {
- const chip = this.dateChips.find((c) => c.key === this.selectedDateKey)
- if (!chip || chip.weekdayDisabled) return true
- return this.selectedDateBooked
- },
- bookBtnText() {
- if (this.selectedDateBooked) return this.$t('bookingOrgPage.btnBooked')
- return this.$t('bookingOrgPage.btnBook')
- },
- avatarText() {
- const name = (this.resource && this.resource.resourceName) || ''
- return name.slice(0, 1) || '?'
- },
- orgTitle() {
- return (this.resource && this.resource.resourceName) || this.$t('bookingOrgPage.noResource')
- },
- orgSub() {
- return (this.resource && this.resource.affiliatedUnit) || this.$t('bookingServicePage.noUnit')
- },
- introBody() {
- const intro = (this.resource && this.resource.introduction) || ''
- return intro.trim() || this.$t('bookingOrgPage.noIntro')
- },
- contactLine() {
- const phone = this.resource && this.resource.contactPhone
- if (!phone) return this.$t('bookingOrgPage.contactLabel') + '—'
- return this.$t('bookingOrgPage.contactLabel') + phone
- },
- detailAddress() {
- const addr = (this.resource && this.resource.detailAddress) || ''
- return addr.trim() || this.$t('bookingOrgPage.noAddress')
- },
- serviceHours() {
- const start = this.resource && this.resource.serviceStartTime
- const end = this.resource && this.resource.serviceEndTime
- if (start && end) {
- return this.$t('bookingOrgPage.hoursTpl', { start, end })
- }
- return this.$t('bookingOrgPage.noHours')
- },
- defaultTimeSlot() {
- const start = this.resource && this.resource.serviceStartTime
- const end = this.resource && this.resource.serviceEndTime
- if (start && end) return `${start}~${end}`
- return '08:00~17:00'
- },
- popupDateLine() {
- const chip = this.dateChips.find((c) => c.key === this.selectedDateKey)
- if (!chip || chip.weekdayDisabled) return ''
- return this.formatMdWeekday(chip)
- },
- formRules() {
- return {
- bookerName: [
- {
- required: true,
- message: this.$t('bookingOrgPage.errBooker'),
- trigger: ['blur', 'change']
- }
- ],
- phone: [
- {
- required: true,
- message: this.$t('bookingOrgPage.errPhone'),
- trigger: ['blur', 'change']
- },
- {
- pattern: /^1[3-9]\d{9}$/,
- message: this.$t('bookingOrgPage.errPhoneFmt'),
- trigger: ['blur', 'change']
- }
- ]
- }
- }
- },
- onLoad(query) {
- if (!ensureApiToken()) return
- const q = query || {}
- this.orgId = q.id ? decodeURIComponent(String(q.id)) : ''
- if (q.resourceType) {
- try {
- this.resourceType = decodeURIComponent(String(q.resourceType))
- } catch (e) {
- this.resourceType = String(q.resourceType)
- }
- }
- this.loadResource()
- this.loadBookingDates()
- },
- methods: {
- weekdayLabelFor(d) {
- const w = d && d.weekday
- if (w != null && w >= 1 && w <= 7) {
- const dowKey = w === 7 ? 0 : w
- return this.$t(`bookingServicePage.wd${dowKey}`)
- }
- return (d && d.weekdayName) || ''
- },
- loadResource() {
- if (!this.orgId) {
- uni.showToast({ title: this.$t('bookingOrgPage.noResource'), icon: 'none' })
- setTimeout(() => uni.navigateBack(), 1500)
- return
- }
- this.resource = loadBookingResourceCache(this.resourceType, this.orgId)
- if (!this.resource) {
- uni.showToast({ title: this.$t('bookingOrgPage.noResource'), icon: 'none' })
- setTimeout(() => uni.navigateBack(), 1500)
- return
- }
- if (this.apiDates.length) {
- this.refreshBookedFlags()
- }
- },
- loadBookingDates() {
- listBookingDates()
- .then((res) => {
- this.apiDates = Array.isArray(res.data) ? res.data : []
- this.$nextTick(() => {
- this.ensureValidSelection()
- if (this.orgId) {
- this.refreshBookedFlags()
- }
- })
- })
- .catch(() => {
- this.apiDates = []
- })
- },
- refreshBookedFlags() {
- if (!this.orgId || !this.apiDates.length) return Promise.resolve()
- const providerId = Number(this.orgId) || this.orgId
- const dates = this.apiDates.map((d) => d.appointDate).filter(Boolean)
- return Promise.all(
- dates.map((appointDate) =>
- checkAppointmentBooked({
- providerType: BOOKING_PROVIDER_TYPE.ORG,
- providerId,
- appointDate
- })
- .then((res) => ({
- appointDate,
- booked: !!(res.data && res.data.booked)
- }))
- .catch(() => ({ appointDate, booked: false }))
- )
- ).then((results) => {
- const next = { ...this.bookedDateMap }
- results.forEach(({ appointDate, booked }) => {
- next[appointDate] = booked
- })
- this.bookedDateMap = next
- this.$nextTick(() => this.ensureValidSelection())
- })
- },
- fetchDateBooked(appointDate) {
- if (!this.orgId || !appointDate) return Promise.resolve(false)
- return checkAppointmentBooked({
- providerType: BOOKING_PROVIDER_TYPE.ORG,
- providerId: Number(this.orgId) || this.orgId,
- appointDate
- })
- .then((res) => {
- const booked = !!(res.data && res.data.booked)
- this.bookedDateMap = { ...this.bookedDateMap, [appointDate]: booked }
- return booked
- })
- .catch(() => false)
- },
- formatMdWeekday(chip) {
- if (!chip || !chip.key) return ''
- return `${chip.dateShort}(${chip.weekdayLabel})`
- },
- ensureValidSelection() {
- const cur = this.dateChips.find((c) => c.key === this.selectedDateKey && !c.weekdayDisabled)
- if (cur) return
- const ok = this.dateChips.find((c) => !c.weekdayDisabled)
- this.selectedDateKey = ok ? ok.key : ''
- },
- onChipTap(chip) {
- if (chip.weekdayDisabled) {
- uni.showToast({
- title: this.$t('bookingOrgPage.dayNotAvailable'),
- icon: 'none'
- })
- return
- }
- this.selectedDateKey = chip.key
- this.fetchDateBooked(chip.key)
- },
- openBookPopup() {
- if (!this.resource || !this.orgId) {
- uni.showToast({ title: this.$t('bookingOrgPage.noResource'), icon: 'none' })
- return
- }
- const sel = this.dateChips.find((c) => c.key === this.selectedDateKey)
- if (!sel || sel.weekdayDisabled) {
- uni.showToast({
- title: this.$t('bookingOrgPage.toastPickDate'),
- icon: 'none'
- })
- return
- }
- if (this.selectedDateBooked) return
- const addr = (this.resource.detailAddress || '').trim()
- if (!addr) {
- uni.showToast({ title: this.$t('bookingOrgPage.noAddress'), icon: 'none' })
- return
- }
- this.resetBookForm()
- this.bookPopupShow = true
- },
- closeBookPopup() {
- this.bookPopupShow = false
- },
- resetBookForm() {
- const store = useUserStore()
- const name = store.displayName()
- this.formModel = {
- bookerName: name || '',
- phone: ''
- }
- this.$nextTick(() => {
- this.$refs.bookFormRef?.clearValidate?.()
- })
- },
- onSubmitBook() {
- if (this.submitting) return
- this.$refs.bookFormRef
- ?.validate?.()
- .then(() => {
- const addr = (this.resource.detailAddress || '').trim()
- if (!addr) {
- uni.showToast({ title: this.$t('bookingOrgPage.noAddress'), icon: 'none' })
- return Promise.reject(new Error('no address'))
- }
- this.submitting = true
- return submitBookingAppointment({
- resourceType: this.resourceType,
- resourceId: Number(this.orgId) || this.orgId,
- appointDate: this.selectedDateKey,
- appointeeName: (this.formModel.bookerName || '').trim(),
- contactPhone: (this.formModel.phone || '').trim(),
- timeSlot: this.defaultTimeSlot,
- serviceAddress: addr
- })
- })
- .then(() => {
- this.bookedDateMap = { ...this.bookedDateMap, [this.selectedDateKey]: true }
- uni.showToast({
- title: this.$t('bookingOrgPage.toastSubmitOk'),
- icon: 'success'
- })
- this.bookPopupShow = false
- })
- .catch((err) => {
- const msg = err && (err.msg || err.message || '')
- if (String(msg).includes('已满')) {
- uni.showToast({ title: this.$t('bookingOrgPage.toastDayFull'), icon: 'none' })
- }
- })
- .finally(() => {
- this.submitting = false
- })
- }
- }
- }
- </script>
- <style lang="scss" scoped>
- @import '@/styles/morandi.scss';
- @import '@/styles/tab-page.scss';
- .bgo-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;
- }
- .bgo-scroll {
- // flex: 1;
- // min-height: 0;
- // min-width: 0;
- // height: 0;
- height: 100%;
- box-sizing: border-box;
- padding: 20rpx 24rpx 24rpx;
- }
- .bgo-card {
- background: #ffffff;
- border-radius: 16rpx;
- border: 1rpx solid $morandi-border-soft;
- padding: 24rpx;
- box-sizing: border-box;
- }
- .bgo-card--top {
- margin-bottom: 10rpx;
- }
- .bgo-hero {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: 20rpx;
- min-width: 0;
- margin-bottom: 20rpx;
- }
- .bgo-hero__right {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 8rpx;
- }
- .bgo-hero__title {
- font-size: 32rpx;
- font-weight: 600;
- color: #111827;
- line-height: 1.35;
- word-break: break-word;
- }
- .bgo-hero__sub {
- font-size: 24rpx;
- color: $morandi-text-secondary;
- line-height: 1.45;
- word-break: break-word;
- }
- .bgo-hero__contact {
- font-size: 22rpx;
- color: $morandi-text-muted;
- line-height: 1.45;
- word-break: break-word;
- }
- .bgo-line {
- display: block;
- font-size: 24rpx;
- line-height: 1.55;
- color: #111827;
- margin-top: 12rpx;
- word-break: break-word;
- }
- .bgo-line__k {
- color: $morandi-text-muted;
- margin-right: 4rpx;
- }
- .bgo-section-title {
- display: block;
- font-size: 28rpx;
- font-weight: 600;
- color: #111827;
- margin-bottom: 16rpx;
- }
- .bgo-date-scroll {
- width: 100%;
- white-space: nowrap;
- }
- .bgo-date-row {
- display: inline-flex;
- flex-direction: row;
- align-items: stretch;
- gap: 12rpx;
- padding: 4rpx 0 8rpx;
- min-width: min-content;
- }
- .bgo-chip {
- flex-shrink: 0;
- min-width: 96rpx;
- padding: 12rpx 16rpx;
- border-radius: 12rpx;
- border: 1rpx solid #e5e7eb;
- background: #ffffff;
- box-sizing: border-box;
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: center;
- }
- .bgo-chip--on {
- background: #22c55e;
- border-color: #16a34a;
- }
- .bgo-chip--disabled {
- opacity: 0.75;
- background: #f9fafb;
- border-color: #e5e7eb;
- }
- .bgo-chip__stack {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 4rpx;
- }
- .bgo-chip__name {
- font-size: 24rpx;
- color: #111827;
- font-weight: 500;
- }
- .bgo-chip__date {
- font-size: 22rpx;
- color: #374151;
- }
- .bgo-chip__full {
- font-size: 22rpx;
- color: #9ca3af;
- font-weight: 500;
- }
- .bgo-chip--on .bgo-chip__name,
- .bgo-chip--on .bgo-chip__date {
- color: #ffffff;
- font-weight: 600;
- }
- .bgo-chip--disabled .bgo-chip__name {
- color: #9ca3af;
- }
- .bgo-footer-spacer {
- height: 32rpx;
- }
- .bgo-popup {
- display: flex;
- flex-direction: column;
- min-width: 0;
- max-height: 72vh;
- padding: 24rpx 24rpx 16rpx;
- box-sizing: border-box;
- }
- .bgo-popup__scroll {
- flex: 1;
- min-height: 0;
- max-height: 48vh;
- }
- .bgo-popup__h1 {
- display: block;
- font-size: 30rpx;
- font-weight: 600;
- color: #111827;
- margin-bottom: 20rpx;
- }
- .bgo-popup__h2 {
- display: block;
- font-size: 28rpx;
- font-weight: 600;
- color: #111827;
- margin: 24rpx 0 16rpx;
- }
- .bgo-popup__row {
- display: block;
- font-size: 26rpx;
- color: #374151;
- line-height: 1.5;
- margin-bottom: 8rpx;
- word-break: break-word;
- }
- .bgo-popup__actions {
- display: flex;
- flex-direction: row;
- gap: 20rpx;
- padding-top: 16rpx;
- border-top: 1rpx solid #e5e7eb;
- margin-top: 8rpx;
- }
- .bgo-popup__btn {
- flex: 1;
- min-width: 0;
- }
- .bgo-popup__btn--cancel {
- background: #ffffff !important;
- }
- .bgo-page.lang-bo {
- .bgo-hero__title {
- font-size: 28rpx;
- line-height: 1.75;
- }
- }
- </style>
|