西藏巴青项目

index.vue 27KB

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