西藏巴青项目

index.vue 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  1. <template>
  2. <!-- 历史评价:见 doc/app/预约服务/预约服务接口说明.md §4;我的预约评价见 myAppointment -->
  3. <view :class="pageRootClass" class="tab-page rh-page" :style="pageStyle">
  4. <!-- 提交评价(我的预约 → 专家已完成) -->
  5. <scroll-view
  6. v-if="pageMode === 'appointmentSubmit'"
  7. scroll-y
  8. class="rh-submit-scroll"
  9. enable-back-to-top
  10. :show-scrollbar="false"
  11. >
  12. <view class="rh-card rh-submit-card">
  13. <text v-if="providerName" class="rh-submit-org text-body">{{ providerName }}</text>
  14. <view v-for="dim in scoreDims" :key="dim.key" class="rh-score-row">
  15. <text class="rh-score-label text-body">{{ $t(dim.labelKey) }}</text>
  16. <up-rate
  17. v-model="form[dim.key]"
  18. :count="5"
  19. :size="22"
  20. gutter="6"
  21. active-color="#EAB308"
  22. inactive-color="#d1d5db"
  23. />
  24. </view>
  25. <text class="rh-score-label text-body rh-score-label--block">{{ $t('reviewHistoryPage.labelSummary') }}</text>
  26. <up-textarea
  27. v-model="form.summary"
  28. height="140"
  29. count
  30. maxlength="100"
  31. border="surround"
  32. :placeholder="$t('reviewHistoryPage.summaryPh')"
  33. />
  34. </view>
  35. <view class="rh-submit-actions">
  36. <up-button
  37. type="success"
  38. shape="circle"
  39. :loading="submitting"
  40. :text="submitting ? $t('reviewHistoryPage.submitting') : $t('reviewHistoryPage.btnSubmit')"
  41. @click="onSubmitReview"
  42. />
  43. </view>
  44. <view class="rh-footer-spacer" />
  45. </scroll-view>
  46. <!-- 评价列表 / 单条查看 -->
  47. <view v-else class="rh-body">
  48. <view v-if="listLoading && !rows.length" class="rh-empty">
  49. <text class="text-body rh-empty__txt">{{ $t('reviewHistoryPage.loading') }}</text>
  50. </view>
  51. <view v-else-if="!rows.length" class="rh-empty">
  52. <text class="text-body rh-empty__txt">{{ $t('reviewHistoryPage.empty') }}</text>
  53. </view>
  54. <view v-else class="rh-list-wrap">
  55. <up-virtual-list
  56. ref="rhVList"
  57. class="rh-vlist"
  58. :list-data="rows"
  59. :item-height="slotHeightPx"
  60. :height="listHeightPx"
  61. :buffer="6"
  62. key-field="id"
  63. :scroll-top="vScrollTop"
  64. @update:scrollTop="vScrollTop = $event"
  65. @scroll="onVirtualListScroll"
  66. >
  67. <template #default="{ item }">
  68. <view class="rh-cell">
  69. <view class="rh-card" :style="{ height: rowBodyPx + 'px' }">
  70. <view class="rh-row">
  71. <up-avatar
  72. shape="circle"
  73. :text="item.avatarText"
  74. size="48"
  75. font-size="18"
  76. bg-color="#9ca3af"
  77. color="#ffffff"
  78. />
  79. <view class="rh-right">
  80. <text class="rh-name text-body">{{ item.reviewerName }}</text>
  81. <text class="rh-date text-body">{{ item.dateStr }}</text>
  82. <view class="rh-scores">
  83. <view v-for="dim in scoreDims" :key="dim.key" class="rh-score-item">
  84. <text class="rh-score-item__label text-body">{{ $t(dim.labelKey) }}</text>
  85. <up-rate
  86. :model-value="scoreForItem(item, dim.key)"
  87. readonly
  88. :touchable="false"
  89. :count="5"
  90. :size="12"
  91. gutter="2"
  92. active-color="#EAB308"
  93. inactive-color="#d1d5db"
  94. />
  95. </view>
  96. </view>
  97. <text class="rh-content text-body">{{ item.summary }}</text>
  98. </view>
  99. </view>
  100. </view>
  101. </view>
  102. </template>
  103. </up-virtual-list>
  104. </view>
  105. </view>
  106. </view>
  107. </template>
  108. <script>
  109. import UAvatar from 'uview-plus/components/u-avatar/u-avatar.vue'
  110. import UButton from 'uview-plus/components/u-button/u-button.vue'
  111. import URate from 'uview-plus/components/u-rate/u-rate.vue'
  112. import UTextarea from 'uview-plus/components/u-textarea/u-textarea.vue'
  113. import UVirtualList from 'uview-plus/components/u-virtual-list/u-virtual-list.vue'
  114. import tabPage from '@/mixins/tabPage'
  115. import { ensureApiToken } from '@/utils/apiAuth'
  116. import { listExpertBookingReviews } from '@/api/bookingService'
  117. import { getMyAppointmentReview, submitMyAppointmentReview } from '@/api/myAppointment'
  118. const ROW_BODY_RPX = 400
  119. const ROW_GAP_RPX = 20
  120. const LIST_PAGE_SIZE = 20
  121. const RH_BODY_PAD_RPX = 40
  122. const SCORE_DIMS = [
  123. { key: 'punctualityScore', labelKey: 'reviewHistoryPage.scorePunctuality' },
  124. { key: 'attitudeScore', labelKey: 'reviewHistoryPage.scoreAttitude' },
  125. { key: 'guidanceScore', labelKey: 'reviewHistoryPage.scoreGuidance' }
  126. ]
  127. function normalizeScore(raw) {
  128. const v = Number(raw)
  129. if (!Number.isFinite(v) || v <= 0) return 0
  130. return Math.min(5, Math.max(0, Math.round(v)))
  131. }
  132. function formatReviewTime(raw) {
  133. if (!raw) return ''
  134. const s = String(raw).trim()
  135. if (s.length >= 16) return s.slice(0, 16)
  136. if (s.length >= 10) return s.slice(0, 10)
  137. return s
  138. }
  139. export default {
  140. components: {
  141. 'up-avatar': UAvatar,
  142. 'up-button': UButton,
  143. 'up-rate': URate,
  144. 'up-textarea': UTextarea,
  145. 'up-virtual-list': UVirtualList
  146. },
  147. mixins: [tabPage],
  148. data() {
  149. return {
  150. pageMode: 'expertList',
  151. expertId: '',
  152. appointmentId: '',
  153. providerName: '',
  154. rows: [],
  155. listLoading: false,
  156. listLoadingMore: false,
  157. listTotal: 0,
  158. pageNum: 1,
  159. pageSize: LIST_PAGE_SIZE,
  160. loadMoreTimer: null,
  161. submitting: false,
  162. form: {
  163. punctualityScore: 5,
  164. attitudeScore: 5,
  165. guidanceScore: 5,
  166. summary: ''
  167. },
  168. rowBodyPx: 120,
  169. marginPx: 10,
  170. slotHeightPx: 130,
  171. listHeightPx: 400,
  172. pageHeightPx: 0,
  173. vScrollTop: 0
  174. }
  175. },
  176. computed: {
  177. scoreDims() {
  178. return SCORE_DIMS
  179. },
  180. navTitleKey() {
  181. if (this.pageMode === 'appointmentSubmit') return 'reviewHistoryPage.navTitleSubmit'
  182. if (this.pageMode === 'appointmentView') return 'reviewHistoryPage.navTitleView'
  183. return 'reviewHistoryPage.navTitle'
  184. },
  185. listNoMore() {
  186. return this.listTotal > 0 && this.rows.length >= this.listTotal
  187. },
  188. pageStyle() {
  189. if (this.pageHeightPx > 0) {
  190. return { height: `${this.pageHeightPx}px` }
  191. }
  192. return {}
  193. }
  194. },
  195. onLoad(query) {
  196. if (!ensureApiToken()) return
  197. const q = query || {}
  198. this.appointmentId = q.appointmentId ? decodeURIComponent(String(q.appointmentId)) : ''
  199. this.providerName = q.providerName ? decodeURIComponent(String(q.providerName)) : ''
  200. const mode = q.mode ? decodeURIComponent(String(q.mode)) : 'view'
  201. this.expertId = q.id ? decodeURIComponent(String(q.id)) : ''
  202. if (this.appointmentId) {
  203. this.pageMode = mode === 'submit' ? 'appointmentSubmit' : 'appointmentView'
  204. } else if (this.expertId) {
  205. this.pageMode = 'expertList'
  206. } else {
  207. this.pageMode = 'expertList'
  208. }
  209. if (this.pageMode === 'expertList' && this.expertId) {
  210. this.loadExpertReviews(true)
  211. } else if (this.pageMode === 'appointmentView') {
  212. this.loadAppointmentReview()
  213. }
  214. },
  215. onReady() {
  216. try {
  217. this.marginPx = Math.ceil(uni.upx2px(ROW_GAP_RPX))
  218. this.rowBodyPx = Math.max(100, Math.ceil(uni.upx2px(ROW_BODY_RPX)))
  219. this.slotHeightPx = this.rowBodyPx + this.marginPx
  220. } catch (e) {
  221. this.marginPx = 10
  222. this.rowBodyPx = 120
  223. this.slotHeightPx = 130
  224. }
  225. if (this.pageMode !== 'appointmentSubmit') {
  226. this.applyListHeightFallback()
  227. this.$nextTick(() => this.calcLayoutHeights())
  228. } else {
  229. this.applyListHeightFallback()
  230. }
  231. },
  232. onUnload() {
  233. if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
  234. },
  235. methods: {
  236. mapReviewRow(row, idx) {
  237. const name = (row.reviewerName || '').trim() || this.$t('reviewHistoryPage.anonymous')
  238. return {
  239. id: row.id != null ? String(row.id) : `r-${idx}`,
  240. reviewerName: name,
  241. avatarText: name.slice(0, 1) || '?',
  242. dateStr: formatReviewTime(row.reviewTime),
  243. punctualityScore: normalizeScore(row.punctualityScore),
  244. attitudeScore: normalizeScore(row.attitudeScore),
  245. guidanceScore: normalizeScore(row.guidanceScore),
  246. summary: (row.summary || '').trim() || '—'
  247. }
  248. },
  249. scoreForItem(item, key) {
  250. if (!item || !key) return 0
  251. return normalizeScore(item[key])
  252. },
  253. loadExpertReviews(reset = false) {
  254. if (!this.expertId) return Promise.resolve()
  255. if (!reset) {
  256. if (this.listLoading || this.listLoadingMore || this.listNoMore) {
  257. return Promise.resolve()
  258. }
  259. this.pageNum += 1
  260. this.listLoadingMore = true
  261. } else {
  262. this.pageNum = 1
  263. this.listLoading = true
  264. }
  265. return listExpertBookingReviews(this.expertId, {
  266. pageNum: this.pageNum,
  267. pageSize: this.pageSize
  268. })
  269. .then((res) => {
  270. const raw = res.rows || []
  271. this.listTotal = res.total != null ? Number(res.total) : 0
  272. const mapped = raw.map((row, idx) => this.mapReviewRow(row, idx))
  273. this.rows = reset ? mapped : this.rows.concat(mapped)
  274. this.$nextTick(() => {
  275. this.calcLayoutHeights()
  276. })
  277. })
  278. .catch(() => {
  279. if (reset) {
  280. this.rows = []
  281. this.listTotal = 0
  282. } else {
  283. this.pageNum -= 1
  284. }
  285. })
  286. .finally(() => {
  287. if (reset) {
  288. this.listLoading = false
  289. } else {
  290. this.listLoadingMore = false
  291. }
  292. })
  293. },
  294. loadAppointmentReview() {
  295. if (!this.appointmentId) return
  296. this.listLoading = true
  297. getMyAppointmentReview(this.appointmentId)
  298. .then((res) => {
  299. const data = res.data
  300. if (data && typeof data === 'object') {
  301. this.rows = [this.mapReviewRow(data, 0)]
  302. this.listTotal = 1
  303. } else {
  304. this.rows = []
  305. this.listTotal = 0
  306. }
  307. this.$nextTick(() => this.calcLayoutHeights())
  308. })
  309. .catch(() => {
  310. this.rows = []
  311. this.listTotal = 0
  312. })
  313. .finally(() => {
  314. this.listLoading = false
  315. })
  316. },
  317. validateSubmitForm() {
  318. for (const dim of SCORE_DIMS) {
  319. const v = Number(this.form[dim.key])
  320. if (!Number.isFinite(v) || v < 1 || v > 5) {
  321. uni.showToast({ title: this.$t('reviewHistoryPage.errScore', { label: this.$t(dim.labelKey) }), icon: 'none' })
  322. return false
  323. }
  324. }
  325. const summary = (this.form.summary || '').trim()
  326. if (!summary) {
  327. uni.showToast({ title: this.$t('reviewHistoryPage.errSummary'), icon: 'none' })
  328. return false
  329. }
  330. if (summary.length > 100) {
  331. uni.showToast({ title: this.$t('reviewHistoryPage.errSummaryLen'), icon: 'none' })
  332. return false
  333. }
  334. return true
  335. },
  336. onSubmitReview() {
  337. if (this.submitting || !this.appointmentId) return
  338. if (!this.validateSubmitForm()) return
  339. this.submitting = true
  340. submitMyAppointmentReview(this.appointmentId, {
  341. punctualityScore: Number(this.form.punctualityScore),
  342. attitudeScore: Number(this.form.attitudeScore),
  343. guidanceScore: Number(this.form.guidanceScore),
  344. summary: (this.form.summary || '').trim()
  345. })
  346. .then(() => {
  347. uni.showToast({ title: this.$t('reviewHistoryPage.toastSubmitOk'), icon: 'success' })
  348. setTimeout(() => uni.navigateBack(), 800)
  349. })
  350. .catch(() => {})
  351. .finally(() => {
  352. this.submitting = false
  353. })
  354. },
  355. tryAutoLoadMore() {
  356. if (this.pageMode !== 'expertList') return
  357. if (this.listLoading || this.listLoadingMore || this.listNoMore) return
  358. const totalH = this.rows.length * this.slotHeightPx
  359. const viewH = this.listHeightPx
  360. if (totalH > 0 && totalH <= viewH) {
  361. this.loadExpertReviews(false)
  362. }
  363. },
  364. onVirtualListScroll(scrollTop) {
  365. if (this.pageMode !== 'expertList') return
  366. if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
  367. this.loadMoreTimer = setTimeout(() => {
  368. this.loadMoreTimer = null
  369. this.tryLoadMoreOnScroll(typeof scrollTop === 'number' ? scrollTop : 0)
  370. }, 180)
  371. },
  372. tryLoadMoreOnScroll(scrollTop) {
  373. if (this.listLoading || this.listLoadingMore || this.listNoMore) return
  374. const totalH = this.rows.length * this.slotHeightPx
  375. const viewH = this.listHeightPx
  376. const threshold = Math.max(this.slotHeightPx * 2, 120)
  377. if (totalH <= viewH) {
  378. this.loadExpertReviews(false)
  379. return
  380. }
  381. if (scrollTop + viewH >= totalH - threshold) {
  382. this.loadExpertReviews(false)
  383. }
  384. },
  385. applyListHeightFallback() {
  386. const contentH = this.getViewportContentHeight()
  387. const bodyPad = Math.ceil(uni.upx2px(RH_BODY_PAD_RPX))
  388. this.pageHeightPx = contentH
  389. this.listHeightPx = Math.max(280, contentH - bodyPad)
  390. },
  391. getNavigationBarHeight() {
  392. const sys = uni.getSystemInfoSync()
  393. const statusBar = sys.statusBarHeight || 0
  394. // #ifdef MP-WEIXIN
  395. try {
  396. const menu = uni.getMenuButtonBoundingClientRect()
  397. if (menu && menu.height) {
  398. return statusBar + (menu.top - statusBar) * 2 + menu.height
  399. }
  400. } catch (e) {
  401. /* noop */
  402. }
  403. // #endif
  404. return statusBar + 44
  405. },
  406. getViewportContentHeight() {
  407. const sys = uni.getSystemInfoSync()
  408. const navH = this.getNavigationBarHeight()
  409. // #ifdef H5
  410. if (typeof window !== 'undefined' && window.innerHeight) {
  411. return Math.max(320, window.innerHeight - navH)
  412. }
  413. // #endif
  414. const screenH = sys.screenHeight || sys.windowHeight || 600
  415. return Math.max(320, screenH - navH)
  416. },
  417. calcLayoutHeights() {
  418. const contentH = this.getViewportContentHeight()
  419. this.pageHeightPx = contentH
  420. uni.createSelectorQuery()
  421. .in(this)
  422. .select('.rh-body')
  423. .boundingClientRect((rect) => {
  424. if (rect && rect.height > 0) {
  425. this.listHeightPx = Math.max(280, Math.floor(rect.height))
  426. } else {
  427. const bodyPad = Math.ceil(uni.upx2px(RH_BODY_PAD_RPX))
  428. this.listHeightPx = Math.max(280, contentH - bodyPad)
  429. }
  430. this.$nextTick(() => {
  431. this.$refs.rhVList?.measureContainerHeight?.()
  432. this.tryAutoLoadMore()
  433. })
  434. })
  435. .exec()
  436. }
  437. }
  438. }
  439. </script>
  440. <style lang="scss" scoped>
  441. @import '@/styles/morandi.scss';
  442. @import '@/styles/tab-page.scss';
  443. .rh-page {
  444. display: flex;
  445. flex-direction: column;
  446. min-width: 0;
  447. width: 100%;
  448. height: 100%;
  449. min-height: 100%;
  450. overflow: hidden;
  451. box-sizing: border-box;
  452. background: $morandi-bg-page;
  453. }
  454. .rh-body {
  455. flex: 1;
  456. min-height: 0;
  457. height: 0;
  458. min-width: 0;
  459. display: flex;
  460. flex-direction: column;
  461. padding: 16rpx 24rpx 24rpx;
  462. box-sizing: border-box;
  463. overflow: hidden;
  464. }
  465. .rh-list-wrap {
  466. flex: 1;
  467. min-height: 0;
  468. display: flex;
  469. flex-direction: column;
  470. min-width: 0;
  471. }
  472. .rh-submit-scroll {
  473. flex: 1;
  474. min-height: 0;
  475. min-width: 0;
  476. height: 0;
  477. box-sizing: border-box;
  478. padding: 20rpx 24rpx 24rpx;
  479. }
  480. .rh-submit-card {
  481. margin-bottom: 24rpx;
  482. }
  483. .rh-submit-org {
  484. display: block;
  485. font-size: 30rpx;
  486. font-weight: 600;
  487. color: #111827;
  488. margin-bottom: 24rpx;
  489. word-break: break-word;
  490. }
  491. .rh-score-row {
  492. display: flex;
  493. flex-direction: row;
  494. align-items: center;
  495. justify-content: space-between;
  496. gap: 16rpx;
  497. margin-bottom: 20rpx;
  498. min-width: 0;
  499. }
  500. .rh-score-label {
  501. font-size: 26rpx;
  502. color: #374151;
  503. flex-shrink: 0;
  504. }
  505. .rh-score-label--block {
  506. display: block;
  507. margin: 8rpx 0 12rpx;
  508. }
  509. .rh-submit-actions {
  510. padding: 0 8rpx;
  511. }
  512. .rh-footer-spacer {
  513. height: 32rpx;
  514. }
  515. .rh-empty {
  516. flex: 1;
  517. display: flex;
  518. align-items: center;
  519. justify-content: center;
  520. min-height: 0;
  521. padding: 80rpx 24rpx;
  522. box-sizing: border-box;
  523. }
  524. .rh-empty__txt {
  525. color: $morandi-text-muted;
  526. }
  527. .rh-vlist {
  528. flex: 1;
  529. min-height: 0;
  530. width: 100%;
  531. height: 100%;
  532. }
  533. .rh-cell {
  534. box-sizing: border-box;
  535. padding-bottom: 10rpx;
  536. }
  537. .rh-card {
  538. padding: 20rpx;
  539. box-sizing: border-box;
  540. background: #ffffff;
  541. border-radius: 12rpx;
  542. border: 1rpx solid $morandi-border-soft;
  543. min-width: 0;
  544. }
  545. .rh-row {
  546. display: flex;
  547. flex-direction: row;
  548. align-items: flex-start;
  549. gap: 16rpx;
  550. min-width: 0;
  551. }
  552. .rh-right {
  553. flex: 1;
  554. min-width: 0;
  555. display: flex;
  556. flex-direction: column;
  557. gap: 8rpx;
  558. }
  559. .rh-name {
  560. font-size: 28rpx;
  561. font-weight: 600;
  562. color: #111827;
  563. line-height: 1.35;
  564. word-break: break-word;
  565. }
  566. .rh-date {
  567. font-size: 22rpx;
  568. color: $morandi-text-muted;
  569. line-height: 1.4;
  570. }
  571. .rh-scores {
  572. display: flex;
  573. flex-direction: column;
  574. gap: 6rpx;
  575. min-width: 0;
  576. }
  577. .rh-score-item {
  578. display: flex;
  579. flex-direction: row;
  580. align-items: center;
  581. gap: 12rpx;
  582. min-width: 0;
  583. }
  584. .rh-score-item__label {
  585. flex-shrink: 0;
  586. width: 128rpx;
  587. font-size: 22rpx;
  588. color: $morandi-text-muted;
  589. line-height: 1.35;
  590. }
  591. .rh-content {
  592. font-size: 24rpx;
  593. color: $morandi-text-secondary;
  594. line-height: 1.5;
  595. word-break: break-word;
  596. }
  597. .rh-page.lang-bo {
  598. .rh-name {
  599. font-size: 26rpx;
  600. line-height: 1.75;
  601. }
  602. }
  603. </style>