西藏巴青项目

index.vue 25KB

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