西藏巴青项目

index.vue 27KB

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