Files
QSY-lvjuxcx/pages/index/index.vue
liangjian 445bd850f4 feat: 新增工单中心、房间巡查、失物招领等业务模块
- 新增工单中心(ticket_center)与工单工作台(ticket_workbench)页面
- 新增房间巡查(room_inspection)与失物招领发布(lost_found_publish)页面
- 预约看样流程新增房型/楼栋/楼层选择器组件
- 新增工具模块: booking-entry、privacy、reservation-flow
- api/index.js 补充楼层列表、看样提交等接口
- locales/messages.js 补充多语言文案
- manifest.json 开启微信小程序隐私检查
- 完善登录、注册、申请详情、账单等相关页面交互
2026-07-24 09:35:46 +08:00

1128 lines
39 KiB
Vue
Raw 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 tn-flex tn-flex-direction-column tn-flex-col-center tn-flex-row-center">
<view class="loading-logo">
<text class="tn-icon-moon tn-color-white" style="font-size: 80rpx; transform: rotate(-45deg); display: block;"></text>
</view>
<view class="loading-desc tn-margin-top-lg" style="color: rgba(255,255,255,0.8); letter-spacing: 4rpx; font-size: 32rpx; font-weight: bold;">{{ t('loadingBrand') }}</view>
<view class="loading-spinner tn-margin-top-xl">
<tn-loading mode="circle" color="#00BBE8" size="60"></tn-loading>
</view>
</view>
<!-- #ifdef MP-WEIXIN -->
<view v-if="showPrivacyPopup" class="privacy-mask">
<view class="privacy-dialog">
<view class="privacy-title">{{ t('privacyTitle') }}</view>
<view class="privacy-content">
{{ t('privacyContentPrefix') }}
<text class="privacy-link" @click="openPrivacyContract">{{ t('privacyContractName') }}</text>
{{ t('privacyContentSuffix') }}
</view>
<view class="privacy-action-row">
<view class="privacy-action-btn" @click="handlePrivacyDisagree">{{ t('privacyDisagree') }}</view>
<button
id="index-privacy-agree-btn"
class="privacy-action-btn is-primary"
open-type="agreePrivacyAuthorization"
@agreeprivacyauthorization="handleAgreePrivacyAuthorization"
>
{{ t('privacyAgree') }}
</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('my-application')" v-show="currentPageId === 'my-application'">
<scroll-view class="custom-tabbar-page" scroll-y enable-back-to-top @scrolltolower="tabbarPageScrollLower">
<my-application-tab
ref="myApplicationTab"
@refresh-shell-state="handleMyApplicationRefreshState"
></my-application-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 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 MyApplicationTab from './component/MyApplicationTab.vue'
import ContractAlert from '../../components/contract-alert/contract-alert.vue'
import { clearLegacyDemoCache, getHomePageTabId, getSessionSnapshot, saveHomePageTabId } from '../../utils/app-state'
import { applicationFormMePage, checkRecordByChecked, notificationPage, orderSearchedMePage, orderSearchedReservationFlow } from '@/api/index.js'
import { parseOrderListResponse, getOrderStatusCode } from '@/utils/booking-helper'
import { bindPrivacyAuthorizationListener, getPrivacyAuthorizationStatus, openWechatPrivacyContract, unbindPrivacyAuthorizationListener } from '@/utils/privacy.js'
import { countPendingInspectionRecords, extractInspectionList, resolveNotificationUnreadTotal } from '../../utils/message-helper'
import { syncLoginStayState } from '../../utils/session-sync'
import { getReservationOrderNo, getReservationCurrentNode, parseReservationFlowResponse } from '@/utils/reservation-flow'
function formatPageResponse(response) {
const payload = response && response.data ? response.data : response
return payload && typeof payload === 'object' ? payload : {}
}
function normalizeApplicationList(list = []) {
if (!Array.isArray(list)) {
return []
}
return list.filter(item => item && typeof item === 'object')
}
export default {
components: {
SelectRoomTab,
StayServiceTab,
MessageCenterTab,
ProfileCenterTab,
MyApplicationTab,
ContractAlert
},
data() {
return {
wrapMaskPositionLeft: -15,
activeBgPositionLeft: 0,
currentPageId: 'select-room',
tabbarRectInfo: [],
activeBgAnimation: {},
messageBadgeCount: 0,
sessionState: {
isLogin: false,
hasCheckedIn: false,
hasStayOrder: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
pendingContracts: []
},
tabDefinitions: [
{ id: 'select-room', labelKey: 'tabSelectRoom', icon: 'tn-icon-home-fill', image: '/static/tabbar/tn-tabbar0.png', component: 'SelectRoomTab', ref: 'selectRoomTab', refreshMethod: 'checkLoginStatus' },
{ id: 'stay-service', labelKey: 'tabStayService', icon: 'tn-icon-safe', image: '/static/tabbar/tn-tabbar1.png', component: 'StayServiceTab', ref: 'stayServiceTab', refreshMethod: 'checkLocalStatus' },
{ id: 'my-application', labelKey: 'tabMyApplication', icon: 'tn-icon-order', image: '/static/tabbar/tn-tabbar2.png', component: 'MyApplicationTab', ref: 'myApplicationTab', refreshMethod: 'load' },
{ id: 'message-center', labelKey: 'tabMessageCenter', icon: 'tn-icon-message', image: '/static/tabbar/tn-tabbar3.png', component: 'MessageCenterTab', ref: 'messageCenterTab', refreshMethod: 'load' },
{ id: 'profile-center', labelKey: 'tabProfileCenter', 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,
i18n: {
zh: {
loadingBrand: '绿居公寓',
shareTitle: '绿居公寓 - 国际化青年社区',
tabSelectRoom: '选 房',
tabStayService: '入 住',
tabMyApplication: '申请单',
tabMessageCenter: '消 息',
tabProfileCenter: '我 的',
privacyTitle: '隐私授权提示',
privacyContentPrefix: '进入小程序前,请先阅读并同意',
privacyContractName: '《用户隐私保护指引》',
privacyContentSuffix: ',同意后才能继续使用相关功能。',
privacyAgree: '同意并继续',
privacyDisagree: '暂不授权'
},
en: {
loadingBrand: 'Luju Apartments',
shareTitle: 'Luju Apartments - International Youth Community',
tabSelectRoom: 'Explore',
tabStayService: 'Stay',
tabMyApplication: 'Applications',
tabMessageCenter: 'Messages',
tabProfileCenter: 'Profile',
privacyTitle: 'Privacy Authorization',
privacyContentPrefix: 'Before entering the mini program, please read and agree to the ',
privacyContractName: 'Privacy Protection Guide',
privacyContentSuffix: ' to continue using related features.',
privacyAgree: 'Agree and Continue',
privacyDisagree: 'Not Now'
}
}
}
},
computed: {
wrapStyle() {
return {
'-webkit-mask-position': `${this.wrapMaskPositionLeft}px -1px, 100%`
}
},
visibleTabIds() {
if (!this.sessionState.isLogin) {
return ['select-room', 'profile-center']
}
const tabs = []
if (this.sessionState.hasStayOrder) {
tabs.push('stay-service')
} else if (this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow) {
tabs.push('my-application')
} else {
tabs.push('select-room')
}
if ((this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow) && !tabs.includes('my-application')) {
tabs.push('my-application')
}
tabs.push('message-center', 'profile-center')
return tabs
},
currentTabbarList() {
return this.tabDefinitions
.filter(item => this.visibleTabIds.includes(item.id))
.map(item => ({
...item,
name: this.t(item.labelKey)
}))
},
currentTabbarIndex() {
const index = this.currentTabbarList.findIndex(item => item.id === this.currentPageId)
return index === -1 ? 0 : index
},
},
onShow() {
this.handlePageShow()
this.checkEntryPrivacyAuthorization()
},
onLoad(options) {
clearLegacyDemoCache()
uni.$on('messageBadgeCountChange', this.handleMessageBadgeCountChange)
uni.$on('reservation-flow-force-refresh', this.handleReservationFlowForceRefresh)
this.initPrivacyAuthorization()
this.checkEntryPrivacyAuthorization()
this.handlePageLoad(options)
},
onUnload() {
uni.$off('messageBadgeCountChange', this.handleMessageBadgeCountChange)
uni.$off('reservation-flow-force-refresh', this.handleReservationFlowForceRefresh)
this.releasePrivacyAuthorization()
},
onReady() {
this.$nextTick(() => {
this.getTabbarItemInfo()
})
},
methods: {
t(key) {
return this.$tm('pages.index.index', key)
},
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
},
getStoreCurrentBooking() {
const user = this.getStoreUser()
return (user && user.currentBooking) || null
},
getStorePendingContracts() {
const list = this.$store && this.$store.state && this.$store.state.vuex_pending_contracts
return Array.isArray(list) ? list : []
},
syncStayState(force = false) {
if (!this.isCurrentLogin()) {
this.refreshSessionState()
return Promise.resolve()
}
return syncLoginStayState({ force }).catch((error) => {
console.error('sync login stay state failed', error)
})
},
resolveApplicationState() {
return applicationFormMePage({
Status: 0,
PageIndex: 1,
PageSize: 1
}, this.$getLanguage())
.then((response) => {
const pageData = formatPageResponse(response)
const list = normalizeApplicationList(pageData.data || [])
const total = Number(pageData.total || 0)
const hasPendingApplication = list.some(item => Number(item && item.status) === 0)
return {
hasPendingApplication: total > 0 || hasPendingApplication
}
})
.catch((error) => {
console.error('resolve application state failed', error)
return {
hasPendingApplication: false
}
})
},
resolveApplicationFlowState(list = []) {
const orderList = normalizeApplicationList(list)
const reservationOrderList = orderList.filter(item => getOrderStatusCode(item) === 0)
const orderNos = [...new Set(reservationOrderList.map(item => getReservationOrderNo(item)).filter(Boolean))]
const result = {
hasPendingApplication: false,
hasActiveReservationFlow: false
}
if (!orderNos.length) {
return Promise.resolve(result)
}
return orderSearchedReservationFlow({
orderNos
}, this.$getLanguage())
.then((response) => {
const flowList = parseReservationFlowResponse(response)
const hasActiveReservationFlow = flowList.some(item => !!getReservationCurrentNode(item))
return {
hasPendingApplication: result.hasPendingApplication || hasActiveReservationFlow,
hasActiveReservationFlow
}
})
.catch((error) => {
console.error('resolve application flow state failed', error)
return result
})
},
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)
]
if (!skipMessageBadge) {
tasks.push(this.refreshMessageBadgeCount())
}
if (this.isCurrentLogin()) {
tasks.push(
this.resolveApplicationState()
.then((applicationState = {}) => {
return orderSearchedMePage({ PageIndex: 1, PageSize: 100, IsExport: false })
.then(res => {
const orderList = parseOrderListResponse(res)
return this.resolveApplicationFlowState(orderList)
.then((flowState = {}) => ({
hasPendingApplication: !!applicationState.hasPendingApplication,
hasActiveReservationFlow: !!flowState.hasActiveReservationFlow
}))
})
})
.then((flowState = {}) => {
this.sessionState.hasPendingApplication = !!flowState.hasPendingApplication
this.sessionState.hasActiveReservationFlow = !!flowState.hasActiveReservationFlow
})
.catch(() => {
this.sessionState.hasPendingApplication = false
this.sessionState.hasActiveReservationFlow = false
})
)
} else {
this.sessionState.hasPendingApplication = false
this.sessionState.hasActiveReservationFlow = false
}
return Promise.all(tasks).finally(() => {
this.refreshSessionState()
})
},
async handlePageLoad(options = {}) {
await this.refreshShellState(true, {
skipMessageBadge: options.pageId === 'message-center'
})
this.syncCurrentTab({
pageId: options.pageId,
index: Number(options.index || 0)
})
setTimeout(() => {
this.isAppInitLoading = false
}, 1500)
},
async handlePageShow() {
await this.refreshShellState(true, {
skipMessageBadge: this.currentPageId === 'message-center'
})
this.syncCurrentTab()
this.$nextTick(() => {
this.loadedPages.forEach(pageId => {
this.refreshTabPageState(pageId)
})
this.getTabbarItemInfo()
})
},
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 storeCurrentBooking = this.getStoreCurrentBooking()
const pendingContracts = storePendingContracts.length
? storePendingContracts
: (storeUser.pendingContracts || (Array.isArray(session.pendingContracts) ? session.pendingContracts : []))
const hasStayOrder = !!(storeHasCheckedIn || storeCurrentBooking || session.currentBooking)
this.sessionState = {
isLogin: this.isCurrentLogin(),
hasCheckedIn: storeHasCheckedIn || session.hasCheckedIn,
hasStayOrder: hasStayOrder || pendingContracts.length > 0,
hasPendingApplication: !!this.sessionState.hasPendingApplication,
hasActiveReservationFlow: !!this.sessionState.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()
},
isPendingInspectionRecord(record) {
return countPendingInspectionRecords([record]) > 0
},
refreshMessageBadgeCount() {
if (!this.isCurrentLogin()) {
this.messageBadgeCount = 0
return Promise.resolve()
}
const tasks = [
notificationPage({
IsRead: false,
PageIndex: 1,
PageSize: 1,
Search: '',
IsExport: false
})
]
const userCode = this.getCurrentUserCode()
const orderNo = this.getCurrentOrderNo()
if (orderNo) {
tasks.push(checkRecordByChecked({
checkedUserCode: userCode,
orderNo
}))
}
return Promise.all(tasks)
.then(([notificationResponse, inspectionResponse]) => {
const remoteUnread = resolveNotificationUnreadTotal(notificationResponse)
const inspectionList = extractInspectionList(inspectionResponse)
const pendingInspectionCount = countPendingInspectionRecords(inspectionList)
this.messageBadgeCount = remoteUnread + pendingInspectionCount
})
.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)
},
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 preferredPageId = getHomePageTabId()
const shouldPreferStayService = this.sessionState.isLogin && this.sessionState.hasStayOrder
if (shouldPreferStayService && (!preferredPageId || preferredPageId === 'select-room')) {
const stayServiceTab = list.find(item => item.id === 'stay-service')
if (stayServiceTab) return stayServiceTab
}
const shouldPreferApplicationFlow = this.sessionState.isLogin
&& !this.sessionState.hasStayOrder
&& (this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow)
if (shouldPreferApplicationFlow && (!preferredPageId || preferredPageId === 'select-room')) {
const applicationTab = list.find(item => item.id === 'my-application')
if (applicationTab) return applicationTab
}
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
},
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)
}
},
handleStayServiceRefreshState() {
this.refreshSessionState()
},
async handleMyApplicationRefreshState() {
await this.refreshShellState(true)
this.syncCurrentTab()
this.$nextTick(() => {
this.refreshTabPageState(this.currentPageId)
this.getTabbarItemInfo()
})
},
async handleReservationFlowForceRefresh() {
await this.refreshShellState(true)
this.ensurePageLoaded('my-application')
this.$nextTick(() => {
const myApplicationTab = this.getPageRef('myApplicationTab')
if (myApplicationTab && typeof myApplicationTab.load === 'function') {
myApplicationTab.load(true)
} else {
this.refreshTabPageState('my-application')
}
if (this.currentPageId) {
this.refreshTabPageState(this.currentPageId)
}
this.getTabbarItemInfo()
})
},
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()
})
} finally {
this.isTabSwitching = false
}
},
},
// 分享到朋友
onShareAppMessage() {
return {
title: this.t('shareTitle'),
path: '/pages/index/index'
}
},
// 分享到朋友圈
onShareTimeline() {
return {
title: this.t('shareTitle')
}
}
}
</script>
<style lang="scss" scoped>
/* 页面初始化全屏遮罩 */
.init-loading-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #111111;
z-index: 99999;
}
.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;
}
.loading-logo {
animation: pulse 2s infinite ease-in-out;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.1); opacity: 1; }
100% { transform: scale(1); opacity: 0.8; }
}
/*整体样式 */
.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>