西藏巴青项目

index.vue 25KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904
  1. <template>
  2. <!-- 预约兽医:见 doc/app/预约服务/预约服务接口说明.md -->
  3. <view :class="pageRootClass" class="tab-page bv-page" :style="pageStyle">
  4. <scroll-view scroll-y class="bv-scroll" enable-back-to-top :show-scrollbar="false">
  5. <!-- 上:兽医信息 -->
  6. <view class="bv-card bv-card--top">
  7. <view class="bv-hero">
  8. <up-avatar
  9. v-if="photoSrc"
  10. shape="circle"
  11. :src="photoSrc"
  12. size="72"
  13. font-size="26"
  14. />
  15. <up-avatar
  16. v-else
  17. shape="circle"
  18. :text="avatarText"
  19. size="72"
  20. font-size="26"
  21. bg-color="#9ca3af"
  22. color="#ffffff"
  23. />
  24. <view class="bv-hero__right">
  25. <text class="bv-hero__title text-body">{{ vetTitle }}</text>
  26. <text class="bv-hero__sub text-body">{{ vetSub }}</text>
  27. <text class="bv-hero__contact text-body">{{ contactLine }}</text>
  28. </view>
  29. </view>
  30. <text class="bv-line text-body"><text class="bv-line__k">{{ $t('bookingVetPage.labelIntro') }}</text>{{ introBody }}</text>
  31. <text class="bv-line text-body"><text class="bv-line__k">{{ $t('bookingVetPage.labelAddress') }}</text>{{ detailAddress }}</text>
  32. <text class="bv-line text-body"><text class="bv-line__k">{{ $t('bookingVetPage.labelHours') }}</text>{{ serviceHours }}</text>
  33. <text class="bv-line text-body"><text class="bv-line__k">{{ $t('bookingVetPage.labelArea') }}</text>{{ serviceArea }}</text>
  34. <text class="bv-line text-body"><text class="bv-line__k">{{ $t('bookingVetPage.labelFee') }}</text>{{ feeLine }}</text>
  35. </view>
  36. <!-- 下:选择时间 + 预约 -->
  37. <view class="bv-card bv-card--bottom">
  38. <text class="bv-section-title text-body">{{ $t('bookingVetPage.pickTimeTitle') }}</text>
  39. <scroll-view scroll-x class="bv-date-scroll" :show-scrollbar="false" enable-flex>
  40. <view class="bv-date-row">
  41. <view
  42. v-for="chip in dateChips"
  43. :key="chip.key"
  44. class="bv-chip"
  45. :class="{
  46. 'bv-chip--on': !chip.weekdayDisabled && selectedDateKey === chip.key,
  47. 'bv-chip--disabled': chip.weekdayDisabled
  48. }"
  49. role="button"
  50. @tap="onDateChipTap(chip)"
  51. >
  52. <view class="bv-chip__stack">
  53. <text class="bv-chip__name text-body">{{ chip.weekdayLabel }}</text>
  54. <text v-if="!chip.weekdayDisabled" class="bv-chip__date text-body">{{ chip.dateShort }}</text>
  55. <text v-else class="bv-chip__full text-body">{{ $t('bookingVetPage.dayNotAvailable') }}</text>
  56. </view>
  57. </view>
  58. </view>
  59. </scroll-view>
  60. <up-divider :hairline="true" line-color="#e5e7eb" margin="12rpx 0 8rpx" />
  61. <up-button
  62. type="success"
  63. shape="circle"
  64. :disabled="bookBtnDisabled"
  65. :text="bookBtnText"
  66. @click="openBookPopup"
  67. />
  68. </view>
  69. <view class="bv-footer-spacer" />
  70. </scroll-view>
  71. <!-- 预约信息弹窗 -->
  72. <up-popup
  73. :show="bookPopupShow"
  74. mode="center"
  75. :round="16"
  76. :close-on-click-overlay="true"
  77. :safe-area-inset-bottom="true"
  78. :custom-style="bookPopupStyle"
  79. @update:show="bookPopupShow = $event"
  80. >
  81. <view class="bv-popup">
  82. <scroll-view scroll-y class="bv-popup__scroll" :show-scrollbar="false">
  83. <text class="bv-popup__h1 text-body">{{ $t('bookingVetPage.popupBookTitle') }}</text>
  84. <text class="bv-popup__row text-body">{{ $t('bookingVetPage.popupVetName') }}{{ vetTitle }}</text>
  85. <text class="bv-popup__row text-body">{{ $t('bookingVetPage.popupBookDate') }}{{ popupDateLine }}</text>
  86. <text class="bv-popup__h2 text-body">{{ $t('bookingVetPage.popupServiceTitle') }}</text>
  87. <up-form
  88. ref="bookFormRef"
  89. label-position="top"
  90. :model="formModel"
  91. :rules="formRules"
  92. label-width="100%"
  93. :border-bottom="false"
  94. error-type="toast"
  95. >
  96. <up-form-item :label="$t('bookingVetPage.formBooker')" prop="bookerName" required>
  97. <up-input v-model="formModel.bookerName" border="surround" clearable />
  98. </up-form-item>
  99. <up-form-item :label="$t('bookingVetPage.formPhone')" prop="phone" required>
  100. <up-input v-model="formModel.phone" type="number" maxlength="11" border="surround" clearable />
  101. </up-form-item>
  102. <up-form-item :label="$t('bookingVetPage.formSlot')" prop="timeSlot" required>
  103. <view class="bv-slot-row" role="button" @tap="openTimePicker">
  104. <up-input
  105. v-model="formModel.timeSlot"
  106. readonly
  107. border="surround"
  108. :placeholder="$t('bookingVetPage.formSlotPh')"
  109. />
  110. <up-icon name="arrow-right" color="#78716c" :size="18" />
  111. </view>
  112. </up-form-item>
  113. <up-form-item :label="$t('bookingVetPage.formAddress')" prop="address" required>
  114. <up-textarea
  115. v-model="formModel.address"
  116. height="120"
  117. count
  118. maxlength="200"
  119. border="surround"
  120. :placeholder="$t('bookingVetPage.formAddressPh')"
  121. />
  122. </up-form-item>
  123. <up-form-item :label="$t('bookingVetPage.formNeed')" prop="needDesc" required>
  124. <up-textarea
  125. v-model="formModel.needDesc"
  126. height="120"
  127. count
  128. maxlength="300"
  129. border="surround"
  130. :placeholder="$t('bookingVetPage.formNeedPh')"
  131. />
  132. </up-form-item>
  133. </up-form>
  134. </scroll-view>
  135. <view class="bv-popup__actions">
  136. <up-button
  137. class="bv-popup__btn bv-popup__btn--cancel"
  138. :text="$t('bookingVetPage.btnCancel')"
  139. shape="circle"
  140. plain
  141. hairline
  142. @click="closeBookPopup"
  143. />
  144. <up-button
  145. class="bv-popup__btn"
  146. type="success"
  147. :loading="submitting"
  148. :text="submitting ? $t('bookingVetPage.submitting') : $t('bookingVetPage.btnSubmit')"
  149. shape="circle"
  150. @click="onSubmitBook"
  151. />
  152. </view>
  153. </view>
  154. </up-popup>
  155. <up-picker
  156. :show="timePickerShow"
  157. :columns="[timeSlotOptions]"
  158. v-model="pickerModel"
  159. :show-toolbar="true"
  160. :title="$t('bookingVetPage.formSlot')"
  161. popup-mode="bottom"
  162. :close-on-click-overlay="true"
  163. :round="12"
  164. @update:show="timePickerShow = $event"
  165. @confirm="onTimePickerConfirm"
  166. />
  167. </view>
  168. </template>
  169. <script>
  170. import UAvatar from 'uview-plus/components/u-avatar/u-avatar.vue'
  171. import UButton from 'uview-plus/components/u-button/u-button.vue'
  172. import UDivider from 'uview-plus/components/u-divider/u-divider.vue'
  173. import UForm from 'uview-plus/components/u-form/u-form.vue'
  174. import UFormItem from 'uview-plus/components/u-form-item/u-form-item.vue'
  175. import UIcon from 'uview-plus/components/u-icon/u-icon.vue'
  176. import UInput from 'uview-plus/components/u-input/u-input.vue'
  177. import UPicker from 'uview-plus/components/u-picker/u-picker.vue'
  178. import UPopup from 'uview-plus/components/u-popup/u-popup.vue'
  179. import UTextarea from 'uview-plus/components/u-textarea/u-textarea.vue'
  180. import tabPage from '@/mixins/tabPage'
  181. import pageViewport from '@/mixins/pageViewport'
  182. import { resolveResourceUrl } from '@/utils/resourceUrl'
  183. import { ensureApiToken } from '@/utils/apiAuth'
  184. import { useUserStore } from '@/store/user'
  185. import {
  186. BOOKING_PROVIDER_TYPE,
  187. checkAppointmentBooked,
  188. listBookingDates,
  189. loadBookingResourceCache,
  190. submitBookingAppointment
  191. } from '@/api/bookingService'
  192. const VET_RESOURCE_TYPE = '004001'
  193. function pad2(n) {
  194. return `${n}`.padStart(2, '0')
  195. }
  196. /** 每 2 小时一档;用于默认时段与服务时段生成 */
  197. const DEFAULT_TIME_SLOTS = (() => {
  198. const r = []
  199. for (let h = 0; h < 24; h += 2) {
  200. const start = `${pad2(h)}:00`
  201. const endH = h + 2
  202. const end = endH >= 24 ? '24:00' : `${pad2(endH)}:00`
  203. r.push(`${start}~${end}`)
  204. }
  205. return r
  206. })()
  207. function parseHourMinute(hm) {
  208. if (!hm || typeof hm !== 'string') return null
  209. const parts = hm.trim().split(':')
  210. if (parts.length < 2) return null
  211. const h = parseInt(parts[0], 10)
  212. const m = parseInt(parts[1], 10)
  213. if (!Number.isFinite(h) || !Number.isFinite(m)) return null
  214. return { h, m }
  215. }
  216. function buildTimeSlotsFromService(start, end, stepHours = 2) {
  217. const s = parseHourMinute(start)
  218. const e = parseHourMinute(end)
  219. if (!s || !e || e.h <= s.h) {
  220. return DEFAULT_TIME_SLOTS.slice()
  221. }
  222. const slots = []
  223. for (let h = s.h; h < e.h; h += stepHours) {
  224. const endH = Math.min(h + stepHours, e.h)
  225. if (endH <= h) break
  226. slots.push(`${pad2(h)}:00~${pad2(endH)}:00`)
  227. }
  228. return slots.length ? slots : DEFAULT_TIME_SLOTS.slice()
  229. }
  230. function parseServiceWeekdays(raw) {
  231. if (raw == null || String(raw).trim() === '') return null
  232. const set = new Set()
  233. for (const part of String(raw).split(',')) {
  234. const n = parseInt(part.trim(), 10)
  235. if (n >= 1 && n <= 7) set.add(n)
  236. }
  237. return set.size ? set : null
  238. }
  239. function slotIndexFromHour(slots) {
  240. const list = slots && slots.length ? slots : DEFAULT_TIME_SLOTS
  241. const h = new Date().getHours()
  242. const target = `${pad2(Math.floor(h / 2) * 2)}:00`
  243. const idx = list.findIndex((s) => s.startsWith(target))
  244. return idx >= 0 ? idx : 0
  245. }
  246. export default {
  247. components: {
  248. 'up-avatar': UAvatar,
  249. 'up-button': UButton,
  250. 'up-divider': UDivider,
  251. 'up-form': UForm,
  252. 'up-form-item': UFormItem,
  253. 'up-icon': UIcon,
  254. 'up-input': UInput,
  255. 'up-picker': UPicker,
  256. 'up-popup': UPopup,
  257. 'up-textarea': UTextarea
  258. },
  259. mixins: [tabPage, pageViewport],
  260. data() {
  261. const slots = DEFAULT_TIME_SLOTS.slice()
  262. const defaultSlot = slots[slotIndexFromHour(slots)] || slots[0]
  263. return {
  264. navTitleKey: 'bookingVetPage.navTitle',
  265. vetId: '',
  266. resourceType: VET_RESOURCE_TYPE,
  267. resource: null,
  268. apiDates: [],
  269. bookedDateMap: {},
  270. selectedDateKey: '',
  271. bookPopupShow: false,
  272. timePickerShow: false,
  273. submitting: false,
  274. pickerModel: [defaultSlot],
  275. formModel: {
  276. bookerName: '',
  277. phone: '',
  278. timeSlot: defaultSlot,
  279. address: '',
  280. needDesc: ''
  281. },
  282. bookPopupStyle: {
  283. width: '90%',
  284. maxWidth: '680px',
  285. maxHeight: '78vh',
  286. overflow: 'hidden',
  287. display: 'flex',
  288. flexDirection: 'column'
  289. }
  290. }
  291. },
  292. computed: {
  293. allowedWeekdays() {
  294. return parseServiceWeekdays(this.resource && this.resource.serviceWeekdays)
  295. },
  296. timeSlotOptions() {
  297. const r = this.resource || {}
  298. return buildTimeSlotsFromService(r.serviceStartTime, r.serviceEndTime)
  299. },
  300. dateChips() {
  301. return (this.apiDates || []).map((d) => {
  302. const key = d.appointDate || ''
  303. const weekday = d.weekday
  304. const allowed = this.allowedWeekdays
  305. const weekdayDisabled =
  306. allowed != null && weekday != null && !allowed.has(Number(weekday))
  307. const booked = !!this.bookedDateMap[key]
  308. return {
  309. key,
  310. dateShort: d.dateMmDd || '',
  311. weekday,
  312. weekdayLabel: this.weekdayLabelFor(d),
  313. booked,
  314. weekdayDisabled
  315. }
  316. })
  317. },
  318. selectedDateBooked() {
  319. return !!this.bookedDateMap[this.selectedDateKey]
  320. },
  321. bookBtnDisabled() {
  322. const chip = this.dateChips.find((c) => c.key === this.selectedDateKey)
  323. if (!chip || chip.weekdayDisabled) return true
  324. return this.selectedDateBooked
  325. },
  326. bookBtnText() {
  327. if (this.selectedDateBooked) return this.$t('bookingVetPage.btnBooked')
  328. return this.$t('bookingVetPage.btnBook')
  329. },
  330. photoSrc() {
  331. const url = this.resource && this.resource.photoFileUrl
  332. return url ? resolveResourceUrl(url) : ''
  333. },
  334. avatarText() {
  335. const name = (this.resource && this.resource.resourceName) || ''
  336. return name.slice(0, 1) || '?'
  337. },
  338. vetTitle() {
  339. return (this.resource && this.resource.resourceName) || this.$t('bookingVetPage.noResource')
  340. },
  341. vetSub() {
  342. return (this.resource && this.resource.affiliatedUnit) || this.$t('bookingServicePage.noUnit')
  343. },
  344. introBody() {
  345. const intro = (this.resource && this.resource.introduction) || ''
  346. return intro.trim() || this.$t('bookingVetPage.noIntro')
  347. },
  348. contactLine() {
  349. const phone = this.resource && this.resource.contactPhone
  350. if (!phone) return this.$t('bookingVetPage.contactLabel') + '—'
  351. return this.$t('bookingVetPage.contactLabel') + phone
  352. },
  353. detailAddress() {
  354. const addr = (this.resource && this.resource.detailAddress) || ''
  355. return addr.trim() || this.$t('bookingVetPage.noAddress')
  356. },
  357. serviceHours() {
  358. const start = this.resource && this.resource.serviceStartTime
  359. const end = this.resource && this.resource.serviceEndTime
  360. if (start && end) {
  361. return this.$t('bookingVetPage.hoursTpl', { start, end })
  362. }
  363. return this.$t('bookingVetPage.noHours')
  364. },
  365. serviceArea() {
  366. const area = (this.resource && this.resource.serviceArea) || ''
  367. return area.trim() || this.$t('bookingVetPage.noArea')
  368. },
  369. feeLine() {
  370. const fee = this.resource && this.resource.feeStandard
  371. if (fee == null || fee === '') return this.$t('bookingVetPage.noFee')
  372. return this.$t('bookingVetPage.feeTpl', { fee })
  373. },
  374. popupDateLine() {
  375. const chip = this.dateChips.find((c) => c.key === this.selectedDateKey)
  376. if (!chip || chip.weekdayDisabled) return ''
  377. return this.formatMdWeekday(chip)
  378. },
  379. formRules() {
  380. return {
  381. bookerName: [
  382. {
  383. required: true,
  384. message: this.$t('bookingVetPage.errBooker'),
  385. trigger: ['blur', 'change']
  386. }
  387. ],
  388. phone: [
  389. {
  390. required: true,
  391. message: this.$t('bookingVetPage.errPhone'),
  392. trigger: ['blur', 'change']
  393. },
  394. {
  395. pattern: /^1[3-9]\d{9}$/,
  396. message: this.$t('bookingVetPage.errPhoneFmt'),
  397. trigger: ['blur', 'change']
  398. }
  399. ],
  400. timeSlot: [
  401. {
  402. required: true,
  403. message: this.$t('bookingVetPage.errSlot'),
  404. trigger: ['change']
  405. }
  406. ],
  407. address: [
  408. {
  409. required: true,
  410. message: this.$t('bookingVetPage.errAddress'),
  411. trigger: ['blur', 'change']
  412. }
  413. ],
  414. needDesc: [
  415. {
  416. required: true,
  417. message: this.$t('bookingVetPage.errNeed'),
  418. trigger: ['blur', 'change']
  419. }
  420. ]
  421. }
  422. }
  423. },
  424. onLoad(query) {
  425. if (!ensureApiToken()) return
  426. const q = query || {}
  427. this.vetId = q.id ? decodeURIComponent(String(q.id)) : ''
  428. if (q.resourceType) {
  429. try {
  430. this.resourceType = decodeURIComponent(String(q.resourceType))
  431. } catch (e) {
  432. this.resourceType = String(q.resourceType)
  433. }
  434. }
  435. this.loadResource()
  436. this.loadBookingDates()
  437. },
  438. methods: {
  439. weekdayLabelFor(d) {
  440. const w = d && d.weekday
  441. if (w != null && w >= 1 && w <= 7) {
  442. const dowKey = w === 7 ? 0 : w
  443. return this.$t(`bookingServicePage.wd${dowKey}`)
  444. }
  445. return (d && d.weekdayName) || ''
  446. },
  447. loadResource() {
  448. if (!this.vetId) {
  449. uni.showToast({ title: this.$t('bookingVetPage.noResource'), icon: 'none' })
  450. setTimeout(() => uni.navigateBack(), 1500)
  451. return
  452. }
  453. this.resource = loadBookingResourceCache(this.resourceType, this.vetId)
  454. if (!this.resource) {
  455. uni.showToast({ title: this.$t('bookingVetPage.noResource'), icon: 'none' })
  456. setTimeout(() => uni.navigateBack(), 1500)
  457. return
  458. }
  459. if (this.apiDates.length) {
  460. this.refreshBookedFlags()
  461. }
  462. },
  463. loadBookingDates() {
  464. listBookingDates()
  465. .then((res) => {
  466. this.apiDates = Array.isArray(res.data) ? res.data : []
  467. this.$nextTick(() => {
  468. this.ensureValidDateSelection()
  469. if (this.vetId) {
  470. this.refreshBookedFlags()
  471. }
  472. })
  473. })
  474. .catch(() => {
  475. this.apiDates = []
  476. })
  477. },
  478. refreshBookedFlags() {
  479. if (!this.vetId || !this.apiDates.length) return Promise.resolve()
  480. const providerId = Number(this.vetId) || this.vetId
  481. const dates = this.apiDates.map((d) => d.appointDate).filter(Boolean)
  482. return Promise.all(
  483. dates.map((appointDate) =>
  484. checkAppointmentBooked({
  485. providerType: BOOKING_PROVIDER_TYPE.VET,
  486. providerId,
  487. appointDate
  488. })
  489. .then((res) => ({
  490. appointDate,
  491. booked: !!(res.data && res.data.booked)
  492. }))
  493. .catch(() => ({ appointDate, booked: false }))
  494. )
  495. ).then((results) => {
  496. const next = { ...this.bookedDateMap }
  497. results.forEach(({ appointDate, booked }) => {
  498. next[appointDate] = booked
  499. })
  500. this.bookedDateMap = next
  501. this.$nextTick(() => this.ensureValidDateSelection())
  502. })
  503. },
  504. fetchDateBooked(appointDate) {
  505. if (!this.vetId || !appointDate) return Promise.resolve(false)
  506. return checkAppointmentBooked({
  507. providerType: BOOKING_PROVIDER_TYPE.VET,
  508. providerId: Number(this.vetId) || this.vetId,
  509. appointDate
  510. })
  511. .then((res) => {
  512. const booked = !!(res.data && res.data.booked)
  513. this.bookedDateMap = { ...this.bookedDateMap, [appointDate]: booked }
  514. return booked
  515. })
  516. .catch(() => false)
  517. },
  518. formatMdWeekday(chip) {
  519. if (!chip || !chip.key) return ''
  520. return `${chip.dateShort}(${chip.weekdayLabel})`
  521. },
  522. ensureValidDateSelection() {
  523. const cur = this.dateChips.find((c) => c.key === this.selectedDateKey && !c.weekdayDisabled)
  524. if (cur) return
  525. const ok = this.dateChips.find((c) => !c.weekdayDisabled)
  526. this.selectedDateKey = ok ? ok.key : ''
  527. },
  528. onDateChipTap(chip) {
  529. if (chip.weekdayDisabled) {
  530. uni.showToast({
  531. title: this.$t('bookingVetPage.dayNotAvailable'),
  532. icon: 'none'
  533. })
  534. return
  535. }
  536. this.selectedDateKey = chip.key
  537. this.fetchDateBooked(chip.key)
  538. },
  539. openBookPopup() {
  540. if (!this.resource || !this.vetId) {
  541. uni.showToast({ title: this.$t('bookingVetPage.noResource'), icon: 'none' })
  542. return
  543. }
  544. const sel = this.dateChips.find((c) => c.key === this.selectedDateKey)
  545. if (!sel || sel.weekdayDisabled) {
  546. uni.showToast({
  547. title: this.$t('bookingVetPage.toastPickDate'),
  548. icon: 'none'
  549. })
  550. return
  551. }
  552. if (this.selectedDateBooked) return
  553. this.resetBookForm()
  554. this.bookPopupShow = true
  555. },
  556. closeBookPopup() {
  557. this.bookPopupShow = false
  558. },
  559. resetBookForm() {
  560. const slots = this.timeSlotOptions
  561. const idx = slotIndexFromHour(slots)
  562. const slot = slots[idx] || slots[0] || ''
  563. const store = useUserStore()
  564. const name = store.displayName()
  565. this.formModel = {
  566. bookerName: name || '',
  567. phone: '',
  568. timeSlot: slot,
  569. address: '',
  570. needDesc: ''
  571. }
  572. this.pickerModel = [slot]
  573. this.$nextTick(() => {
  574. this.$refs.bookFormRef?.clearValidate?.()
  575. })
  576. },
  577. openTimePicker() {
  578. const slots = this.timeSlotOptions
  579. const cur = this.formModel.timeSlot
  580. const ok = slots.includes(cur)
  581. this.pickerModel = [ok ? cur : slots[slotIndexFromHour(slots)] || slots[0]]
  582. this.timePickerShow = true
  583. },
  584. onTimePickerConfirm(e) {
  585. const v = e.value && e.value[0]
  586. if (v) {
  587. this.formModel.timeSlot = v
  588. this.pickerModel = [v]
  589. }
  590. },
  591. onSubmitBook() {
  592. if (this.submitting) return
  593. this.$refs.bookFormRef
  594. ?.validate?.()
  595. .then(() => {
  596. this.submitting = true
  597. return submitBookingAppointment({
  598. resourceType: this.resourceType,
  599. resourceId: Number(this.vetId) || this.vetId,
  600. appointDate: this.selectedDateKey,
  601. appointeeName: (this.formModel.bookerName || '').trim(),
  602. contactPhone: (this.formModel.phone || '').trim(),
  603. timeSlot: this.formModel.timeSlot,
  604. serviceAddress: (this.formModel.address || '').trim(),
  605. serviceRequirement: (this.formModel.needDesc || '').trim()
  606. })
  607. })
  608. .then(() => {
  609. this.bookedDateMap = { ...this.bookedDateMap, [this.selectedDateKey]: true }
  610. uni.showToast({
  611. title: this.$t('bookingVetPage.toastSubmitOk'),
  612. icon: 'success'
  613. })
  614. this.bookPopupShow = false
  615. })
  616. .catch(() => {})
  617. .finally(() => {
  618. this.submitting = false
  619. })
  620. }
  621. }
  622. }
  623. </script>
  624. <style lang="scss" scoped>
  625. @import '@/styles/morandi.scss';
  626. @import '@/styles/tab-page.scss';
  627. .bv-page {
  628. display: flex;
  629. flex-direction: column;
  630. min-width: 0;
  631. width: 100%;
  632. height: 100%;
  633. min-height: 100%;
  634. overflow: hidden;
  635. box-sizing: border-box;
  636. background: $morandi-bg-page;
  637. }
  638. .bv-scroll {
  639. flex: 1;
  640. min-height: 0;
  641. min-width: 0;
  642. height: 0;
  643. box-sizing: border-box;
  644. padding: 20rpx 24rpx 24rpx;
  645. }
  646. .bv-card {
  647. background: #ffffff;
  648. border-radius: 16rpx;
  649. border: 1rpx solid $morandi-border-soft;
  650. padding: 24rpx;
  651. box-sizing: border-box;
  652. }
  653. .bv-card--top {
  654. margin-bottom: 10rpx;
  655. }
  656. .bv-hero {
  657. display: flex;
  658. flex-direction: row;
  659. align-items: center;
  660. gap: 20rpx;
  661. min-width: 0;
  662. margin-bottom: 20rpx;
  663. }
  664. .bv-hero__right {
  665. flex: 1;
  666. min-width: 0;
  667. display: flex;
  668. flex-direction: column;
  669. gap: 8rpx;
  670. }
  671. .bv-hero__title {
  672. font-size: 32rpx;
  673. font-weight: 600;
  674. color: #111827;
  675. line-height: 1.35;
  676. word-break: break-word;
  677. }
  678. .bv-hero__sub {
  679. font-size: 24rpx;
  680. color: $morandi-text-secondary;
  681. line-height: 1.45;
  682. word-break: break-word;
  683. }
  684. .bv-hero__contact {
  685. font-size: 22rpx;
  686. color: $morandi-text-muted;
  687. line-height: 1.45;
  688. word-break: break-word;
  689. }
  690. .bv-line {
  691. display: block;
  692. font-size: 24rpx;
  693. line-height: 1.55;
  694. color: #111827;
  695. margin-top: 12rpx;
  696. word-break: break-word;
  697. }
  698. .bv-line__k {
  699. color: $morandi-text-muted;
  700. margin-right: 4rpx;
  701. }
  702. .bv-section-title {
  703. display: block;
  704. font-size: 28rpx;
  705. font-weight: 600;
  706. color: #111827;
  707. margin-bottom: 16rpx;
  708. }
  709. .bv-date-scroll {
  710. width: 100%;
  711. white-space: nowrap;
  712. }
  713. .bv-date-row {
  714. display: inline-flex;
  715. flex-direction: row;
  716. align-items: stretch;
  717. gap: 12rpx;
  718. padding: 4rpx 0 8rpx;
  719. min-width: min-content;
  720. }
  721. .bv-chip {
  722. flex-shrink: 0;
  723. min-width: 96rpx;
  724. padding: 12rpx 16rpx;
  725. border-radius: 12rpx;
  726. border: 1rpx solid #e5e7eb;
  727. background: #ffffff;
  728. box-sizing: border-box;
  729. display: flex;
  730. flex-direction: row;
  731. align-items: center;
  732. justify-content: center;
  733. }
  734. .bv-chip--on {
  735. background: #22c55e;
  736. border-color: #16a34a;
  737. }
  738. .bv-chip--disabled {
  739. opacity: 0.75;
  740. background: #f9fafb;
  741. border-color: #e5e7eb;
  742. }
  743. .bv-chip__full {
  744. font-size: 22rpx;
  745. color: #9ca3af;
  746. font-weight: 500;
  747. }
  748. .bv-chip--disabled .bv-chip__name {
  749. color: #9ca3af;
  750. }
  751. .bv-chip__all {
  752. font-size: 26rpx;
  753. color: #111827;
  754. font-weight: 500;
  755. }
  756. .bv-chip--on .bv-chip__all {
  757. color: #ffffff;
  758. font-weight: 600;
  759. }
  760. .bv-chip__stack {
  761. display: flex;
  762. flex-direction: column;
  763. align-items: center;
  764. justify-content: center;
  765. gap: 4rpx;
  766. }
  767. .bv-chip__name {
  768. font-size: 24rpx;
  769. color: #111827;
  770. font-weight: 500;
  771. }
  772. .bv-chip__date {
  773. font-size: 22rpx;
  774. color: #374151;
  775. }
  776. .bv-chip--on .bv-chip__name,
  777. .bv-chip--on .bv-chip__date {
  778. color: #ffffff;
  779. font-weight: 600;
  780. }
  781. .bv-footer-spacer {
  782. height: 32rpx;
  783. }
  784. .bv-popup {
  785. display: flex;
  786. flex-direction: column;
  787. min-width: 0;
  788. max-height: 78vh;
  789. padding: 24rpx 24rpx 16rpx;
  790. box-sizing: border-box;
  791. }
  792. .bv-popup__scroll {
  793. flex: 1;
  794. min-height: 0;
  795. max-height: 56vh;
  796. }
  797. .bv-popup__h1 {
  798. display: block;
  799. font-size: 30rpx;
  800. font-weight: 600;
  801. color: #111827;
  802. margin-bottom: 20rpx;
  803. }
  804. .bv-popup__h2 {
  805. display: block;
  806. font-size: 28rpx;
  807. font-weight: 600;
  808. color: #111827;
  809. margin: 24rpx 0 16rpx;
  810. }
  811. .bv-popup__row {
  812. display: block;
  813. font-size: 26rpx;
  814. color: #374151;
  815. line-height: 1.5;
  816. margin-bottom: 8rpx;
  817. word-break: break-word;
  818. }
  819. .bv-slot-row {
  820. display: flex;
  821. flex-direction: row;
  822. align-items: center;
  823. gap: 8rpx;
  824. min-width: 0;
  825. }
  826. .bv-slot-row :deep(.u-input) {
  827. flex: 1;
  828. min-width: 0;
  829. }
  830. .bv-popup__actions {
  831. display: flex;
  832. flex-direction: row;
  833. gap: 20rpx;
  834. padding-top: 16rpx;
  835. border-top: 1rpx solid #e5e7eb;
  836. margin-top: 8rpx;
  837. }
  838. .bv-popup__btn {
  839. flex: 1;
  840. min-width: 0;
  841. }
  842. .bv-popup__btn--cancel {
  843. background: #ffffff !important;
  844. }
  845. .bv-page.lang-bo {
  846. .bv-hero__title {
  847. font-size: 28rpx;
  848. line-height: 1.75;
  849. }
  850. }
  851. </style>