| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640 |
- <template>
- <!-- 历史评价:见 doc/app/预约服务/预约服务接口说明.md §4;我的预约评价见 myAppointment -->
- <view :class="pageRootClass" class="tab-page rh-page" :style="pageStyle">
- <!-- 提交评价(我的预约 → 专家已完成) -->
- <scroll-view
- v-if="pageMode === 'appointmentSubmit'"
- scroll-y
- class="rh-submit-scroll"
- enable-back-to-top
- :show-scrollbar="false"
- >
- <view class="rh-card rh-submit-card">
- <text v-if="providerName" class="rh-submit-org text-body">{{ providerName }}</text>
- <view v-for="dim in scoreDims" :key="dim.key" class="rh-score-row">
- <text class="rh-score-label text-body">{{ $t(dim.labelKey) }}</text>
- <up-rate
- v-model="form[dim.key]"
- :count="5"
- :size="22"
- gutter="6"
- active-color="#EAB308"
- inactive-color="#d1d5db"
- />
- </view>
- <text class="rh-score-label text-body rh-score-label--block">{{ $t('reviewHistoryPage.labelSummary') }}</text>
- <up-textarea
- v-model="form.summary"
- height="140"
- count
- maxlength="100"
- border="surround"
- :placeholder="$t('reviewHistoryPage.summaryPh')"
- />
- </view>
- <view class="rh-submit-actions">
- <up-button
- type="success"
- shape="circle"
- :loading="submitting"
- :text="submitting ? $t('reviewHistoryPage.submitting') : $t('reviewHistoryPage.btnSubmit')"
- @click="onSubmitReview"
- />
- </view>
- <view class="rh-footer-spacer" />
- </scroll-view>
- <!-- 评价列表 / 单条查看 -->
- <view v-else class="rh-body">
- <view v-if="listLoading && !rows.length" class="rh-empty">
- <text class="text-body rh-empty__txt">{{ $t('reviewHistoryPage.loading') }}</text>
- </view>
- <view v-else-if="!rows.length" class="rh-empty">
- <text class="text-body rh-empty__txt">{{ $t('reviewHistoryPage.empty') }}</text>
- </view>
- <view v-else class="rh-list-wrap">
- <up-virtual-list
- ref="rhVList"
- class="rh-vlist"
- :list-data="rows"
- :item-height="slotHeightPx"
- :height="listHeightPx"
- :buffer="6"
- key-field="id"
- :scroll-top="vScrollTop"
- @update:scrollTop="vScrollTop = $event"
- @scroll="onVirtualListScroll"
- >
- <template #default="{ item }">
- <view class="rh-cell">
- <view class="rh-card" :style="{ height: rowBodyPx + 'px' }">
- <view class="rh-row">
- <up-avatar
- shape="circle"
- :text="item.avatarText"
- size="48"
- font-size="18"
- bg-color="#9ca3af"
- color="#ffffff"
- />
- <view class="rh-right">
- <text class="rh-name text-body">{{ item.reviewerName }}</text>
- <text class="rh-date text-body">{{ item.dateStr }}</text>
- <view class="rh-scores">
- <view v-for="dim in scoreDims" :key="dim.key" class="rh-score-item">
- <text class="rh-score-item__label text-body">{{ $t(dim.labelKey) }}</text>
- <up-rate
- :model-value="scoreForItem(item, dim.key)"
- readonly
- :touchable="false"
- :count="5"
- :size="12"
- gutter="2"
- active-color="#EAB308"
- inactive-color="#d1d5db"
- />
- </view>
- </view>
- <text class="rh-content text-body">{{ item.summary }}</text>
- </view>
- </view>
- </view>
- </view>
- </template>
- </up-virtual-list>
- </view>
- </view>
- </view>
- </template>
- <script>
- import UAvatar from 'uview-plus/components/u-avatar/u-avatar.vue'
- import UButton from 'uview-plus/components/u-button/u-button.vue'
- import URate from 'uview-plus/components/u-rate/u-rate.vue'
- import UTextarea from 'uview-plus/components/u-textarea/u-textarea.vue'
- import UVirtualList from 'uview-plus/components/u-virtual-list/u-virtual-list.vue'
- import tabPage from '@/mixins/tabPage'
- import { ensureApiToken } from '@/utils/apiAuth'
- import { listExpertBookingReviews } from '@/api/bookingService'
- import { getMyAppointmentReview, submitMyAppointmentReview } from '@/api/myAppointment'
- const ROW_BODY_RPX = 400
- const ROW_GAP_RPX = 20
- const LIST_PAGE_SIZE = 20
- const RH_BODY_PAD_RPX = 40
- const SCORE_DIMS = [
- { key: 'punctualityScore', labelKey: 'reviewHistoryPage.scorePunctuality' },
- { key: 'attitudeScore', labelKey: 'reviewHistoryPage.scoreAttitude' },
- { key: 'guidanceScore', labelKey: 'reviewHistoryPage.scoreGuidance' }
- ]
- function normalizeScore(raw) {
- const v = Number(raw)
- if (!Number.isFinite(v) || v <= 0) return 0
- return Math.min(5, Math.max(0, Math.round(v)))
- }
- function formatReviewTime(raw) {
- if (!raw) return ''
- const s = String(raw).trim()
- if (s.length >= 16) return s.slice(0, 16)
- if (s.length >= 10) return s.slice(0, 10)
- return s
- }
- export default {
- components: {
- 'up-avatar': UAvatar,
- 'up-button': UButton,
- 'up-rate': URate,
- 'up-textarea': UTextarea,
- 'up-virtual-list': UVirtualList
- },
- mixins: [tabPage],
- data() {
- return {
- pageMode: 'expertList',
- expertId: '',
- appointmentId: '',
- providerName: '',
- rows: [],
- listLoading: false,
- listLoadingMore: false,
- listTotal: 0,
- pageNum: 1,
- pageSize: LIST_PAGE_SIZE,
- loadMoreTimer: null,
- submitting: false,
- form: {
- punctualityScore: 5,
- attitudeScore: 5,
- guidanceScore: 5,
- summary: ''
- },
- rowBodyPx: 120,
- marginPx: 10,
- slotHeightPx: 130,
- listHeightPx: 400,
- pageHeightPx: 0,
- vScrollTop: 0
- }
- },
- computed: {
- scoreDims() {
- return SCORE_DIMS
- },
- navTitleKey() {
- if (this.pageMode === 'appointmentSubmit') return 'reviewHistoryPage.navTitleSubmit'
- if (this.pageMode === 'appointmentView') return 'reviewHistoryPage.navTitleView'
- return 'reviewHistoryPage.navTitle'
- },
- listNoMore() {
- return this.listTotal > 0 && this.rows.length >= this.listTotal
- },
- pageStyle() {
- if (this.pageHeightPx > 0) {
- return { height: `${this.pageHeightPx}px` }
- }
- return {}
- }
- },
- onLoad(query) {
- if (!ensureApiToken()) return
- const q = query || {}
- this.appointmentId = q.appointmentId ? decodeURIComponent(String(q.appointmentId)) : ''
- this.providerName = q.providerName ? decodeURIComponent(String(q.providerName)) : ''
- const mode = q.mode ? decodeURIComponent(String(q.mode)) : 'view'
- this.expertId = q.id ? decodeURIComponent(String(q.id)) : ''
- if (this.appointmentId) {
- this.pageMode = mode === 'submit' ? 'appointmentSubmit' : 'appointmentView'
- } else if (this.expertId) {
- this.pageMode = 'expertList'
- } else {
- this.pageMode = 'expertList'
- }
- if (this.pageMode === 'expertList' && this.expertId) {
- this.loadExpertReviews(true)
- } else if (this.pageMode === 'appointmentView') {
- this.loadAppointmentReview()
- }
- },
- onReady() {
- try {
- this.marginPx = Math.ceil(uni.upx2px(ROW_GAP_RPX))
- this.rowBodyPx = Math.max(100, Math.ceil(uni.upx2px(ROW_BODY_RPX)))
- this.slotHeightPx = this.rowBodyPx + this.marginPx
- } catch (e) {
- this.marginPx = 10
- this.rowBodyPx = 120
- this.slotHeightPx = 130
- }
- if (this.pageMode !== 'appointmentSubmit') {
- this.applyListHeightFallback()
- this.$nextTick(() => this.calcLayoutHeights())
- } else {
- this.applyListHeightFallback()
- }
- },
- onUnload() {
- if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
- },
- methods: {
- mapReviewRow(row, idx) {
- const name = (row.reviewerName || '').trim() || this.$t('reviewHistoryPage.anonymous')
- return {
- id: row.id != null ? String(row.id) : `r-${idx}`,
- reviewerName: name,
- avatarText: name.slice(0, 1) || '?',
- dateStr: formatReviewTime(row.reviewTime),
- punctualityScore: normalizeScore(row.punctualityScore),
- attitudeScore: normalizeScore(row.attitudeScore),
- guidanceScore: normalizeScore(row.guidanceScore),
- summary: (row.summary || '').trim() || '—'
- }
- },
- scoreForItem(item, key) {
- if (!item || !key) return 0
- return normalizeScore(item[key])
- },
- loadExpertReviews(reset = false) {
- if (!this.expertId) return Promise.resolve()
- if (!reset) {
- if (this.listLoading || this.listLoadingMore || this.listNoMore) {
- return Promise.resolve()
- }
- this.pageNum += 1
- this.listLoadingMore = true
- } else {
- this.pageNum = 1
- this.listLoading = true
- }
- return listExpertBookingReviews(this.expertId, {
- pageNum: this.pageNum,
- pageSize: this.pageSize
- })
- .then((res) => {
- const raw = res.rows || []
- this.listTotal = res.total != null ? Number(res.total) : 0
- const mapped = raw.map((row, idx) => this.mapReviewRow(row, idx))
- this.rows = reset ? mapped : this.rows.concat(mapped)
- this.$nextTick(() => {
- this.calcLayoutHeights()
- })
- })
- .catch(() => {
- if (reset) {
- this.rows = []
- this.listTotal = 0
- } else {
- this.pageNum -= 1
- }
- })
- .finally(() => {
- if (reset) {
- this.listLoading = false
- } else {
- this.listLoadingMore = false
- }
- })
- },
- loadAppointmentReview() {
- if (!this.appointmentId) return
- this.listLoading = true
- getMyAppointmentReview(this.appointmentId)
- .then((res) => {
- const data = res.data
- if (data && typeof data === 'object') {
- this.rows = [this.mapReviewRow(data, 0)]
- this.listTotal = 1
- } else {
- this.rows = []
- this.listTotal = 0
- }
- this.$nextTick(() => this.calcLayoutHeights())
- })
- .catch(() => {
- this.rows = []
- this.listTotal = 0
- })
- .finally(() => {
- this.listLoading = false
- })
- },
- validateSubmitForm() {
- for (const dim of SCORE_DIMS) {
- const v = Number(this.form[dim.key])
- if (!Number.isFinite(v) || v < 1 || v > 5) {
- uni.showToast({ title: this.$t('reviewHistoryPage.errScore', { label: this.$t(dim.labelKey) }), icon: 'none' })
- return false
- }
- }
- const summary = (this.form.summary || '').trim()
- if (!summary) {
- uni.showToast({ title: this.$t('reviewHistoryPage.errSummary'), icon: 'none' })
- return false
- }
- if (summary.length > 100) {
- uni.showToast({ title: this.$t('reviewHistoryPage.errSummaryLen'), icon: 'none' })
- return false
- }
- return true
- },
- onSubmitReview() {
- if (this.submitting || !this.appointmentId) return
- if (!this.validateSubmitForm()) return
- this.submitting = true
- submitMyAppointmentReview(this.appointmentId, {
- punctualityScore: Number(this.form.punctualityScore),
- attitudeScore: Number(this.form.attitudeScore),
- guidanceScore: Number(this.form.guidanceScore),
- summary: (this.form.summary || '').trim()
- })
- .then(() => {
- uni.showToast({ title: this.$t('reviewHistoryPage.toastSubmitOk'), icon: 'success' })
- setTimeout(() => uni.navigateBack(), 800)
- })
- .catch(() => {})
- .finally(() => {
- this.submitting = false
- })
- },
- tryAutoLoadMore() {
- if (this.pageMode !== 'expertList') return
- if (this.listLoading || this.listLoadingMore || this.listNoMore) return
- const totalH = this.rows.length * this.slotHeightPx
- const viewH = this.listHeightPx
- if (totalH > 0 && totalH <= viewH) {
- this.loadExpertReviews(false)
- }
- },
- onVirtualListScroll(scrollTop) {
- if (this.pageMode !== 'expertList') return
- if (this.loadMoreTimer) clearTimeout(this.loadMoreTimer)
- this.loadMoreTimer = setTimeout(() => {
- this.loadMoreTimer = null
- this.tryLoadMoreOnScroll(typeof scrollTop === 'number' ? scrollTop : 0)
- }, 180)
- },
- tryLoadMoreOnScroll(scrollTop) {
- if (this.listLoading || this.listLoadingMore || this.listNoMore) return
- const totalH = this.rows.length * this.slotHeightPx
- const viewH = this.listHeightPx
- const threshold = Math.max(this.slotHeightPx * 2, 120)
- if (totalH <= viewH) {
- this.loadExpertReviews(false)
- return
- }
- if (scrollTop + viewH >= totalH - threshold) {
- this.loadExpertReviews(false)
- }
- },
- applyListHeightFallback() {
- const contentH = this.getViewportContentHeight()
- const bodyPad = Math.ceil(uni.upx2px(RH_BODY_PAD_RPX))
- this.pageHeightPx = contentH
- this.listHeightPx = Math.max(280, contentH - bodyPad)
- },
- getNavigationBarHeight() {
- const sys = uni.getSystemInfoSync()
- const statusBar = sys.statusBarHeight || 0
- // #ifdef MP-WEIXIN
- try {
- const menu = uni.getMenuButtonBoundingClientRect()
- if (menu && menu.height) {
- return statusBar + (menu.top - statusBar) * 2 + menu.height
- }
- } catch (e) {
- /* noop */
- }
- // #endif
- return statusBar + 44
- },
- getViewportContentHeight() {
- const sys = uni.getSystemInfoSync()
- const navH = this.getNavigationBarHeight()
- // #ifdef H5
- if (typeof window !== 'undefined' && window.innerHeight) {
- return Math.max(320, window.innerHeight - navH)
- }
- // #endif
- const screenH = sys.screenHeight || sys.windowHeight || 600
- return Math.max(320, screenH - navH)
- },
- calcLayoutHeights() {
- const contentH = this.getViewportContentHeight()
- this.pageHeightPx = contentH
- uni.createSelectorQuery()
- .in(this)
- .select('.rh-body')
- .boundingClientRect((rect) => {
- if (rect && rect.height > 0) {
- this.listHeightPx = Math.max(280, Math.floor(rect.height))
- } else {
- const bodyPad = Math.ceil(uni.upx2px(RH_BODY_PAD_RPX))
- this.listHeightPx = Math.max(280, contentH - bodyPad)
- }
- this.$nextTick(() => {
- this.$refs.rhVList?.measureContainerHeight?.()
- this.tryAutoLoadMore()
- })
- })
- .exec()
- }
- }
- }
- </script>
- <style lang="scss" scoped>
- @import '@/styles/morandi.scss';
- @import '@/styles/tab-page.scss';
- .rh-page {
- display: flex;
- flex-direction: column;
- min-width: 0;
- width: 100%;
- height: 100%;
- min-height: 100%;
- overflow: hidden;
- box-sizing: border-box;
- background: $morandi-bg-page;
- }
- .rh-body {
- flex: 1;
- min-height: 0;
- height: 0;
- min-width: 0;
- display: flex;
- flex-direction: column;
- padding: 16rpx 24rpx 24rpx;
- box-sizing: border-box;
- overflow: hidden;
- }
- .rh-list-wrap {
- flex: 1;
- min-height: 0;
- display: flex;
- flex-direction: column;
- min-width: 0;
- }
- .rh-submit-scroll {
- flex: 1;
- min-height: 0;
- min-width: 0;
- height: 0;
- box-sizing: border-box;
- padding: 20rpx 24rpx 24rpx;
- }
- .rh-submit-card {
- margin-bottom: 24rpx;
- }
- .rh-submit-org {
- display: block;
- font-size: 30rpx;
- font-weight: 600;
- color: #111827;
- margin-bottom: 24rpx;
- word-break: break-word;
- }
- .rh-score-row {
- display: flex;
- flex-direction: row;
- align-items: center;
- justify-content: space-between;
- gap: 16rpx;
- margin-bottom: 20rpx;
- min-width: 0;
- }
- .rh-score-label {
- font-size: 26rpx;
- color: #374151;
- flex-shrink: 0;
- }
- .rh-score-label--block {
- display: block;
- margin: 8rpx 0 12rpx;
- }
- .rh-submit-actions {
- padding: 0 8rpx;
- }
- .rh-footer-spacer {
- height: 32rpx;
- }
- .rh-empty {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
- min-height: 0;
- padding: 80rpx 24rpx;
- box-sizing: border-box;
- }
- .rh-empty__txt {
- color: $morandi-text-muted;
- }
- .rh-vlist {
- flex: 1;
- min-height: 0;
- width: 100%;
- height: 100%;
- }
- .rh-cell {
- box-sizing: border-box;
- padding-bottom: 10rpx;
- }
- .rh-card {
- padding: 20rpx;
- box-sizing: border-box;
- background: #ffffff;
- border-radius: 12rpx;
- border: 1rpx solid $morandi-border-soft;
- min-width: 0;
- }
- .rh-row {
- display: flex;
- flex-direction: row;
- align-items: flex-start;
- gap: 16rpx;
- min-width: 0;
- }
- .rh-right {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- gap: 8rpx;
- }
- .rh-name {
- font-size: 28rpx;
- font-weight: 600;
- color: #111827;
- line-height: 1.35;
- word-break: break-word;
- }
- .rh-date {
- font-size: 22rpx;
- color: $morandi-text-muted;
- line-height: 1.4;
- }
- .rh-scores {
- display: flex;
- flex-direction: column;
- gap: 6rpx;
- min-width: 0;
- }
- .rh-score-item {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: 12rpx;
- min-width: 0;
- }
- .rh-score-item__label {
- flex-shrink: 0;
- width: 128rpx;
- font-size: 22rpx;
- color: $morandi-text-muted;
- line-height: 1.35;
- }
- .rh-content {
- font-size: 24rpx;
- color: $morandi-text-secondary;
- line-height: 1.5;
- word-break: break-word;
- }
- .rh-page.lang-bo {
- .rh-name {
- font-size: 26rpx;
- line-height: 1.75;
- }
- }
- </style>
|