Files
QSY-lvjuxcx/pages/index/index.vue
liangjian 9fee2c3a74 feat: 优化首页服务入口与租住业务流程
补齐首页待办跳转、押金支付引导和多类租住服务页面能力,统一租客端关键业务流程体验。
2026-08-25 13:53:12 +08:00

2234 lines
75 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="index" style="background-color: #F6F6F6;">
<!-- 启动全屏加载遮罩 -->
<view v-if="isAppInitLoading" class="init-loading-mask">
<view class="premium-loading-wrapper">
<view class="icon-container">
<text class="ri-building-3-line premium-icon"></text>
<view class="spinner-ring"></view>
<view class="spinner-ring inner-ring"></view>
</view>
<view class="brand-text">{{ $tr('绿居公寓') }}</view>
<view class="brand-sub">LUJU APARTMENTS</view>
</view>
</view>
<!-- #ifdef MP-WEIXIN -->
<view v-if="showPrivacyPopup" class="privacy-mask">
<view class="privacy-dialog">
<view class="privacy-title">{{ $tr('隐私授权提示') }}</view>
<view class="privacy-content">
{{ $tr('进入小程序前,请先阅读并同意') }}
<text class="privacy-link" @click="openPrivacyContract">{{ $tr('《用户隐私保护指引》') }}</text>
{{ $tr(',同意后才能继续使用相关功能。') }}
</view>
<view class="privacy-action-row">
<view class="privacy-action-btn" @click="handlePrivacyDisagree">{{ $tr('暂不授权') }}</view>
<button
id="index-privacy-agree-btn"
class="privacy-action-btn is-primary"
open-type="agreePrivacyAuthorization"
@agreeprivacyauthorization="handleAgreePrivacyAuthorization"
>
{{ $tr('同意并继续') }}
</button>
</view>
</view>
</view>
<!-- #endif -->
<!-- 二级页面 -->
<view v-if="hasPage('select-room')" v-show="currentPageId === 'select-room'">
<scroll-view class="custom-tabbar-page" scroll-y enable-back-to-top @scrolltolower="tabbarPageScrollLower">
<select-room-tab ref="selectRoomTab"></select-room-tab>
</scroll-view>
</view>
<view v-if="hasPage('stay-service')" v-show="currentPageId === 'stay-service'">
<scroll-view class="custom-tabbar-page" scroll-y enable-back-to-top @scrolltolower="tabbarPageScrollLower">
<stay-service-tab
ref="stayServiceTab"
@refresh-shell-state="handleStayServiceRefreshState"
></stay-service-tab>
</scroll-view>
</view>
<view v-if="hasPage('message-center')" v-show="currentPageId === 'message-center'">
<scroll-view class="custom-tabbar-page" scroll-y enable-back-to-top @scrolltolower="tabbarPageScrollLower">
<message-center-tab ref="messageCenterTab"></message-center-tab>
</scroll-view>
</view>
<view v-if="hasPage('profile-center')" v-show="currentPageId === 'profile-center'">
<scroll-view class="custom-tabbar-page" scroll-y enable-back-to-top @scrolltolower="tabbarPageScrollLower">
<profile-center-tab ref="profileCenterTab"></profile-center-tab>
</scroll-view>
</view>
<contract-alert
v-if="sessionState.isLogin && sessionState.pendingContracts.length"
:pending-contracts="sessionState.pendingContracts"
/>
<view
v-if="showTodoEntry"
class="todo-float"
>
<view
class="todo-float__box"
:class="{ 'is-collapsed': !isTodoExpanded }"
@tap="handleTodoFloatClick"
>
<view class="todo-float__icon-wrap">
<text class="ri-task-line"></text>
<view v-if="todoBadgeCount > 0" class="todo-float__badge">{{ getTodoBadgeText() }}</view>
</view>
<view class="todo-float__content-wrap">
<view class="todo-float__content">
<view class="todo-float__title">{{ $tr('我的待办') }}</view>
<view class="todo-float__desc">{{ todoFloatDesc }}</view>
</view>
<view class="todo-float__action">
<view class="todo-float__action-btn">{{ $tr('查看') }}</view>
</view>
</view>
</view>
</view>
<tn-popup
v-model="showTodoPopup"
mode="bottom"
:customStyle="todoPopupCustomStyle"
:borderRadius="24"
:safeAreaInsetBottom="true"
:closeBtn="false"
:zIndex="100193"
@close="handleTodoPopupClose"
>
<view class="todo-popup">
<view class="todo-popup__header">
<view>
<view class="todo-popup__title">{{ $tr('我的待办') }}</view>
<view class="todo-popup__subtitle">
{{ todoBadgeCount > 0 ? $tr('当前共 {count} 项待处理').replace('{count}', todoBadgeCount) : $tr('当前暂无待办事项') }}
</view>
</view>
<view class="todo-popup__close" @tap="handleTodoPopupClose">
<text class="ri-close-line"></text>
</view>
</view>
<scroll-view scroll-y class="todo-popup__list">
<view v-if="todoVisibleItems.length > 0" class="todo-popup__content">
<view
v-for="(item, index) in todoVisibleItems"
:key="`${item.todoType || 'todo'}-${index}`"
class="todo-popup__item"
>
<view class="todo-popup__item-head">
<view class="todo-popup__item-title-wrap">
<view class="todo-popup__item-title">{{ resolveTodoTypeTitle(item) }}</view>
<view class="todo-popup__item-action">{{ resolveTodoGroupAction(item) }}</view>
</view>
<view class="todo-popup__item-count">{{ item.count }} {{ $tr('项') }}</view>
</view>
<view v-if="resolveTodoEntries(item).length > 0" class="todo-popup__entry-list">
<view
v-for="(entry, entryIndex) in resolveTodoEntries(item)"
:key="resolveTodoEntryKey(item, entry, entryIndex)"
class="todo-popup__entry"
:class="{ 'is-disabled': !canOpenTodoEntry(item, entry) }"
@tap.stop="handleTodoEntryClick(item, entry)"
>
<view class="todo-popup__entry-main">
<view class="todo-popup__entry-desc is-primary">
{{ resolveTodoEntryDescription(entry) }}
</view>
<view v-if="resolveTodoEntryCode(entry)" class="todo-popup__entry-code">
{{ resolveTodoEntryCode(entry) }}
</view>
</view>
<view class="todo-popup__entry-side">
<view class="todo-popup__entry-action">{{ resolveTodoEntryAction(item, entry) }}</view>
<view class="todo-popup__entry-arrow">
<text class="ri-arrow-right-s-line"></text>
</view>
</view>
</view>
</view>
<view v-else class="todo-popup__empty-tip">{{ resolveTodoEmptyTip(item) }}</view>
</view>
</view>
<view v-else class="todo-popup__empty">
<view class="todo-popup__empty-icon">
<text class="ri-checkbox-circle-line"></text>
</view>
<view class="todo-popup__empty-title">{{ $tr('暂无待办') }}</view>
<view class="todo-popup__empty-desc">{{ $tr('当前暂无待办事项') }}</view>
</view>
</scroll-view>
</view>
</tn-popup>
<!-- <view class="bg-tabbar-shadow"></view> -->
<!-- 底部导航栏 -->
<view class="tabbar">
<view class="tabbar__bg" :style="[wrapStyle]"></view>
<view class="tabbar__list">
<block v-for="(item, index) in currentTabbarList" :key="item.id">
<view :id="`tabbar_item_${index}`" class="tabbar__item"
:class="[{'tabbar__item--active': index === currentTabbarIndex}]" @click="changeTabbar(index, item.id)">
<!-- 可以用字体图标的方式 -->
<view class="tabbar__item__icon-wrap">
<view class="tabbar__item__icon" :class="[item.icon]"></view>
<view
v-if="item.id === 'message-center' && messageBadgeCount > 0"
class="tabbar__item__badge"
>
{{ getMessageBadgeText() }}
</view>
</view>
<!-- <image class="tabbar__item__image" :src="item.image"></image> -->
<!-- 底部文字,需要自行显示出来-->
<view class="tabbar__item__text">{{ item.name }}</view>
</view>
</block>
</view>
<!-- <view class="tabbar__select-active-bg" :class="[showActiceBg ? 'tabbar__select-active-bg--show' : 'tabbar__select-active-bg--hide']" :style="[activeBgStyle]"></view> -->
<view class="tabbar__select-active-bg" :animation="activeBgAnimation"></view>
</view>
</view>
</template>
<script>
import SelectRoomTab from './component/SelectRoomTab.vue'
import StayServiceTab from './component/StayServiceTab.vue'
import MessageCenterTab from './component/MessageCenterTab.vue'
import ProfileCenterTab from './component/ProfileCenterTab.vue'
import ContractAlert from '../../components/contract-alert/contract-alert.vue'
import {
clearLegacyDemoCache,
getCurrentUser,
getHomePageTabId,
getSessionSnapshot,
saveBookingList,
saveCurrentBooking,
saveCurrentUser,
saveEffectiveContracts,
saveHomePageTabId,
savePendingContractCount,
savePendingContracts,
setCheckedInStatus
} from '../../utils/app-state'
import { getMyTodoList, notificationPage, ticketOrderFlows, orderSearchedDetail, orderSearchedReservationFlow, userMe } from '@/api/index.js'
import { mergeBackendUserInfo } from '@/utils/flow'
import { bindPrivacyAuthorizationListener, getPrivacyAuthorizationStatus, openWechatPrivacyContract, unbindPrivacyAuthorizationListener } from '@/utils/privacy.js'
import { resolveNotificationUnreadTotal } from '../../utils/message-helper'
import { getReservationCurrentFlow, getReservationCurrentNode, hasEffectiveReservationFlow, parseReservationFlowResponse } from '@/utils/reservation-flow'
import { getOrderStatusCode, normalizeBookingDetail, parseOrderDetailResponse } from '../../utils/booking-helper'
import { extractTodoRows, normalizeTodoList } from '../../utils/todo-route'
const STAY_STATE_SYNC_TTL = 800
const FLOW_AUTO_REFRESH_INTERVAL = 5000
export default {
components: {
SelectRoomTab,
StayServiceTab,
MessageCenterTab,
ProfileCenterTab,
ContractAlert
},
data() {
return {
wrapMaskPositionLeft: -15,
activeBgPositionLeft: 0,
currentPageId: 'select-room',
tabbarRectInfo: [],
activeBgAnimation: {},
messageBadgeCount: 0,
todoBadgeCount: 0,
todoList: [],
isTodoExpanded: true,
todoCollapseTimer: null,
todoPollingTimer: null,
todoNavigatingKey: '',
showTodoPopup: false,
sessionState: {
isLogin: false,
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
pendingContracts: []
},
tabDefinitions: [
{ id: 'select-room', labelKey: '选 房', icon: 'tn-icon-home-fill', image: '/static/tabbar/tn-tabbar0.png', component: 'SelectRoomTab', ref: 'selectRoomTab', refreshMethod: 'refreshSelectRoomState' },
{ id: 'stay-service', labelKey: '入 住', icon: 'tn-icon-safe', image: '/static/tabbar/tn-tabbar1.png', component: 'StayServiceTab', ref: 'stayServiceTab', refreshMethod: 'checkLocalStatus' },
{ id: 'message-center', labelKey: '消 息', icon: 'tn-icon-message', image: '/static/tabbar/tn-tabbar3.png', component: 'MessageCenterTab', ref: 'messageCenterTab', refreshMethod: 'load' },
{ id: 'profile-center', labelKey: '我 的', icon: 'tn-icon-my-fill', image: '/static/tabbar/tn-tabbar3.png', component: 'ProfileCenterTab', ref: 'profileCenterTab', refreshMethod: 'checkLoginStatus' }
],
loadedPages: ['select-room'],
isAppInitLoading: true,
isTabSwitching: false,
showPrivacyPopup: false,
resolvePrivacyAuthorization: null,
isCheckingPrivacyAuthorization: false
}
},
computed: {
wrapStyle() {
return {
'-webkit-mask-position': `${this.wrapMaskPositionLeft}px -1px, 100%`
}
},
visibleTabIds() {
if (!this.sessionState.isLogin) {
return ['select-room', 'profile-center']
}
const tabs = []
const shouldShowStayService = this.sessionState.hasCheckedIn
|| this.sessionState.hasPendingApplication
|| this.sessionState.hasActiveReservationFlow
|| this.sessionState.pendingContracts.length > 0
if (shouldShowStayService) {
tabs.push('stay-service')
} else {
tabs.push('select-room')
}
tabs.push('message-center', 'profile-center')
return tabs
},
currentTabbarList() {
return this.tabDefinitions
.filter(item => this.visibleTabIds.includes(item.id))
.map(item => ({
...item,
name: this.$tr(item.labelKey)
}))
},
currentTabbarIndex() {
const index = this.currentTabbarList.findIndex(item => item.id === this.currentPageId)
return index === -1 ? 0 : index
},
currentOrderNo() {
return this.getCurrentOrderNo()
},
todoPopupCustomStyle() {
return {
bottom: '0'
}
},
todoVisibleItems() {
return this.todoList.filter((item) => {
const count = Math.max(Number(item && item.count) || 0, 0)
return count > 0 || this.resolveTodoEntries(item).length > 0
})
},
todoFloatDesc() {
if (!this.todoBadgeCount) return this.$tr('当前暂无待办事项')
return this.$tr('待处理 {count} 项').replace('{count}', this.todoBadgeCount)
},
showTodoEntry() {
return !!(this.sessionState.isLogin && this.sessionState.hasCheckedIn && this.currentOrderNo)
},
},
watch: {
currentOrderNo(newVal, oldVal) {
if (newVal === oldVal) return
this.updateTodoPolling({ forceFetch: true })
},
showTodoEntry(newVal) {
if (!newVal) {
this.stopTodoPolling(true)
this.clearTodoCollapseTimer()
this.showTodoPopup = false
this.isTodoExpanded = true
return
}
this.expandTodoFloat()
},
todoBadgeCount(newVal, oldVal) {
if (newVal === oldVal || !this.showTodoEntry) return
this.expandTodoFloat()
}
},
onShow() {
this.handlePageShow()
this.checkEntryPrivacyAuthorization()
},
onHide() {
this.stopReservationFlowAutoRefresh()
this.stopTodoPolling()
this.showTodoPopup = false
},
onLoad(options) {
clearLegacyDemoCache()
uni.$on('messageBadgeCountChange', this.handleMessageBadgeCountChange)
uni.$on('reservation-flow-force-refresh', this.handleReservationFlowForceRefresh)
this.initPrivacyAuthorization()
this.checkEntryPrivacyAuthorization()
this.handlePageLoad(options)
},
onUnload() {
this.stopReservationFlowAutoRefresh()
this.stopTodoPolling()
this.clearTodoCollapseTimer()
uni.$off('messageBadgeCountChange', this.handleMessageBadgeCountChange)
uni.$off('reservation-flow-force-refresh', this.handleReservationFlowForceRefresh)
this.releasePrivacyAuthorization()
},
onReady() {
this.$nextTick(() => {
this.getTabbarItemInfo()
})
},
methods: {
initPrivacyAuthorization() {
// #ifdef MP-WEIXIN
if (typeof wx === 'undefined' || !wx.onNeedPrivacyAuthorization) {
return
}
this.handlePrivacyAuthorizationRequest = (resolve) => {
this.resolvePrivacyAuthorization = resolve
this.showPrivacyPopup = true
}
bindPrivacyAuthorizationListener(this.handlePrivacyAuthorizationRequest)
// #endif
},
releasePrivacyAuthorization() {
// #ifdef MP-WEIXIN
if (!this.handlePrivacyAuthorizationRequest) {
return
}
unbindPrivacyAuthorizationListener(this.handlePrivacyAuthorizationRequest)
this.handlePrivacyAuthorizationRequest = null
this.resolvePrivacyAuthorization = null
this.showPrivacyPopup = false
// #endif
},
checkEntryPrivacyAuthorization() {
// #ifdef MP-WEIXIN
if (this.isCheckingPrivacyAuthorization) {
return
}
this.isCheckingPrivacyAuthorization = true
getPrivacyAuthorizationStatus()
.then((res) => {
if (!this.resolvePrivacyAuthorization) {
this.showPrivacyPopup = !!(res && res.needAuthorization)
}
})
.finally(() => {
this.isCheckingPrivacyAuthorization = false
})
// #endif
},
openPrivacyContract() {
// #ifdef MP-WEIXIN
openWechatPrivacyContract().catch((error) => {
console.error('open privacy contract failed', error)
})
// #endif
},
handleAgreePrivacyAuthorization() {
this.showPrivacyPopup = false
if (!this.resolvePrivacyAuthorization) {
return
}
const resolvePrivacyAuthorization = this.resolvePrivacyAuthorization
this.resolvePrivacyAuthorization = null
resolvePrivacyAuthorization({
buttonId: 'index-privacy-agree-btn',
event: 'agree'
})
},
handlePrivacyDisagree() {
this.showPrivacyPopup = false
if (!this.resolvePrivacyAuthorization) {
return
}
const resolvePrivacyAuthorization = this.resolvePrivacyAuthorization
this.resolvePrivacyAuthorization = null
resolvePrivacyAuthorization({
event: 'disagree'
})
},
getStoreUser() {
return (this.$store && this.$store.state && this.$store.state.vuex_user) || {}
},
isCurrentLogin() {
const storeUser = this.getStoreUser()
if (storeUser && Object.keys(storeUser).length) {
return true
}
return !!getSessionSnapshot().isLogin
},
getStoreCheckinStatus() {
return !!(this.$store && this.$store.state && this.$store.state.vuex_checkin_status)
},
getStoreCurrentOrder() {
return (this.$store && this.$store.state && this.$store.state.vuex_current_order) || null
},
getStorePendingContracts() {
const list = this.$store && this.$store.state && this.$store.state.vuex_pending_contracts
return Array.isArray(list) ? list : []
},
getCachedStayStateResult() {
const storeUser = this.getStoreUser()
return {
hasCheckedIn: this.getStoreCheckinStatus(),
hasPendingApplication: !!storeUser.hasPendingApplication,
hasActiveReservationFlow: !!storeUser.hasActiveReservationFlow,
currentStep: storeUser.currentReservationStep || null,
currentRefNo: storeUser.currentReservationRefNo || '',
currentOrderNo: storeUser.currentFlow && storeUser.currentFlow.orderNo ? storeUser.currentFlow.orderNo : '',
flowList: Array.isArray(storeUser.reservationFlowList) ? storeUser.reservationFlowList : []
}
},
syncStayState(force = false) {
if (!this.isCurrentLogin()) {
this.refreshSessionState()
return Promise.resolve({
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
})
}
const now = Date.now()
if (this._syncStayStatePromise) {
return this._syncStayStatePromise
}
if (this._lastStayStateSyncAt && now - this._lastStayStateSyncAt < STAY_STATE_SYNC_TTL) {
return Promise.resolve(this.getCachedStayStateResult())
}
const storeUser = this.getStoreUser()
const cachedUserCode = storeUser.userCode || storeUser.code || ''
// 先获取用户信息(含 userCode),再获取流程
this._syncStayStatePromise = userMe({}, this.$getLanguage())
.then((response) => {
const responseData = response && response.data ? response.data : response
const info = responseData && responseData.data ? responseData.data : responseData
const mergedUser = mergeBackendUserInfo(storeUser, response)
const resolvedUserCode = mergedUser.userCode || mergedUser.code || info.userCode || info.code || cachedUserCode || ''
if (this.$store && this.$store.commit) {
this.$store.commit('$tStore', { name: 'vuex_user', value: mergedUser })
}
saveCurrentUser(mergedUser)
if (!resolvedUserCode) {
this.refreshSessionState()
return Promise.resolve({
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
})
}
return this.fetchReservationFlowWithUserCode(resolvedUserCode)
})
.catch((error) => {
console.error('fetch user info before flow failed', error)
if (!cachedUserCode) {
this.refreshSessionState()
return Promise.resolve({
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
})
}
return this.fetchReservationFlowWithUserCode(cachedUserCode)
})
.then((result) => {
this._lastStayStateSyncAt = Date.now()
return result
})
.finally(() => {
this._syncStayStatePromise = null
})
return this._syncStayStatePromise
},
fetchReservationFlowWithUserCode(userCode, options = {}) {
const { silent = true } = options
const storeUser = this.getStoreUser()
return orderSearchedReservationFlow({
userCode
}, this.$getLanguage())
.then((response) => {
const flowList = parseReservationFlowResponse(response)
const currentFlow = getReservationCurrentFlow(flowList) || flowList[0] || null
const currentNode = currentFlow ? getReservationCurrentNode(currentFlow) : null
const currentStep = currentNode ? currentNode.step : null
const currentRefNo = currentNode ? String(currentNode.refNo || '').trim() : ''
const hasActiveReservationFlow = flowList.some(item => hasEffectiveReservationFlow(item))
const hasPendingApplication = currentStep === 1 && !!currentRefNo
const currentBooking = hasActiveReservationFlow && currentFlow
? {
orderNo: String(currentFlow.orderNo || (Number(currentStep || 0) === 5 ? currentRefNo : '') || '').trim(),
refNo: currentRefNo,
step: currentStep
}
: null
const currentOrderNo = String(
(currentFlow && currentFlow.orderNo)
|| (Number(currentStep || 0) === 5 ? currentRefNo : '')
|| ''
).trim()
const resolveCheckedInState = () => {
if (Number(currentStep || 0) !== 5 || !currentOrderNo) {
return Promise.resolve({
hasCheckedIn: false,
currentOrder: null
})
}
return orderSearchedDetail(currentOrderNo, this.$getLanguage())
.then((detailResponse) => {
const orderDetail = normalizeBookingDetail(parseOrderDetailResponse(detailResponse), this.$getLanguage())
return {
hasCheckedIn: Number(getOrderStatusCode(orderDetail || {})) === 10,
currentOrder: orderDetail || null
}
})
.catch((error) => {
console.error('fetch current order detail before checkin failed', error)
return {
hasCheckedIn: false,
currentOrder: null
}
})
}
return resolveCheckedInState().then((checkinState = {}) => {
const hasCheckedIn = !!checkinState.hasCheckedIn
const currentOrder = hasCheckedIn ? (checkinState.currentOrder || null) : null
if (this.$store && this.$store.commit) {
this.$store.commit('$tStore', { name: 'vuex_checkin_status', value: hasCheckedIn })
this.$store.commit('$tStore', { name: 'vuex_current_order', value: currentOrder })
this.$store.commit('$tStore', { name: 'vuex_pending_contracts', value: [] })
this.$store.commit('$tStore', { name: 'vuex_pending_contract_count', value: 0 })
this.$store.commit('$tStore', { name: 'vuex_effective_contracts', value: [] })
this.$store.commit('$tStore', { name: 'vuex_valid_orders', value: [] })
this.$store.commit('$tStore', {
name: 'vuex_user',
value: {
...storeUser,
hasCheckedIn,
currentOrder,
currentBooking,
currentFlow,
bookingStatus: hasCheckedIn ? 'checked_in' : 'none',
reservationFlowList: flowList,
currentReservationStep: currentStep,
currentReservationRefNo: currentRefNo,
hasActiveReservationFlow,
hasPendingApplication
}
})
}
setCheckedInStatus(hasCheckedIn)
saveCurrentBooking(currentBooking)
saveBookingList([])
savePendingContracts([])
savePendingContractCount(0)
saveEffectiveContracts([])
const currentUser = getCurrentUser() || {}
saveCurrentUser({
...currentUser,
hasCheckedIn,
currentOrder,
currentBooking,
currentFlow,
bookingStatus: hasCheckedIn ? 'checked_in' : 'none',
reservationFlowList: flowList,
currentReservationStep: currentStep,
currentReservationRefNo: currentRefNo,
hasActiveReservationFlow,
hasPendingApplication
})
this.refreshSessionState()
return {
hasCheckedIn,
hasPendingApplication,
hasActiveReservationFlow,
currentStep,
currentRefNo,
currentOrderNo,
flowList
}
})
})
.catch((error) => {
console.error('sync stay state by reservation flow failed', error)
this.refreshSessionState()
if (!silent) {
return Promise.reject(error)
}
return {
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
}
})
},
refreshShellState(force = false, options = {}) {
const { skipMessageBadge = false } = options
const tasks = [
this.$store && this.$store.dispatch ? this.$store.dispatch('fetchGlobalEnums').catch(() => ({})) : Promise.resolve(),
this.syncStayState(force).then((flowState = {}) => {
this.sessionState.hasPendingApplication = !!flowState.hasPendingApplication
this.sessionState.hasActiveReservationFlow = !!flowState.hasActiveReservationFlow
})
]
if (!skipMessageBadge) {
tasks.push(this.refreshMessageBadgeCount())
}
return Promise.all(tasks).finally(() => {
this.refreshSessionState()
})
},
async handlePageLoad(options = {}) {
await this.refreshShellState(true, {
skipMessageBadge: options.pageId === 'message-center'
})
await this.updateTodoPolling({ forceFetch: true })
this.syncCurrentTab({
pageId: options.pageId,
index: Number(options.index || 0)
})
this.$nextTick(() => {
this.refreshTabPageState('select-room')
this.updateReservationFlowAutoRefresh()
})
setTimeout(() => {
this.isAppInitLoading = false
}, 1500)
},
async handlePageShow() {
await this.refreshShellState(true, {
skipMessageBadge: this.currentPageId === 'message-center'
})
await this.updateTodoPolling({ forceFetch: true })
this.syncCurrentTab()
this.$nextTick(() => {
this.loadedPages.forEach(pageId => {
this.refreshTabPageState(pageId)
})
this.getTabbarItemInfo()
this.updateReservationFlowAutoRefresh()
})
},
hasPage(id) {
return this.loadedPages.includes(id)
},
ensurePageLoaded(id) {
if (!id || this.loadedPages.includes(id)) return
this.loadedPages = [...this.loadedPages, id]
},
refreshSessionState() {
const session = getSessionSnapshot()
const storePendingContracts = this.getStorePendingContracts()
const storeUser = this.getStoreUser()
const storeHasCheckedIn = this.getStoreCheckinStatus()
const pendingContracts = storePendingContracts.length
? storePendingContracts
: (storeUser.pendingContracts || (Array.isArray(session.pendingContracts) ? session.pendingContracts : []))
const hasCheckedIn = !!(storeHasCheckedIn || storeUser.hasCheckedIn)
const hasActiveReservationFlow = !!(storeUser.hasActiveReservationFlow || session.resolvedUser.hasActiveReservationFlow || this.sessionState.hasActiveReservationFlow)
const hasPendingApplication = !!(storeUser.hasPendingApplication || session.resolvedUser.hasPendingApplication || this.sessionState.hasPendingApplication)
this.sessionState = {
isLogin: this.isCurrentLogin(),
hasCheckedIn,
hasPendingApplication,
hasActiveReservationFlow,
pendingContracts
}
},
getCurrentUserCode() {
const storeUser = this.getStoreUser()
if (Object.keys(storeUser).length) {
return storeUser.code || storeUser.userCode || ''
}
const session = getSessionSnapshot()
const user = session.currentUser || session.registeredUser || session.resolvedUser || {}
return user.code || user.userCode || ''
},
getCurrentOrderNo() {
const storeCurrentOrder = this.getStoreCurrentOrder()
if (storeCurrentOrder && storeCurrentOrder.orderNo) {
return String(storeCurrentOrder.orderNo).trim()
}
const session = getSessionSnapshot()
const room = session.myRoomData || {}
const user = session.currentUser || session.registeredUser || session.resolvedUser || {}
const currentOrder = (user && user.currentOrder) || {}
return String(room.orderNo || currentOrder.orderNo || '').trim()
},
clearTodoCollapseTimer() {
if (this.todoCollapseTimer) {
clearTimeout(this.todoCollapseTimer)
this.todoCollapseTimer = null
}
},
startTodoCollapseTimer() {
this.clearTodoCollapseTimer()
this.todoCollapseTimer = setTimeout(() => {
this.isTodoExpanded = false
}, 3000)
},
expandTodoFloat() {
this.isTodoExpanded = true
this.startTodoCollapseTimer()
},
resetTodoState() {
this.todoBadgeCount = 0
this.todoList = []
this.showTodoPopup = false
},
startTodoPolling() {
if (this.todoPollingTimer) return
this.todoPollingTimer = setInterval(() => {
this.fetchMyTodoList()
}, 3000)
},
stopTodoPolling(reset = false) {
if (this.todoPollingTimer) {
clearInterval(this.todoPollingTimer)
this.todoPollingTimer = null
}
if (reset) {
this.resetTodoState()
}
},
updateTodoPolling(options = {}) {
const forceFetch = !!(options && options.forceFetch)
if (!this.showTodoEntry) {
this.stopTodoPolling(true)
return Promise.resolve()
}
this.startTodoPolling()
if (forceFetch) {
return this.fetchMyTodoList({ force: true })
}
return Promise.resolve()
},
normalizeContractTodoEntry(record = {}) {
const todoCode = this.pickTodoText(record.no, record.contractNo, record.code)
return {
...record,
_todoCode: todoCode,
code: todoCode,
description: this.pickTodoText(record.contractDisplayName, record.contractName, record.name, this.$tr('待签合同'))
}
},
buildPendingContractTodoGroup(records = []) {
const entries = (Array.isArray(records) ? records : [])
.map(item => this.normalizeContractTodoEntry(item))
.filter(item => !!item._todoCode)
if (!entries.length) return null
return {
todoType: '10',
todoTypeDesc: this.$tr('待签合同'),
count: entries.length,
entries
}
},
mergeTodoGroup(baseList = [], extraGroup = null) {
if (!extraGroup || !extraGroup.todoType) return baseList
const mergedList = Array.isArray(baseList) ? baseList.map(item => ({ ...item })) : []
const groupIndex = mergedList.findIndex(item => String(item.todoType || '') === String(extraGroup.todoType || ''))
const appendEntries = Array.isArray(extraGroup.entries) ? extraGroup.entries : []
if (groupIndex === -1) {
mergedList.unshift({
...extraGroup,
entries: appendEntries
})
return mergedList
}
const currentGroup = mergedList[groupIndex] || {}
const currentEntries = Array.isArray(currentGroup.entries) ? currentGroup.entries.slice() : []
const existingCodeSet = new Set(currentEntries.map(item => this.resolveTodoEntryCode(item)).filter(Boolean))
appendEntries.forEach((entry) => {
const code = this.resolveTodoEntryCode(entry)
if (!code || existingCodeSet.has(code)) return
existingCodeSet.add(code)
currentEntries.push(entry)
})
mergedList.splice(groupIndex, 1, {
...currentGroup,
todoTypeDesc: currentGroup.todoTypeDesc || extraGroup.todoTypeDesc,
entries: currentEntries,
count: Math.max(Number(currentGroup.count) || 0, currentEntries.length)
})
return mergedList
},
async fetchMyTodoList(options = {}) {
const force = !!(options && options.force)
if (!this.showTodoEntry) {
this.stopTodoPolling(true)
return
}
if (this._fetchMyTodoPromise && !force) {
return this._fetchMyTodoPromise
}
this._fetchMyTodoPromise = Promise.resolve().then(async () => {
const orderNo = this.currentOrderNo
const todoResponse = await getMyTodoList({ OrderNo: orderNo }, this.$getLanguage(), { hideErrorToast: true })
let mergedTodoList = normalizeTodoList(extractTodoRows(todoResponse))
const pendingContracts = this.getStorePendingContracts()
mergedTodoList = this.mergeTodoGroup(mergedTodoList, this.buildPendingContractTodoGroup(pendingContracts))
this.todoList = mergedTodoList
this.todoBadgeCount = mergedTodoList.reduce((sum, item) => sum + Math.max(Number(item.count) || 0, 0), 0)
}).catch((error) => {
console.error('首页获取我的待办失败', error)
}).finally(() => {
this._fetchMyTodoPromise = null
})
return this._fetchMyTodoPromise
},
refreshMessageBadgeCount() {
if (!this.isCurrentLogin()) {
this.messageBadgeCount = 0
return Promise.resolve()
}
return notificationPage({
IsRead: false,
PageIndex: 1,
PageSize: 1,
Search: '',
IsExport: false
})
.then((notificationResponse) => {
this.messageBadgeCount = resolveNotificationUnreadTotal(notificationResponse)
})
.catch((error) => {
console.error('refresh message badge count failed', error)
this.messageBadgeCount = 0
})
},
handleMessageBadgeCountChange(count) {
this.messageBadgeCount = Math.max(Number(count) || 0, 0)
},
getMessageBadgeText() {
if (!this.messageBadgeCount) return ''
return this.messageBadgeCount > 99 ? '99+' : String(this.messageBadgeCount)
},
getTodoBadgeText() {
if (!this.todoBadgeCount) return ''
return this.todoBadgeCount > 99 ? '99+' : String(this.todoBadgeCount)
},
handleTodoFloatClick() {
if (!this.isTodoExpanded) {
this.expandTodoFloat()
return
}
this.openTodoPopup()
},
openTodoPopup() {
this.expandTodoFloat()
this.showTodoPopup = true
},
handleTodoPopupClose() {
this.showTodoPopup = false
},
resolveTodoEntries(item = {}) {
return Array.isArray(item.entries) ? item.entries : []
},
resolveTodoEntryKey(item = {}, entry = {}, index = 0) {
return `${String(item.todoType || 'todo')}-${this.resolveTodoEntryCode(entry) || index}`
},
pickTodoText() {
for (let index = 0; index < arguments.length; index += 1) {
const value = arguments[index]
if (value === null || value === undefined) continue
const text = String(value).trim()
if (text) return text
}
return ''
},
getTodoObjectValue(source, field) {
if (!source || typeof source !== 'object') return ''
const fieldText = String(field || '').trim()
if (!fieldText) return ''
if (Object.prototype.hasOwnProperty.call(source, fieldText)) {
return source[fieldText]
}
const normalizedField = fieldText.toLowerCase()
const sourceKeys = Object.keys(source)
for (let index = 0; index < sourceKeys.length; index += 1) {
const currentKey = sourceKeys[index]
if (String(currentKey || '').toLowerCase() === normalizedField) {
return source[currentKey]
}
}
return ''
},
getTodoFieldValue(source, fields = []) {
const fieldList = Array.isArray(fields) ? fields : [fields]
for (let index = 0; index < fieldList.length; index += 1) {
const value = this.getTodoObjectValue(source, fieldList[index])
if (value !== '' && value !== null && value !== undefined) {
return value
}
}
return ''
},
resolveTodoEntryCode(entry = {}) {
return this.pickTodoText(
entry._todoCode,
this.getTodoFieldValue(entry, ['code', 'no', 'contractNo', 'billNo', 'inspectionNo', 'orderNo', 'referenceTypeNo'])
)
},
resolveTodoEntryDescription(entry = {}) {
return this.pickTodoText(
this.getTodoFieldValue(entry, ['description', 'desc', 'remark', 'content', 'title', 'name']),
this.resolveTodoEntryCode(entry),
this.$tr('待办事项')
)
},
canOpenTodoEntry(item = {}, entry = {}) {
const todoType = String(item.todoType || '').trim()
if (!['10', '20', '30', '90'].includes(todoType)) return false
return !!this.resolveTodoEntryCode(entry)
},
resolveTodoGroupAction(item = {}) {
const todoType = String(item.todoType || '').trim()
if (todoType === '90') return this.$tr('点击下方单号进入验房处理')
if (todoType === '20') return this.$tr('点击下方单号识别工单并进入处理')
if (todoType === '30') return this.$tr('点击下方单号进入账单处理')
if (todoType === '10') return this.$tr('点击下方单号进入协议签署')
return this.$tr('点击下方单号查看待办详情')
},
resolveTodoEmptyTip() {
return this.$tr('当前类型暂无可处理的待办明细')
},
resolveTodoEntryAction(item = {}, entry = {}) {
if (!this.canOpenTodoEntry(item, entry)) {
return this.$tr('无法处理')
}
const todoType = String(item.todoType || '').trim()
if (todoType === '90') return this.$tr('去验房')
if (todoType === '20') return this.$tr('去处理')
if (todoType === '30') return this.$tr('去支付')
if (todoType === '10') return this.$tr('去签署')
return this.$tr('查看')
},
buildTodoNavigateUrl(path, query = {}) {
const targetPath = String(path || '').trim()
if (!targetPath) return ''
const queryEntries = Object.entries(query).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '')
if (!queryEntries.length) return targetPath
const queryText = queryEntries.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`).join('&')
return `${targetPath}${targetPath.includes('?') ? '&' : '?'}${queryText}`
},
resolveTodoTicketServiceType(flowList = []) {
const list = Array.isArray(flowList) ? flowList : []
for (let index = 0; index < list.length; index += 1) {
const serviceType = this.pickTodoText(this.getTodoFieldValue(list[index], ['serviceType', 'ticketType', 'type']))
if (serviceType) return serviceType
}
return ''
},
async resolveTicketTodoNavigateUrl(entry = {}) {
const todoCode = this.resolveTodoEntryCode(entry)
if (!todoCode) return ''
const res = await ticketOrderFlows(todoCode, this.$getLanguage())
const flowList = extractTodoRows(res)
const serviceType = this.resolveTodoTicketServiceType(flowList)
if (!serviceType) {
throw new Error(this.$tr('未识别工单类型'))
}
return this.buildTodoNavigateUrl('/pkgService/ticket_workbench/ticket_workbench', {
serviceType
})
},
resolveBillTodoNavigateUrl(entry = {}) {
const referenceTypeNo = this.pickTodoText(
this.getTodoFieldValue(entry, ['referenceTypeNo', 'ReferenceTypeNo', 'detailCode', 'DetailCode', 'applicationCode', 'ApplicationCode', 'referenceNo', 'ReferenceNo', 'orderNo', 'OrderNo'])
)
const billNo = this.pickTodoText(
this.getTodoFieldValue(entry, ['billNo', 'BillNo', 'no', 'No', 'code', 'Code'])
)
if (!referenceTypeNo && !billNo) {
throw new Error(this.$tr('当前待办单号不完整'))
}
return this.buildTodoNavigateUrl('/pkgService/utility_recharge/utility_recharge', {
referenceTypeNo,
billNo
})
},
resolveContractTodoNavigateUrl(entry = {}) {
const todoCode = this.resolveTodoEntryCode(entry)
if (!todoCode) return ''
return this.buildTodoNavigateUrl('/pkgService/contract_sign/contract_sign', {
no: todoCode,
orderNo: this.currentOrderNo
})
},
resolveInspectionTodoNavigateUrl(entry = {}) {
const inspectionNo = this.resolveTodoEntryCode(entry)
if (!inspectionNo) return ''
return this.buildTodoNavigateUrl('/pkgService/room_inspection/room_inspection', {
orderNo: this.currentOrderNo,
inspectionNo
})
},
async resolveTodoEntryNavigateUrl(item = {}, entry = {}) {
const todoType = String(item.todoType || '').trim()
if (todoType === '90') return this.resolveInspectionTodoNavigateUrl(entry)
if (todoType === '20') return this.resolveTicketTodoNavigateUrl(entry)
if (todoType === '30') return this.resolveBillTodoNavigateUrl(entry)
if (todoType === '10') return this.resolveContractTodoNavigateUrl(entry)
return ''
},
async handleTodoEntryClick(item = {}, entry = {}) {
if (!this.canOpenTodoEntry(item, entry)) {
uni.showToast({
title: this.$tr('当前待办单号不完整'),
icon: 'none'
})
return
}
const navigateKey = this.resolveTodoEntryKey(item, entry)
if (this.todoNavigatingKey && this.todoNavigatingKey === navigateKey) {
return
}
this.todoNavigatingKey = navigateKey
uni.showLoading({
title: this.$tr('处理中...'),
mask: true
})
try {
const url = await this.resolveTodoEntryNavigateUrl(item, entry)
if (!url) {
throw new Error(this.$tr('当前类型暂未配置跳转入口'))
}
this.handleTodoPopupClose()
uni.navigateTo({ url })
} catch (error) {
console.error('待办跳转失败', error)
uni.showToast({
title: this.pickTodoText(error && error.message, this.$tr('待办跳转失败')),
icon: 'none'
})
} finally {
uni.hideLoading()
this.todoNavigatingKey = ''
}
},
resolveTodoTypeTitle(item = {}) {
const title = String(item.todoTypeDesc || '').trim()
if (title) return title
const todoType = String(item.todoType || '').trim()
return todoType ? `${this.$tr('待办类型')} ${todoType}` : this.$tr('待办事项')
},
resolveTargetTab({ pageId, index = 0 } = {}) {
const list = this.currentTabbarList
if (!list.length) return null
if (pageId) {
const explicitMatchedTab = list.find(item => item.id === pageId)
if (explicitMatchedTab) return explicitMatchedTab
}
const shouldPreferFlowPage = this.sessionState.isLogin
&& (this.sessionState.hasCheckedIn || this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow || this.sessionState.pendingContracts.length > 0)
if (shouldPreferFlowPage) {
const stayServiceTab = list.find(item => item.id === 'stay-service')
if (stayServiceTab) return stayServiceTab
}
const preferredPageId = getHomePageTabId()
if (preferredPageId) {
const matchedTab = list.find(item => item.id === preferredPageId)
if (matchedTab) return matchedTab
}
return list[index] || list[0]
},
syncCurrentTab({ pageId, index = 0 } = {}) {
const targetTab = this.resolveTargetTab({ pageId, index })
if (!targetTab) return
this.ensurePageLoaded(targetTab.id)
this.currentPageId = targetTab.id
saveHomePageTabId(targetTab.id)
},
getPageRef(refName) {
const ref = this.$refs[refName]
return Array.isArray(ref) ? ref[0] : ref
},
getStayServiceTabRef() {
return this.getPageRef('stayServiceTab')
},
buildReservationFlowStateSignature(flowState = {}) {
const flowList = Array.isArray(flowState.flowList) ? flowState.flowList : []
return JSON.stringify({
hasCheckedIn: !!flowState.hasCheckedIn,
hasPendingApplication: !!flowState.hasPendingApplication,
hasActiveReservationFlow: !!flowState.hasActiveReservationFlow,
currentStep: Number(flowState.currentStep || 0),
currentRefNo: String(flowState.currentRefNo || '').trim(),
currentOrderNo: String(flowState.currentOrderNo || '').trim(),
flowList: flowList.map((flow = {}) => ({
orderNo: String(flow.orderNo || flow.OrderNo || '').trim(),
nodes: (Array.isArray(flow.flowNodes) ? flow.flowNodes : []).map((node = {}) => ({
step: Number(node.step || 0),
refNo: String(node.refNo || '').trim(),
isCurrent: !!node.isCurrent,
stepDesc: String(node.stepDesc || '').trim()
}))
}))
})
},
clearReservationFlowAutoRefreshTimer() {
if (this._reservationFlowAutoRefreshTimer) {
clearTimeout(this._reservationFlowAutoRefreshTimer)
this._reservationFlowAutoRefreshTimer = null
}
},
scheduleReservationFlowAutoRefresh(delay = FLOW_AUTO_REFRESH_INTERVAL) {
this.clearReservationFlowAutoRefreshTimer()
this._reservationFlowAutoRefreshTimer = setTimeout(() => {
this._reservationFlowAutoRefreshTimer = null
this.runReservationFlowAutoRefresh()
}, delay)
},
stopReservationFlowAutoRefresh() {
this.clearReservationFlowAutoRefreshTimer()
this._isReservationFlowAutoRefreshing = false
},
canAutoRefreshReservationFlow() {
if (!this.isCurrentLogin() || this.currentPageId !== 'stay-service') {
return false
}
if (!this.sessionState.hasPendingApplication && !this.sessionState.hasActiveReservationFlow) {
return false
}
const stayServiceTab = this.getStayServiceTabRef()
if (!stayServiceTab || typeof stayServiceTab.canAutoRefreshReservationFlow !== 'function') {
return false
}
return !!stayServiceTab.canAutoRefreshReservationFlow()
},
updateReservationFlowAutoRefresh() {
if (!this.canAutoRefreshReservationFlow()) {
this.stopReservationFlowAutoRefresh()
return
}
this.scheduleReservationFlowAutoRefresh()
},
runReservationFlowAutoRefresh() {
if (this._isReservationFlowAutoRefreshing) {
return
}
if (!this.canAutoRefreshReservationFlow()) {
this.stopReservationFlowAutoRefresh()
return
}
this._isReservationFlowAutoRefreshing = true
this.refreshReservationFlowOnly()
.then((flowState = {}) => {
const nextSignature = this.buildReservationFlowStateSignature(flowState)
this._lastReservationFlowSignature = nextSignature
this.sessionState.hasPendingApplication = !!flowState.hasPendingApplication
this.sessionState.hasActiveReservationFlow = !!flowState.hasActiveReservationFlow
this.refreshSessionState()
this.$nextTick(() => {
const stayServiceTab = this.getStayServiceTabRef()
if (stayServiceTab && typeof stayServiceTab.onFlowRefreshed === 'function') {
stayServiceTab.onFlowRefreshed({ ...flowState, success: true, silent: true })
}
})
})
.catch((error) => {
console.error('auto refresh reservation flow failed', error)
})
.finally(() => {
this._isReservationFlowAutoRefreshing = false
if (this.canAutoRefreshReservationFlow()) {
this.scheduleReservationFlowAutoRefresh()
}
})
},
refreshTabPageState(pageId, retryCount = 0) {
const page = this.tabDefinitions.find(item => item.id === pageId)
if (!page || !page.refreshMethod) return
const pageVm = this.getPageRef(page.ref)
if (pageVm && typeof pageVm[page.refreshMethod] === 'function') {
pageVm[page.refreshMethod]()
return
}
if (retryCount < 8) {
setTimeout(() => {
this.refreshTabPageState(pageId, retryCount + 1)
}, 60)
}
},
refreshReservationFlowOnly() {
if (!this.isCurrentLogin()) {
this.refreshSessionState()
return Promise.resolve({
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
})
}
const storeUser = this.getStoreUser()
let userCode = storeUser.userCode || storeUser.code || ''
if (!userCode) {
const session = getSessionSnapshot()
const fallbackUser = session.resolvedUser || session.currentUser || session.registeredUser || {}
userCode = fallbackUser.userCode || fallbackUser.code || ''
}
if (!userCode) {
this.refreshSessionState()
return Promise.resolve({
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
})
}
return this.fetchReservationFlowWithUserCode(userCode, { silent: false })
},
handleStayServiceRefreshState(event = {}) {
const { pendingContracts } = event || {}
this.clearReservationFlowAutoRefreshTimer()
this.refreshReservationFlowOnly()
.then((flowState = {}) => {
this._lastReservationFlowSignature = this.buildReservationFlowStateSignature(flowState)
if (pendingContracts && Array.isArray(pendingContracts)) {
if (this.$store && this.$store.commit) {
this.$store.commit('$tStore', { name: 'vuex_pending_contracts', value: pendingContracts })
}
savePendingContracts(pendingContracts)
}
this.sessionState.hasPendingApplication = !!flowState.hasPendingApplication
this.sessionState.hasActiveReservationFlow = !!flowState.hasActiveReservationFlow
this.refreshSessionState()
this.updateTodoPolling({ forceFetch: true })
this.$nextTick(() => {
const stayServiceTab = this.getStayServiceTabRef()
if (stayServiceTab && typeof stayServiceTab.onFlowRefreshed === 'function') {
stayServiceTab.onFlowRefreshed({ ...flowState, success: true })
}
this.updateReservationFlowAutoRefresh()
})
})
.catch((error) => {
console.error('refresh reservation flow from stay service failed', error)
this.$nextTick(() => {
const stayServiceTab = this.getStayServiceTabRef()
if (stayServiceTab && typeof stayServiceTab.onFlowRefreshed === 'function') {
stayServiceTab.onFlowRefreshed({ success: false })
}
this.updateReservationFlowAutoRefresh()
})
})
},
async handleReservationFlowForceRefresh() {
await this.refreshShellState(true)
await this.updateTodoPolling({ forceFetch: true })
this.ensurePageLoaded('stay-service')
this.syncCurrentTab()
this.$nextTick(() => {
this.refreshTabPageState('stay-service')
if (this.currentPageId && this.currentPageId !== 'stay-service') {
this.refreshTabPageState(this.currentPageId)
}
this.getTabbarItemInfo()
this.updateReservationFlowAutoRefresh()
})
},
tabbarPageScrollLower(e) {
if (this.currentPageId === 'select-room') {
const selectRoomTab = this.getPageRef('selectRoomTab')
selectRoomTab && selectRoomTab.getRandomData && selectRoomTab.getRandomData()
}
},
// 获取底部元素的位置
getTabbarItemInfo() {
const view = uni.createSelectorQuery().in(this)
for (let i = 0; i < this.currentTabbarList.length; i++) {
view.select('#tabbar_item_' + i).boundingClientRect()
}
view.exec(res => {
if (!res || !res.length) {
if (!this._tabbarRetryCount) this._tabbarRetryCount = 0;
if (this._tabbarRetryCount < 20) {
this._tabbarRetryCount++;
setTimeout(() => {
this.getTabbarItemInfo()
}, 50)
}
return
}
// 将信息存入数组中
this.tabbarRectInfo = [];
res.map((item) => {
if (item) {
this.tabbarRectInfo.push({
left: item.left,
width: item.width
})
}
})
if (this.tabbarRectInfo[this.currentTabbarIndex]) {
this.updateHollowsPosition()
this.updateActiveBgPosition(true)
}
})
},
// 更新凹陷位置
updateHollowsPosition() {
if (!this.tabbarRectInfo[this.currentTabbarIndex]) return;
const {
width,
left
} = this.tabbarRectInfo[this.currentTabbarIndex]
// 计算掩模图片的宽高比
// const imageRatio = 200 / 92
// 计算定高的宽比
const imageFixedHeightWidthRatioValue = 300 * (uni.upx2px(20) / 92)
this.wrapMaskPositionLeft = left - ((imageFixedHeightWidthRatioValue - width) / 2)
},
// 更新激活时背景的位置
updateActiveBgPosition(init = false) {
if (!this.tabbarRectInfo[this.currentTabbarIndex]) return;
const {
width,
left
} = this.tabbarRectInfo[this.currentTabbarIndex]
const oldActiveBgPositionLeft = this.activeBgPositionLeft
this.activeBgPositionLeft = left + ((width - uni.upx2px(16)) / 2)
if (!init) {
const animation = uni.createAnimation({
duration: 160,
timingFunction: "linear"
})
animation.top(uni.upx2px(-60)).left(oldActiveBgPositionLeft + ((this.activeBgPositionLeft -
oldActiveBgPositionLeft) / 2)).scale(1).step()
animation.left(this.activeBgPositionLeft).top(uni.upx2px(-10)).scale(1).step()
this.activeBgAnimation = animation.export()
} else {
const animation = uni.createAnimation({
duration: 160,
timingFunction: "linear"
})
animation.left(this.activeBgPositionLeft).top(uni.upx2px(-10)).step()
this.activeBgAnimation = animation.export()
}
},
async changeTabbar(index, id) {
if (this.currentTabbarIndex === index && this.currentPageId === id) return;
if (this.isTabSwitching) return
// 兼容处理:防止 H5 等非小程序环境下找不到 wx 对象导致 JS 报错,从而中断后续页面切换逻辑
if (typeof uni !== 'undefined' && uni.vibrateShort) {
uni.vibrateShort({
fail: () => {} // 忽略 H5 环境下的不支持报错
});
}
this.isTabSwitching = true
try {
await this.refreshShellState(true, {
skipMessageBadge: id === 'message-center'
})
const targetTab = this.resolveTargetTab({
pageId: id,
index
})
const nextPageId = targetTab ? targetTab.id : id
this.ensurePageLoaded(nextPageId)
this.currentPageId = nextPageId
saveHomePageTabId(nextPageId)
this.$nextTick(() => {
this.refreshTabPageState(nextPageId)
this.updateHollowsPosition()
this.updateActiveBgPosition()
this.updateReservationFlowAutoRefresh()
})
} finally {
this.isTabSwitching = false
}
},
},
// 分享到朋友
onShareAppMessage() {
return {
title: this.$tr('绿居公寓 - 国际化青年社区'),
path: '/pages/index/index'
}
},
// 分享到朋友圈
onShareTimeline() {
return {
title: this.$tr('绿居公寓 - 国际化青年社区')
}
}
}
</script>
<style lang="scss" scoped>
/* 页面初始化全屏遮罩 */
.init-loading-mask {
position: fixed;
inset: 0;
background-color: #FFFFFF;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
}
.premium-loading-wrapper {
display: flex;
flex-direction: column;
align-items: center;
transform: translateY(-8vh);
opacity: 0;
animation: premium-fade-in 1.2s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
}
.icon-container {
position: relative;
width: 180rpx;
height: 180rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 80rpx;
}
.premium-icon {
font-size: 68rpx;
color: #1A2233;
z-index: 2;
animation: premium-pulse 3s ease-in-out infinite;
}
.spinner-ring {
position: absolute;
inset: 0;
border: 2rpx solid transparent;
border-top-color: #1A2233;
border-right-color: rgba(26, 34, 51, 0.1);
border-radius: 50%;
animation: premium-spin 2.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite;
}
.inner-ring {
inset: 24rpx;
border-top-color: transparent;
border-right-color: transparent;
border-bottom-color: rgba(26, 34, 51, 0.6);
border-left-color: rgba(26, 34, 51, 0.05);
animation: premium-spin-reverse 3.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) infinite;
}
.brand-text {
font-size: 40rpx;
font-weight: 300;
color: #1A2233;
letter-spacing: 28rpx;
margin-left: 28rpx; /* 视觉居中补偿 */
}
.brand-sub {
font-size: 20rpx;
font-weight: 300;
color: #8A96A3;
letter-spacing: 16rpx;
margin-left: 16rpx;
margin-top: 24rpx;
text-transform: uppercase;
}
@keyframes premium-fade-in {
from { opacity: 0; transform: translateY(-6vh); }
to { opacity: 1; transform: translateY(-8vh); }
}
@keyframes premium-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes premium-spin-reverse {
0% { transform: rotate(360deg); }
100% { transform: rotate(0deg); }
}
@keyframes premium-pulse {
0%, 100% { opacity: 0.7; transform: scale(0.96); }
50% { opacity: 1; transform: scale(1.04); }
}
.todo-float {
position: fixed;
right: 0;
bottom: calc(180rpx + constant(safe-area-inset-bottom));
bottom: calc(180rpx + env(safe-area-inset-bottom));
z-index: 100191;
}
.todo-float__box {
display: flex;
align-items: center;
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 100rpx 0 0 100rpx;
padding: 12rpx 16rpx;
border: 1px solid rgba($brand-primary, 0.14);
border-right: none;
box-shadow: 0 10rpx 30rpx rgba($brand-primary, 0.14);
transition: all 0.4s ease;
max-width: 500rpx;
}
.todo-float__box.is-collapsed {
transform: translateX(calc(100% - 88rpx));
padding-right: 0;
}
.todo-float__box.is-collapsed .todo-float__content-wrap {
opacity: 0;
width: 0;
margin-left: 0;
}
.todo-float__icon-wrap {
position: relative;
width: 64rpx;
height: 64rpx;
background: linear-gradient(135deg, rgba($brand-primary, 0.88) 0%, $brand-primary 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 32rpx;
margin-right: 16rpx;
flex-shrink: 0;
}
.todo-float__badge {
position: absolute;
top: -8rpx;
right: -8rpx;
min-width: 36rpx;
height: 36rpx;
padding: 0 10rpx;
border-radius: 999rpx;
background: #EF4444;
color: #ffffff;
font-size: 20rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
border: 4rpx solid #ffffff;
box-sizing: border-box;
}
.todo-float__content-wrap {
display: flex;
align-items: center;
overflow: hidden;
transition: all 0.4s ease;
}
.todo-float__content {
margin-right: 20rpx;
max-width: 240rpx;
white-space: nowrap;
}
.todo-float__title {
font-size: 26rpx;
line-height: 1.2;
color: #0f172a;
font-weight: 700;
}
.todo-float__desc {
margin-top: 4rpx;
font-size: 20rpx;
line-height: 1.3;
color: #64748b;
}
.todo-float__action-btn {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
border: 1rpx solid rgba($brand-primary, 0.12);
font-size: 24rpx;
font-weight: bold;
padding: 10rpx 24rpx;
border-radius: 100rpx;
white-space: nowrap;
}
.todo-popup {
height: 60vh;
padding: 32rpx 28rpx 24rpx;
box-sizing: border-box;
background: #ffffff;
display: flex;
flex-direction: column;
gap: 24rpx;
}
.todo-popup__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
flex-shrink: 0;
}
.todo-popup__title {
font-size: 34rpx;
line-height: 1.2;
color: #0f172a;
font-weight: 600;
}
.todo-popup__subtitle {
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.5;
color: #64748b;
}
.todo-popup__close {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba($brand-primary, 0.08);
color: $brand-primary;
border: 1rpx solid rgba($brand-primary, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx;
flex-shrink: 0;
}
.todo-popup__list {
flex: 1;
min-height: 0;
}
.todo-popup__content {
display: flex;
flex-direction: column;
gap: 20rpx;
padding-bottom: 20rpx;
}
.todo-popup__item {
border-radius: 28rpx;
background: linear-gradient(180deg, #ffffff 0%, rgba($brand-primary, 0.04) 100%);
border: 1rpx solid rgba($brand-primary, 0.08);
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 22rpx;
}
.todo-popup__item-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.todo-popup__item-title-wrap {
display: flex;
flex-direction: column;
gap: 8rpx;
min-width: 0;
}
.todo-popup__item-title {
font-size: 28rpx;
line-height: 1.3;
color: #0f172a;
font-weight: 600;
}
.todo-popup__item-action {
font-size: 22rpx;
line-height: 1.5;
color: #64748b;
}
.todo-popup__item-count {
min-width: 72rpx;
height: 44rpx;
padding: 0 16rpx;
border-radius: 999rpx;
background: rgba(239, 68, 68, 0.12);
color: #DC2626;
font-size: 22rpx;
line-height: 44rpx;
text-align: center;
flex-shrink: 0;
}
.todo-popup__entry-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.todo-popup__entry {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 22rpx 24rpx;
border-radius: 22rpx;
background: #ffffff;
border: 1rpx solid rgba($brand-primary, 0.1);
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.04);
}
.todo-popup__entry.is-disabled {
opacity: 0.56;
}
.todo-popup__entry-main {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.todo-popup__entry-desc {
font-size: 24rpx;
line-height: 1.5;
color: #475569;
word-break: break-word;
}
.todo-popup__entry-desc.is-primary {
font-size: 26rpx;
color: #0f172a;
font-weight: 500;
}
.todo-popup__entry-code {
font-size: 20rpx;
line-height: 1.4;
color: #94a3b8;
word-break: break-all;
}
.todo-popup__entry-side {
display: flex;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
}
.todo-popup__entry-action {
font-size: 22rpx;
line-height: 1.4;
color: $brand-primary;
white-space: nowrap;
}
.todo-popup__entry-arrow {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background: rgba($brand-primary, 0.08);
color: $brand-primary;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
}
.todo-popup__empty-tip {
padding: 28rpx 20rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.72);
text-align: center;
font-size: 22rpx;
line-height: 1.6;
color: #94a3b8;
}
.todo-popup__empty {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40rpx 24rpx;
box-sizing: border-box;
}
.todo-popup__empty-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba($brand-primary, 0.08);
color: $brand-primary;
border: 1rpx solid rgba($brand-primary, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
margin-bottom: 24rpx;
}
.todo-popup__empty-title {
font-size: 30rpx;
line-height: 1.3;
color: #0f172a;
font-weight: 600;
}
.todo-popup__empty-desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.6;
color: #64748b;
}
.privacy-mask {
position: fixed;
inset: 0;
z-index: 100000;
background: rgba(17, 24, 39, 0.56);
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
box-sizing: border-box;
}
.privacy-dialog {
width: 100%;
max-width: 640rpx;
background: #FFFFFF;
border-radius: 28rpx;
padding: 40rpx 32rpx 32rpx;
box-sizing: border-box;
box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, 0.16);
}
.privacy-title {
font-size: 34rpx;
font-weight: 700;
color: #1A2233;
text-align: center;
}
.privacy-content {
margin-top: 24rpx;
font-size: 28rpx;
line-height: 1.7;
color: #5C6B8A;
}
.privacy-link {
color: $brand-primary;
font-weight: 700;
}
.privacy-action-row {
display: flex;
margin-top: 32rpx;
}
.privacy-action-btn {
flex: 1;
height: 88rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 700;
color: $brand-primary;
background: rgba($brand-primary, 0.08);
border: 1rpx solid rgba($brand-primary, 0.12);
box-sizing: border-box;
}
.privacy-action-btn + .privacy-action-btn {
margin-left: 20rpx;
}
button.privacy-action-btn {
padding: 0;
line-height: 88rpx;
}
button.privacy-action-btn::after {
border: none;
}
.privacy-action-btn.is-primary {
color: #FFFFFF;
background: linear-gradient(135deg, rgba($brand-primary, 0.92) 0%, $brand-primary 100%);
border-color: transparent;
}
/*整体样式 */
.index {
width: 100%;
height: 100vh;
position: relative;
.custom-tabbar-page {
width: 100%;
height: calc(100vh - 114rpx - constant(safe-area-inset-bottom));
height: calc(100vh - 114rpx - env(safe-area-inset-bottom));
box-sizing: border-box;
}
/* 底部导航 statr */
.tabbar {
width: 100%;
height: 114rpx;
height: calc(114rpx + constant(safe-area-inset-bottom));
height: calc(114rpx + env(safe-area-inset-bottom));
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: transparent;
z-index: 998;
/* 图鸟温馨提醒,裁剪式也生效的阴影样式,不用box-shadow*/
filter: drop-shadow(0rpx 0rpx 30rpx rgba(0, 0, 0, 0.08));
&__bg {
position: absolute;
width: 100%;
height: 100%;
bottom: 0;
left: 0;
background-color: #FFFFFF;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 61.5'%3E%3Cpath d='M100 0H0c32.9 0 49.3 61.5 100 61.5S167.1 0 200 0H100z'/%3E%3C/svg%3E"), linear-gradient(#000, #000);
-webkit-mask-size: auto 20rpx, cover;
-webkit-mask-position: center top;
-webkit-mask-repeat: no-repeat;
-webkit-mask-composite: xor;
/*只显示不重合的地方, chorem 、safari 支持*/
z-index: -1;
transition: 0.5s;
}
&__list {
position: absolute;
z-index: 999;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
&__item {
height: 100%;
width: 100%;
flex: 1;
text-align: center;
position: relative;
margin-top: 10rpx;
&--active {
.tabbar__item__icon {
// top: 10rpx;
color: $icon-tone-brand;
}
.tabbar__item__image {
top: -42rpx;
}
.tabbar__item__text {
opacity: 1;
color: $icon-tone-strong;
font-size: 20rpx;
}
}
&__icon {
font-size: 48rpx;
position: absolute;
left: 0;
right: 0;
top: 22rpx;
transition: 0.5s;
color: $icon-tone-muted;
}
&__icon-wrap {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 88rpx;
}
&__badge {
position: absolute;
top: 10rpx;
right: 50%;
margin-right: -42rpx;
min-width: 30rpx;
height: 30rpx;
padding: 0 8rpx;
border-radius: 999rpx;
background: #ff5b5b;
color: #ffffff;
font-size: 18rpx;
line-height: 30rpx;
text-align: center;
box-shadow: 0 8rpx 18rpx rgba(255, 91, 91, 0.25);
z-index: 2;
}
&__image {
width: 58rpx;
height: 58rpx;
position: absolute;
left: 0;
right: 0;
top: 20rpx;
transition: 0.5s;
margin: 0 auto;
}
&__text {
position: absolute;
left: 0;
right: 0;
bottom: 20rpx;
bottom: calc(20rpx + constant(safe-area-inset-bottom)*0.8);
bottom: calc(20rpx + env(safe-area-inset-bottom)*0.8);
transition: 0.5s;
font-size: 20rpx;
color: $icon-tone-muted;
}
}
&__select-active-bg {
position: absolute;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background-color: $brand-primary;
border: 2rpx solid #FFFFFF;
// transition: 0.5s;
z-index: -1;
left: calc(64rpx);
box-shadow: 0rpx 10rpx 30rpx 0rpx rgba($brand-primary, 0.28);
// box-shadow: inset 0rpx 0rpx 50rpx 0rpx rgba(0, 0, 0, 0.05);
// box-shadow: 0rpx 10rpx 30rpx rgba(70,23,129, 0.07),
// 0rpx -8rpx 40rpx rgba(255, 255, 255, 0.07),
// inset 0rpx -10rpx 10rpx rgba(70,23,129, 0.07),
// inset 0rpx 10rpx 20rpx rgba(255, 255, 255, 1);
transition: box-shadow .2s ease-out;
&--hide {
top: calc(110rpx + 50rpx);
}
&--show {
top: -54rpx;
}
}
}
/* 底部导航 end */
/* 移动背景部分 start*/
.bg-contaniner {
position: fixed;
top: -0rpx;
left: -300rpx;
--text-color: hsl(0 95% 60%);
--bg-color: #F6F6F6;
--bg-size: 200px;
height: 100%;
display: grid;
place-items: center;
place-content: center;
overflow: hidden;
background-color: var(--bg-color);
z-index: -1;
}
.bg-contaniner::before {
--size: 150vmax;
content: "";
inline-size: var(--size);
block-size: var(--size);
background-image: url("https://cdn.nlark.com/yuque/0/2022/png/280373/1663568736856-assets/web-upload/97cab67d-6f17-4c6e-a3f9-ac81961d571a.png");
background-size: var(--bg-size);
background-repeat: repeat;
transform: rotate(45deg);
opacity: 0.1;
animation: bg 6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.bg-contaniner::before {
animation-duration: 0s;
}
}
@keyframes bg {
to {
background-position: 0 calc(var(--bg-size) * -1);
}
}
/* 移动背景部分 end*/
}
/* 毛玻璃*/
.dd-glass {
width: 100%;
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
}
</style>