chore: initial import
This commit is contained in:
1080
pages/index/component/MessageCenterTab.vue
Normal file
1080
pages/index/component/MessageCenterTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
1421
pages/index/component/MyApplicationTab.vue
Normal file
1421
pages/index/component/MyApplicationTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
1031
pages/index/component/ProfileCenterTab.vue
Normal file
1031
pages/index/component/ProfileCenterTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
828
pages/index/component/SelectRoomAreaPanel.vue
Normal file
828
pages/index/component/SelectRoomAreaPanel.vue
Normal file
@@ -0,0 +1,828 @@
|
||||
<template>
|
||||
<view class="panel-container">
|
||||
<view
|
||||
v-if="bannerList.length"
|
||||
id="content-start"
|
||||
class="compact-banner-wrap"
|
||||
:style="{ top: 0 }"
|
||||
>
|
||||
<!-- 收缩轮播图 -->
|
||||
<view class="compact-swiper-container">
|
||||
<swiper
|
||||
class="compact-swiper"
|
||||
circular
|
||||
:current="currentBannerIndex"
|
||||
@change="handleBannerChange"
|
||||
>
|
||||
<swiper-item v-for="(item, index) in bannerList" :key="index">
|
||||
<image :src="item.image" mode="aspectFill" class="compact-swiper__image"></image>
|
||||
<view class="compact-swiper__mask"></view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<view class="search-header tn-flex tn-flex-row-between tn-flex-col-center">
|
||||
<view class="back-btn tn-flex tn-flex-col-center tn-flex-row-center" @click="$emit('collapse-to-hero')">
|
||||
<text class="tn-icon-up nav-icon"></text>
|
||||
</view>
|
||||
<view class="search-box tn-flex tn-flex-col-center">
|
||||
<text class="tn-icon-search search-icon"></text>
|
||||
<text class="search-placeholder">{{ t('searchApartment') }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="sort-trigger tn-flex tn-flex-col-center tn-flex-row-center"
|
||||
:class="{ 'sort-trigger--active': selectedSortKey !== 'default' }"
|
||||
@click="showSortActionSheet"
|
||||
>
|
||||
<text class="sort-trigger__text">{{ currentSortLabel }}</text>
|
||||
<text class="tn-icon-down sort-trigger__arrow"></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="quick-filter-card">
|
||||
<view class="quick-filter-head tn-flex tn-flex-row-between tn-flex-col-center">
|
||||
<view class="quick-filter-title-box tn-flex tn-flex-col-center">
|
||||
<view class="quick-filter-dot"></view>
|
||||
<text class="quick-filter-title">{{ t('tagFilter') }}</text>
|
||||
</view>
|
||||
<text class="quick-filter-current">{{ displayTag(currentTagLabel) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 快捷标签栏 (横向滚动) -->
|
||||
<scroll-view class="quick-tags-scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="quick-tags-wrap tn-flex tn-flex-col-center">
|
||||
<view
|
||||
class="quick-tag"
|
||||
:class="{ 'quick-tag--active': !selectedTag }"
|
||||
@click="$emit('select-tag', '')"
|
||||
>
|
||||
{{ t('all') }}
|
||||
</view>
|
||||
<view
|
||||
v-for="tag in availableFilterTags"
|
||||
:key="tag"
|
||||
class="quick-tag"
|
||||
:class="{ 'quick-tag--active': selectedTag === tag }"
|
||||
@click="$emit('select-tag', tag)"
|
||||
>
|
||||
{{ displayTag(tag) }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content-section">
|
||||
<view class="content-panel">
|
||||
|
||||
<view
|
||||
v-if="isAreaLoading || roomLoadError || areaLoadError"
|
||||
class="room-status-card tn-margin-bottom-lg"
|
||||
>
|
||||
<view class="room-status-card__title">
|
||||
{{ isAreaLoading ? t('loadingAreaStatus') : t('areaStatus') }}
|
||||
</view>
|
||||
<view class="room-status-card__desc">
|
||||
{{ roomLoadError || areaLoadError || t('areaStatusDesc') }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="!isAreaLoading && !displayAreaSections.length" class="empty-state-card tn-margin-bottom-lg">
|
||||
<view class="empty-state-card__title">{{ bannerList.length ? t('emptyAreaTitle') : t('emptyBannerTitle') }}</view>
|
||||
<view class="empty-state-card__desc">
|
||||
{{ bannerList.length ? t('emptyAreaDesc') : t('emptyBannerDesc') }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 小区/区域卡片 -->
|
||||
<view v-for="area in displayAreaSections" :key="area.code" class="store-card">
|
||||
<!-- 小区头部信息 -->
|
||||
<view class="store-header" @click="openStoreDetail(area)">
|
||||
<view class="store-title-bar tn-flex tn-flex-row-between tn-flex-col-center">
|
||||
<view class="store-title tn-text-ellipsis">{{ getAreaName(area) }}</view>
|
||||
<view class="store-detail-btn tn-flex tn-flex-col-center">
|
||||
<text class="btn-text">{{ t('viewCommunity') }}</text>
|
||||
<text class="tn-icon-right btn-icon"></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="store-tags tn-flex tn-flex-wrap">
|
||||
<view v-for="tag in getDisplayTags(area)" :key="tag" class="store-tag">{{ displayTag(tag) }}</view>
|
||||
</view>
|
||||
|
||||
<view class="store-location-info tn-flex tn-flex-row-between tn-flex-col-center">
|
||||
<view class="store-address tn-flex tn-flex-col-center tn-text-ellipsis">
|
||||
<view class="address-icon-box tn-flex tn-flex-col-center tn-flex-row-center">
|
||||
<text class="tn-icon-location location-icon"></text>
|
||||
</view>
|
||||
<text class="address-text">{{ getAreaAddress(area) || defaultAreaAddress(getAreaName(area)) }}</text>
|
||||
</view>
|
||||
<view class="store-distance">{{ t('distancePlaceholder') }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 横向滚动房型列表 -->
|
||||
<scroll-view class="room-scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="room-list-wrap tn-flex">
|
||||
<view
|
||||
v-for="(roomType, idx) in area.roomTypes"
|
||||
:key="roomType.code"
|
||||
class="room-item"
|
||||
@click="$emit('open-room-type', area, roomType)"
|
||||
>
|
||||
<view class="room-image-box">
|
||||
<image
|
||||
:src="roomType.images && roomType.images.length ? roomType.images[0] : ''"
|
||||
mode="aspectFill"
|
||||
class="room-image"
|
||||
></image>
|
||||
<view class="room-tag-overlay" v-if="roomType.payMethod">{{ getPayMethodText(roomType.payMethod) }}</view>
|
||||
</view>
|
||||
|
||||
<view class="room-info">
|
||||
<view class="room-name tn-text-ellipsis">{{ formatRoomTypeName(getRoomTypeName(roomType)) }}</view>
|
||||
<view class="room-price-row tn-flex tn-flex-row-between tn-flex-col-bottom">
|
||||
<view class="price-content">
|
||||
<text class="price-symbol">¥</text>
|
||||
<text class="price-val">{{ formatDisplayPrice(roomType.price) || t('pricePending') }}</text>
|
||||
<text class="price-unit">{{ t('priceUnit') }}</text>
|
||||
</view>
|
||||
<view class="room-go-icon tn-flex tn-flex-col-center tn-flex-row-center">
|
||||
<text class="tn-icon-right"></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 底部安全区占位,防止最后一张卡片被 tabBar 遮挡 -->
|
||||
<view class="safe-area-bottom-padding"></view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { localizeHousingTag, localizePayMethodText } from '../../../utils/select-room-normalizers'
|
||||
|
||||
export default {
|
||||
name: 'SelectRoomAreaPanel',
|
||||
props: {
|
||||
isBannerLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
bannerLoadError: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
bannerLoadedFromApi: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
currentBanner: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
selectedAreaCode: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
areaList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentAreaLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
selectedTag: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
currentTagLabel: {
|
||||
type: String,
|
||||
default: '全部标签'
|
||||
},
|
||||
availableFilterTags: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedSortKey: {
|
||||
type: String,
|
||||
default: 'default'
|
||||
},
|
||||
currentSortLabel: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isAreaLoading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
roomLoadError: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
areaLoadError: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isLogin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
userInfo: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
displayAreaSections: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
bannerList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentBannerIndex: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'zh'
|
||||
},
|
||||
compactBannerTop: {
|
||||
type: Number,
|
||||
default: 88
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
displayTag(tag = '') {
|
||||
const text = String(tag || '').trim()
|
||||
if (text === '全部标签' && this.language === 'en') {
|
||||
return 'All Tags'
|
||||
}
|
||||
return localizeHousingTag(text, this.language)
|
||||
},
|
||||
t(key) {
|
||||
return this.$tm('pages.index.component.SelectRoomTab', key)
|
||||
},
|
||||
formatRoomTypeName(name) {
|
||||
const normalizedName = String(name || '')
|
||||
return normalizedName.replace(/^[^·]+·/, '')
|
||||
},
|
||||
getAreaName(area = {}) {
|
||||
if (this.language === 'en') {
|
||||
return area.nameEnDisplay || area.nameEn || area.name || ''
|
||||
}
|
||||
return area.name || ''
|
||||
},
|
||||
getAreaAddress(area = {}) {
|
||||
if (this.language === 'en') {
|
||||
return area.addressEnDisplay || area.addressEn || area.address || ''
|
||||
}
|
||||
return area.address || ''
|
||||
},
|
||||
getRoomTypeName(roomType = {}) {
|
||||
if (this.language === 'en') {
|
||||
return roomType.nameEnDisplay || roomType.nameEn || roomType.name || ''
|
||||
}
|
||||
return roomType.name || ''
|
||||
},
|
||||
getPayMethodText(payMethod = '') {
|
||||
return localizePayMethodText(payMethod, this.language)
|
||||
},
|
||||
formatDisplayPrice(price) {
|
||||
if (!price) {
|
||||
return ''
|
||||
}
|
||||
const normalizedPrice = String(price).replace(/,/g, '')
|
||||
const matched = normalizedPrice.match(/(\d+(?:\.\d+)?)/)
|
||||
return matched ? matched[1] : ''
|
||||
},
|
||||
getDisplayTags(area) {
|
||||
const areaTags = Array.isArray(area.tags) ? area.tags : []
|
||||
if (areaTags.length) {
|
||||
return areaTags.slice(0, 4)
|
||||
}
|
||||
|
||||
const roomTypeTags = (area.roomTypes || [])
|
||||
.flatMap((roomType) => roomType.tags || [])
|
||||
.filter(Boolean)
|
||||
|
||||
return [...new Set(roomTypeTags)].slice(0, 4)
|
||||
},
|
||||
showTagActionSheet() {
|
||||
const tagOptions = [this.t('allTags'), ...this.availableFilterTags]
|
||||
if (!tagOptions.length) {
|
||||
return
|
||||
}
|
||||
uni.showActionSheet({
|
||||
itemList: tagOptions.map((tag) => this.displayTag(tag)),
|
||||
success: ({ tapIndex }) => {
|
||||
const selectedTag = tapIndex === 0 ? '' : tagOptions[tapIndex]
|
||||
this.$emit('select-tag', selectedTag)
|
||||
}
|
||||
})
|
||||
},
|
||||
showSortActionSheet() {
|
||||
const sortOptions = [
|
||||
{ label: this.t('sortDefault'), value: 'default' },
|
||||
{ label: this.t('sortPriceAsc'), value: 'price-asc' },
|
||||
{ label: this.t('sortPriceDesc'), value: 'price-desc' },
|
||||
{ label: this.t('sortNameAsc'), value: 'name-asc' }
|
||||
]
|
||||
uni.showActionSheet({
|
||||
itemList: sortOptions.map((item) => item.label),
|
||||
success: ({ tapIndex }) => {
|
||||
const selected = sortOptions[tapIndex]
|
||||
if (selected) {
|
||||
this.$emit('select-sort', selected.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
handleBannerChange(e) {
|
||||
this.$emit('banner-change', e.detail.current)
|
||||
},
|
||||
openStoreDetail(area) {
|
||||
uni.navigateTo({
|
||||
url: `/pkgExplore/store_detail/store_detail?storeData=${encodeURIComponent(JSON.stringify(area))}`
|
||||
})
|
||||
},
|
||||
defaultAreaAddress(areaName = '') {
|
||||
if (this.language === 'en') {
|
||||
return `${this.t('defaultAreaAddressPrefix')}${areaName} CBD`
|
||||
}
|
||||
return `${this.t('defaultAreaAddressPrefix')}${areaName}核心商圈`
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.panel-container {
|
||||
min-height: 100vh;
|
||||
background-color: transparent;
|
||||
}
|
||||
.content-panel {
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* 顶部搜索栏包装 */
|
||||
.compact-banner-wrap {
|
||||
position: sticky;
|
||||
z-index: 99;
|
||||
padding: 0 0 24rpx;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.compact-swiper-container {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.compact-swiper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.compact-swiper__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.compact-swiper__mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(0,0,0,0.15) 0%,
|
||||
rgba(0,0,0,0) 30%,
|
||||
rgba(255,255,255,0) 60%,
|
||||
rgba(255,255,255,1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.search-header {
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
margin-top: -96rpx;
|
||||
min-height: 92rpx;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 46rpx;
|
||||
padding: 0 36rpx;
|
||||
box-sizing: border-box;
|
||||
margin-left: 24rpx;
|
||||
margin-right: 24rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.8);
|
||||
box-shadow:
|
||||
0 10rpx 30rpx rgba(0, 0, 0, 0.05),
|
||||
0 4rpx 10rpx rgba($brand-primary, 0.05);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
flex-shrink: 0;
|
||||
color: #333;
|
||||
margin-right: 20rpx;
|
||||
background: #f8f9fa;
|
||||
border-radius: 50%;
|
||||
border: 1rpx solid rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 34rpx;
|
||||
margin-right: 16rpx;
|
||||
line-height: 1;
|
||||
color: $brand-primary;
|
||||
}
|
||||
|
||||
.search-placeholder {
|
||||
font-size: 28rpx;
|
||||
color: #a0a7b5;
|
||||
letter-spacing: 1rpx;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.sort-trigger {
|
||||
max-width: 200rpx;
|
||||
min-width: 144rpx;
|
||||
height: 64rpx;
|
||||
padding: 0 24rpx;
|
||||
flex-shrink: 0;
|
||||
border-radius: 32rpx;
|
||||
border: 1rpx solid rgba($brand-primary, 0.12);
|
||||
background: #f0f7ff;
|
||||
color: #0C6E8B;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.sort-trigger--active {
|
||||
background: $brand-primary;
|
||||
color: #ffffff;
|
||||
border-color: $brand-primary;
|
||||
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.2);
|
||||
}
|
||||
|
||||
.sort-trigger__text {
|
||||
max-width: 128rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort-trigger__arrow {
|
||||
margin-left: 10rpx;
|
||||
font-size: 20rpx;
|
||||
line-height: 1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.quick-filter-card {
|
||||
margin: 18rpx 24rpx 0;
|
||||
padding: 22rpx 0 18rpx;
|
||||
border-radius: 28rpx;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.88);
|
||||
box-shadow:
|
||||
0 12rpx 28rpx rgba(15, 23, 42, 0.05),
|
||||
0 6rpx 16rpx rgba($brand-primary, 0.06);
|
||||
}
|
||||
|
||||
.quick-filter-head {
|
||||
padding: 0 24rpx 14rpx;
|
||||
}
|
||||
|
||||
.quick-filter-title-box {
|
||||
color: #1E293B;
|
||||
}
|
||||
|
||||
.quick-filter-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
margin-right: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, $brand-primary 0%, #76B8FF 100%);
|
||||
box-shadow: 0 0 0 6rpx rgba($brand-primary, 0.08);
|
||||
}
|
||||
|
||||
.quick-filter-title {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.quick-filter-current {
|
||||
max-width: 220rpx;
|
||||
font-size: 22rpx;
|
||||
color: #94A3B8;
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 横向快捷标签 */
|
||||
.quick-tags-scroll {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-tags-wrap {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.quick-tag {
|
||||
flex-shrink: 0;
|
||||
min-width: 96rpx;
|
||||
text-align: center;
|
||||
background: #F8FBFF;
|
||||
color: #475569;
|
||||
font-size: 24rpx;
|
||||
padding: 12rpx 26rpx;
|
||||
border-radius: 999rpx;
|
||||
margin-right: 16rpx;
|
||||
font-weight: 500;
|
||||
border: 1rpx solid rgba($brand-primary, 0.08);
|
||||
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.quick-tag--active {
|
||||
background: linear-gradient(135deg, rgba($brand-primary, 0.16) 0%, rgba(118, 184, 255, 0.24) 100%);
|
||||
color: #0C6E8B;
|
||||
border-color: rgba($brand-primary, 0.22);
|
||||
box-shadow: 0 8rpx 18rpx rgba($brand-primary, 0.10);
|
||||
}
|
||||
|
||||
/* 小区卡片整体 */
|
||||
.store-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20rpx);
|
||||
-webkit-backdrop-filter: blur(20rpx);
|
||||
border-radius: 32rpx;
|
||||
padding: 32rpx;
|
||||
margin-bottom: 32rpx;
|
||||
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.08);
|
||||
border: 1rpx solid rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
/* 小区头部信息 */
|
||||
.store-header {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.store-title-bar {
|
||||
min-width: 0;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.store-title {
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
color: #1A1D24;
|
||||
max-width: 75%;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.store-detail-btn {
|
||||
background: rgba($brand-primary, 0.08);
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 24rpx;
|
||||
color: $brand-primary;
|
||||
|
||||
.btn-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 20rpx;
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.store-tags {
|
||||
margin-bottom: 24rpx;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.store-tag {
|
||||
color: #0C6E8B;
|
||||
background: #F0F9FF;
|
||||
font-size: 22rpx;
|
||||
padding: 6rpx 18rpx;
|
||||
border-radius: 12rpx;
|
||||
font-weight: 500;
|
||||
border: 1rpx solid rgba($brand-primary, 0.1);
|
||||
}
|
||||
|
||||
.store-location-info {
|
||||
color: #64748B;
|
||||
font-size: 24rpx;
|
||||
background: #F8FAFC;
|
||||
padding: 16rpx 24rpx;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.store-address {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.address-icon-box {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
margin-right: 12rpx;
|
||||
box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
font-size: 26rpx;
|
||||
color: $brand-primary;
|
||||
}
|
||||
|
||||
.address-text {
|
||||
min-width: 0;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-distance {
|
||||
color: #1E293B;
|
||||
font-weight: 600;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
/* 横向滚动房型列表 */
|
||||
.room-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.room-list-wrap {
|
||||
gap: 16rpx;
|
||||
padding-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.room-item {
|
||||
width: 240rpx;
|
||||
flex-shrink: 0;
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding-bottom: 16rpx;
|
||||
box-shadow: 0 6rpx 16rpx rgba(0, 0, 0, 0.03);
|
||||
overflow: hidden;
|
||||
border: 1rpx solid #F1F5F9;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.room-item:active {
|
||||
transform: translateY(-2rpx);
|
||||
box-shadow: 0 10rpx 24rpx rgba($brand-primary, 0.1);
|
||||
}
|
||||
|
||||
.room-image-box {
|
||||
width: 100%;
|
||||
height: 180rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.room-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #F8FAFC;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.room-tag-overlay {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
right: 12rpx;
|
||||
max-width: calc(100% - 24rpx);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(8rpx);
|
||||
color: #fff;
|
||||
font-size: 18rpx;
|
||||
padding: 4rpx 10rpx;
|
||||
border-radius: 999rpx;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.room-info {
|
||||
width: 100%;
|
||||
padding: 16rpx 16rpx 0;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-size: 26rpx;
|
||||
color: #1E293B;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.room-price-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.price-symbol {
|
||||
font-size: 20rpx;
|
||||
color: #EF4444;
|
||||
font-weight: 700;
|
||||
margin-right: 2rpx;
|
||||
}
|
||||
|
||||
.price-val {
|
||||
color: #EF4444;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.price-unit {
|
||||
color: #94A3B8;
|
||||
font-size: 18rpx;
|
||||
margin-left: 4rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.room-go-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
background: #F8FAFC;
|
||||
border-radius: 50%;
|
||||
color: #94A3B8;
|
||||
font-size: 18rpx;
|
||||
border: 1rpx solid #F1F5F9;
|
||||
}
|
||||
|
||||
.safe-area-bottom-padding {
|
||||
height: calc(140rpx + env(safe-area-inset-bottom));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.room-status-card,
|
||||
.empty-state-card {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
backdrop-filter: blur(10rpx);
|
||||
border-radius: 20rpx;
|
||||
padding: 32rpx;
|
||||
text-align: center;
|
||||
border: 1rpx solid rgba($brand-primary, 0.1);
|
||||
box-shadow: 0 4rpx 16rpx rgba($brand-primary, 0.04);
|
||||
}
|
||||
|
||||
.room-status-card__title,
|
||||
.empty-state-card__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.room-status-card__desc,
|
||||
.empty-state-card__desc {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
459
pages/index/component/SelectRoomHero.vue
Normal file
459
pages/index/component/SelectRoomHero.vue
Normal file
@@ -0,0 +1,459 @@
|
||||
<template>
|
||||
<view>
|
||||
<view v-if="bannerList.length" class="hero-shell">
|
||||
<swiper
|
||||
class="hero-swiper"
|
||||
circular
|
||||
:autoplay="true"
|
||||
interval="6000"
|
||||
duration="800"
|
||||
:current="currentBannerIndex"
|
||||
@change="handleBannerChange"
|
||||
>
|
||||
<swiper-item v-for="item in bannerList" :key="item.id">
|
||||
<image :src="item.image" mode="aspectFill" class="hero-image"></image>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<view class="hero-mask"></view>
|
||||
|
||||
<view class="hero-content">
|
||||
<!-- 顶部横向 Tabs -->
|
||||
<view class="hero-tabs tn-flex tn-flex-row-center" :class="{ 'hero-tabs--en': language === 'en' }">
|
||||
<view
|
||||
v-for="(item, index) in bannerList"
|
||||
:key="item.id"
|
||||
class="hero-tab-item tn-flex tn-flex-direction-column tn-flex-col-center"
|
||||
:class="{
|
||||
'hero-tab-item--active': currentBannerIndex === index,
|
||||
'hero-tab-item--en': language === 'en'
|
||||
}"
|
||||
@click="switchBanner(index)"
|
||||
>
|
||||
<text class="tab-title" :class="{ 'tab-title--en': language === 'en' }">{{ displayTabTitle(item) }}</text>
|
||||
<text class="tab-subtitle" :class="{ 'tab-subtitle--en': language === 'en' }">{{ displayTabSubtitle(item) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 居中文案区 -->
|
||||
<view
|
||||
class="hero-typography tn-flex tn-flex-direction-column tn-flex-col-center"
|
||||
:class="{ 'hero-typography--no-logo': !(currentBanner.logoType === 'image' && currentBanner.logoImage) && !currentBanner.logoIconClass }"
|
||||
>
|
||||
<image
|
||||
v-if="currentBanner.logoType === 'image' && currentBanner.logoImage"
|
||||
:src="currentBanner.logoImage"
|
||||
mode="aspectFit"
|
||||
class="hero-logo-image"
|
||||
></image>
|
||||
<text
|
||||
v-else-if="currentBanner.logoIconClass"
|
||||
:class="[currentBanner.logoIconClass, 'hero-logo-icon']"
|
||||
></text>
|
||||
<view class="hero-eng-title" :class="{ 'hero-eng-title--en': language === 'en' }">{{ getBannerText(currentBanner, 'subtitle') }}</view>
|
||||
<view class="hero-title" :class="{ 'hero-title--en': language === 'en' }">{{ getBannerText(currentBanner, 'title') }}</view>
|
||||
<view class="hero-desc" :class="{ 'hero-desc--en': language === 'en' }">{{ getBannerText(currentBanner, 'desc') }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部操作区 -->
|
||||
<view class="hero-bottom tn-flex tn-flex-direction-column tn-flex-col-center">
|
||||
<view class="hero-actions tn-flex tn-flex-row-between" :class="{ 'hero-actions--en': language === 'en' }">
|
||||
<view class="action-btn outline-btn" :class="{ 'action-btn--en': language === 'en' }" @click="$emit('consult')">{{ t('apartmentDetail') }}</view>
|
||||
<view class="action-btn primary-btn" :class="{ 'action-btn--en': language === 'en' }" @click="$emit('submit')">{{ t('bookViewing') }}</view>
|
||||
</view>
|
||||
|
||||
<view class="scroll-hint tn-flex tn-flex-direction-column tn-flex-col-center" @click="$emit('collapse')">
|
||||
<text class="scroll-hint__text">{{ t('learnMore') }}</text>
|
||||
<view class="scroll-hint__double-icon">
|
||||
<text class="tn-icon-down scroll-hint__icon"></text>
|
||||
<text class="tn-icon-down scroll-hint__icon scroll-hint__icon--second"></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'SelectRoomHero',
|
||||
props: {
|
||||
bannerList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
currentBanner: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
currentBannerIndex: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
default: 'zh'
|
||||
},
|
||||
isHeroCollapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
compactBannerTop: {
|
||||
type: Number,
|
||||
default: 88
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
getBannerText(item = {}, field = '') {
|
||||
const key = String(field || '').trim()
|
||||
if (!key) {
|
||||
return ''
|
||||
}
|
||||
const zhText = item[key]
|
||||
const enText = item[`${key}En`]
|
||||
if (this.language === 'en') {
|
||||
return enText || zhText || ''
|
||||
}
|
||||
return zhText || ''
|
||||
},
|
||||
getBannerDisplayText(item = {}, field = '') {
|
||||
const key = String(field || '').trim()
|
||||
if (!key) {
|
||||
return ''
|
||||
}
|
||||
if (this.language === 'en') {
|
||||
return item[`${key}EnDisplay`] || item[`${key}En`] || item[key] || ''
|
||||
}
|
||||
return item[key] || ''
|
||||
},
|
||||
displayTabTitle(item = {}) {
|
||||
return this.formatTabTitle(this.getBannerDisplayText(item, 'title'))
|
||||
},
|
||||
displayTabSubtitle(item = {}) {
|
||||
return this.getBannerDisplayText(item, 'tab')
|
||||
},
|
||||
t(key) {
|
||||
return this.$tm('pages.index.component.SelectRoomTab', key)
|
||||
},
|
||||
formatTabTitle(title) {
|
||||
const normalizedTitle = String(title || '')
|
||||
return normalizedTitle
|
||||
.replace(/^[^·]+·/, '')
|
||||
.replace(/^\s*CBD/, '')
|
||||
.trim()
|
||||
},
|
||||
handleBannerChange(e) {
|
||||
this.$emit('banner-change', e.detail.current)
|
||||
},
|
||||
switchBanner(index) {
|
||||
this.$emit('banner-switch', index)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hero-shell {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
min-height: 1100rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-swiper,
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hero-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.2) 50%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 180rpx 0 40rpx;
|
||||
pointer-events: none; /* 让事件穿透到 swiper */
|
||||
}
|
||||
|
||||
.hero-tabs, .hero-typography, .hero-bottom {
|
||||
pointer-events: auto; /* 恢复子元素事件 */
|
||||
}
|
||||
|
||||
/* 顶部横向 Tabs */
|
||||
.hero-tabs {
|
||||
padding: 0 40rpx;
|
||||
gap: 60rpx;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hero-tabs--en {
|
||||
padding: 0 24rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.hero-tab-item {
|
||||
opacity: 0.6;
|
||||
transition: all 0.3s ease;
|
||||
max-width: 240rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-tab-item--en {
|
||||
flex: 1;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-tab-item--active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: #ffffff;
|
||||
font-size: 34rpx;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2rpx;
|
||||
margin-bottom: 8rpx;
|
||||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-subtitle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: #ffffff;
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 2rpx;
|
||||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tab-title--en {
|
||||
font-size: 24rpx;
|
||||
letter-spacing: 0.5rpx;
|
||||
line-height: 1.25;
|
||||
min-height: 60rpx;
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
.tab-subtitle--en {
|
||||
font-size: 18rpx;
|
||||
letter-spacing: 0.5rpx;
|
||||
line-height: 1.2;
|
||||
white-space: normal;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
text-overflow: unset;
|
||||
}
|
||||
|
||||
/* 居中文案区 */
|
||||
.hero-typography {
|
||||
position: relative;
|
||||
margin-top: -100rpx; /* 微调垂直居中位置 */
|
||||
padding: 0 48rpx;
|
||||
}
|
||||
|
||||
.hero-logo-icon {
|
||||
font-size: 80rpx;
|
||||
color: $icon-tone-luxury;
|
||||
margin-bottom: 24rpx;
|
||||
text-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.hero-logo-image {
|
||||
width: 160rpx;
|
||||
height: 80rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.hero-typography--no-logo {
|
||||
margin-top: -36rpx;
|
||||
}
|
||||
|
||||
.hero-typography--no-logo::before {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 88rpx;
|
||||
height: 2rpx;
|
||||
margin: 0 auto 28rpx;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.75) 50%, rgba(255, 255, 255, 0) 100%);
|
||||
}
|
||||
|
||||
.hero-eng-title {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 22rpx;
|
||||
letter-spacing: 4rpx;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 16rpx;
|
||||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.hero-eng-title--en {
|
||||
font-size: 20rpx;
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
|
||||
.hero-typography--no-logo .hero-eng-title {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
color: #ffffff;
|
||||
font-size: 56rpx;
|
||||
font-weight: bold;
|
||||
letter-spacing: 6rpx;
|
||||
margin-bottom: 24rpx;
|
||||
text-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.hero-title--en {
|
||||
font-size: 48rpx;
|
||||
letter-spacing: 1rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-typography--no-logo .hero-title {
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
color: #ffffff;
|
||||
font-size: 30rpx;
|
||||
letter-spacing: 4rpx;
|
||||
max-width: 560rpx;
|
||||
text-align: center;
|
||||
line-height: 1.7;
|
||||
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.hero-desc--en {
|
||||
font-size: 26rpx;
|
||||
letter-spacing: 1rpx;
|
||||
max-width: 600rpx;
|
||||
}
|
||||
|
||||
/* 底部操作区 */
|
||||
.hero-bottom {
|
||||
width: 100%;
|
||||
/* 避开首页自定义 tabbar(114rpx),同时别抬得太高 */
|
||||
padding-bottom: calc(132rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
width: 100%;
|
||||
padding: 0 120rpx;
|
||||
margin-bottom: 60rpx;
|
||||
gap: 40rpx;
|
||||
}
|
||||
|
||||
.hero-actions--en {
|
||||
padding: 0 92rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
border: 2rpx solid rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2rpx;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.action-btn--en {
|
||||
font-size: 28rpx;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.outline-btn {
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96) 0%, rgba(244, 248, 255, 0.92) 100%);
|
||||
color: #0f172a;
|
||||
box-shadow: 0 14rpx 30rpx rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.primary-btn:active {
|
||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.88) 0%, rgba(235, 242, 252, 0.84) 100%);
|
||||
}
|
||||
|
||||
.scroll-hint {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scroll-hint__text {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 24rpx;
|
||||
letter-spacing: 4rpx;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.scroll-hint__double-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-top: -4rpx;
|
||||
}
|
||||
|
||||
.scroll-hint__icon {
|
||||
color: $icon-tone-overlay;
|
||||
font-size: 32rpx;
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
.scroll-hint__icon--second {
|
||||
margin-top: -14rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 20%, 50%, 80%, 100% { transform: translateY(0); }
|
||||
40% { transform: translateY(12rpx); }
|
||||
60% { transform: translateY(6rpx); }
|
||||
}
|
||||
</style>
|
||||
617
pages/index/component/SelectRoomTab.vue
Normal file
617
pages/index/component/SelectRoomTab.vue
Normal file
@@ -0,0 +1,617 @@
|
||||
<template>
|
||||
<view class="select-room-page tn-safe-area-inset-bottom">
|
||||
<tn-nav-bar
|
||||
v-if="currentScreen === 0"
|
||||
:isBack="false"
|
||||
:bottomShadow="false"
|
||||
:backgroundColor="navBarBg"
|
||||
:fixed="true"
|
||||
:customBackStyle="navBarTransition"
|
||||
>
|
||||
<view class="custom-nav tn-flex tn-flex-col-center">
|
||||
<view class="nav-left">
|
||||
<view class="lang-text-toggle tn-flex tn-flex-col-center" @click="toggleLang">
|
||||
<text class="lang-text" :class="{ 'lang-text--active': currentLang === 'zh' }">中</text>
|
||||
<text class="lang-split">/</text>
|
||||
<text class="lang-text" :class="{ 'lang-text--active': currentLang === 'en' }">EN</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</tn-nav-bar>
|
||||
|
||||
<swiper
|
||||
class="page-swiper"
|
||||
vertical
|
||||
:current="currentScreen"
|
||||
@change="handleScreenChange"
|
||||
:disable-touch="currentScreen === 1"
|
||||
>
|
||||
<swiper-item>
|
||||
<SelectRoomHero
|
||||
:language="currentLang"
|
||||
:banner-list="bannerList"
|
||||
:current-banner="currentBanner"
|
||||
:current-banner-index="currentBannerIndex"
|
||||
@banner-change="handleBannerChange"
|
||||
@banner-switch="switchBanner"
|
||||
@consult="handleConsult"
|
||||
@submit="handleSubmit"
|
||||
@collapse="collapseToTopBanner"
|
||||
/>
|
||||
</swiper-item>
|
||||
|
||||
<swiper-item>
|
||||
<scroll-view
|
||||
class="panel-scroll"
|
||||
scroll-y
|
||||
scroll-with-animation
|
||||
:show-scrollbar="false"
|
||||
>
|
||||
<SelectRoomAreaPanel
|
||||
:language="currentLang"
|
||||
:banner-list="bannerList"
|
||||
:current-banner-index="currentBannerIndex"
|
||||
:compact-banner-top="compactBannerTop"
|
||||
:is-banner-loading="isBannerLoading"
|
||||
:banner-load-error="bannerLoadError"
|
||||
:banner-loaded-from-api="bannerLoadedFromApi"
|
||||
:current-banner="currentBanner"
|
||||
:selected-area-code="selectedAreaCode"
|
||||
:area-list="areaList"
|
||||
:current-area-label="currentAreaLabel"
|
||||
:selected-tag="selectedTag"
|
||||
:current-tag-label="currentTagLabel"
|
||||
:available-filter-tags="availableFilterTags"
|
||||
:selected-sort-key="selectedSortKey"
|
||||
:current-sort-label="currentSortLabel"
|
||||
:is-area-loading="isAreaLoading"
|
||||
:room-load-error="roomLoadError"
|
||||
:area-load-error="areaLoadError"
|
||||
:is-login="isLogin"
|
||||
:user-info="userInfo"
|
||||
:display-area-sections="displayAreaSections"
|
||||
@retry-banner="retryLoadBanner"
|
||||
@select-area="selectArea"
|
||||
@select-tag="selectTag"
|
||||
@select-sort="selectSort"
|
||||
@open-room-type="openRoomTypeEntry"
|
||||
@banner-change="handleBannerChange"
|
||||
@collapse-to-hero="collapseToHero"
|
||||
/>
|
||||
</scroll-view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getSessionSnapshot } from '../../../utils/app-state'
|
||||
import { ensureBookingAccess } from '../../../utils/booking-guard'
|
||||
import { getStoredUser } from '../../../utils/flow'
|
||||
import { areaPagination, bannerAll } from '@/api/index.js'
|
||||
import { normalizeAreaItem, normalizeBannerItem } from '../../../utils/select-room-normalizers'
|
||||
import SelectRoomHero from './SelectRoomHero.vue'
|
||||
import SelectRoomAreaPanel from './SelectRoomAreaPanel.vue'
|
||||
|
||||
const SORT_LABEL_MAP = {
|
||||
zh: {
|
||||
default: '默认排序',
|
||||
'price-asc': '价格从低到高',
|
||||
'price-desc': '价格从高到低',
|
||||
'name-asc': '名称排序'
|
||||
},
|
||||
en: {
|
||||
default: 'Default',
|
||||
'price-asc': 'Price: Low to High',
|
||||
'price-desc': 'Price: High to Low',
|
||||
'name-asc': 'Name'
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'SelectRoomTab',
|
||||
components: {
|
||||
SelectRoomHero,
|
||||
SelectRoomAreaPanel
|
||||
},
|
||||
i18n: {
|
||||
zh: {
|
||||
shareTitle: '绿居公寓 - 国际化青年社区',
|
||||
all: '全部',
|
||||
allAreas: '全部区域',
|
||||
allTags: '全部标签',
|
||||
apartmentDetail: '公寓详情',
|
||||
areaStatus: '区域状态',
|
||||
areaStatusDesc: '暂无符合筛选条件的区域',
|
||||
areaUnavailableTitle: '该区域暂未开放',
|
||||
bookViewing: '预约看房',
|
||||
defaultAreaAddressPrefix: '位于',
|
||||
distancePlaceholder: '附近',
|
||||
emptyAreaDesc: '当前筛选条件下暂无可用房源',
|
||||
emptyAreaTitle: "暂无匹配房源",
|
||||
emptyBannerDesc: "暂无可展示的轮播图",
|
||||
emptyBannerTitle: "暂无轮播图",
|
||||
learnMore: "了解更多",
|
||||
loadingAreaStatus: "区域加载中",
|
||||
pricePending: "待定",
|
||||
priceUnit: "元/月起",
|
||||
searchApartment: "搜索区域、公寓或房型",
|
||||
sortDefault: "默认排序",
|
||||
sortNameAsc: "名称排序",
|
||||
sortPriceAsc: "价格从低到高",
|
||||
sortPriceDesc: "价格从高到低",
|
||||
tagFilter: "标签筛选",
|
||||
viewCommunity: "查看详情"
|
||||
},
|
||||
en: {
|
||||
shareTitle: 'Luju Apartments - International Youth Community',
|
||||
all: "All",
|
||||
allAreas: "All Areas",
|
||||
allTags: "All Tags",
|
||||
apartmentDetail: "Apartment Detail",
|
||||
areaStatus: "Area Status",
|
||||
areaStatusDesc: "No areas matched the current filter.",
|
||||
areaUnavailableTitle: "Area information unavailable",
|
||||
bookViewing: "Book Viewing",
|
||||
defaultAreaAddressPrefix: "Located at ",
|
||||
distancePlaceholder: "Nearby",
|
||||
emptyAreaDesc: "No rooms available under the current filters.",
|
||||
emptyAreaTitle: "No matched rooms",
|
||||
emptyBannerDesc: "No active banners available.",
|
||||
emptyBannerTitle: "No banners",
|
||||
learnMore: "Learn More",
|
||||
loadingAreaStatus: "Loading areas",
|
||||
pricePending: "TBD",
|
||||
priceUnit: "/month",
|
||||
searchApartment: "Search by area, apartment or room type",
|
||||
sortDefault: "Default",
|
||||
sortNameAsc: "Name",
|
||||
sortPriceAsc: "Price: Low to High",
|
||||
sortPriceDesc: "Price: High to Low",
|
||||
tagFilter: "Tags",
|
||||
viewCommunity: "View Details"
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLogin: false,
|
||||
userInfo: null,
|
||||
currentBannerIndex: 0,
|
||||
currentScreen: 0,
|
||||
isHeroCollapsed: false,
|
||||
isBannerLoading: false,
|
||||
bannerLoadedFromApi: false,
|
||||
bannerLoadError: '',
|
||||
isAreaLoading: false,
|
||||
areaLoadError: '',
|
||||
areaList: [],
|
||||
selectedAreaCode: '',
|
||||
selectedTag: '',
|
||||
selectedSortKey: 'default',
|
||||
roomLoadError: '',
|
||||
navBarBg: 'transparent',
|
||||
navBarTransition: { transition: 'background-color 0.3s ease' },
|
||||
bannerList: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentLang() {
|
||||
return this.$getLanguage()
|
||||
},
|
||||
currentBanner() {
|
||||
return this.bannerList[this.currentBannerIndex] || {}
|
||||
},
|
||||
compactBannerTop() {
|
||||
if (this.currentScreen === 0) {
|
||||
return this.vuex_custom_bar_height || 88
|
||||
}
|
||||
return (this.vuex_status_bar_height || 20) + 12
|
||||
},
|
||||
currentAreaLabel() {
|
||||
const matchedArea = this.areaList.find((item) => item.code === this.resolveBannerAreaCode())
|
||||
if (matchedArea) {
|
||||
return this.getLocalizedAreaText(matchedArea)
|
||||
}
|
||||
if (this.currentLang === 'en') {
|
||||
return this.currentBanner.areaLabelEnDisplay || this.currentBanner.areaLabelEn || this.currentBanner.areaLabel || this.t('allAreas')
|
||||
}
|
||||
return this.currentBanner.areaLabel || this.t('allAreas')
|
||||
},
|
||||
currentTagLabel() {
|
||||
return this.selectedTag || this.t('allTags')
|
||||
},
|
||||
availableFilterTags() {
|
||||
const preferredOrder = ['押一付一', '生活便利', '独立厨卫', '商圈环绕', '民水民电', '拎包入住', '近地铁', '智能家居']
|
||||
const tagSet = new Set()
|
||||
|
||||
this.areaList.forEach((area) => {
|
||||
;(area.tags || []).forEach((tag) => tagSet.add(tag))
|
||||
;(area.roomTypes || []).forEach((roomType) => {
|
||||
;(roomType.tags || []).forEach((tag) => tagSet.add(tag))
|
||||
})
|
||||
})
|
||||
|
||||
const preferredTags = preferredOrder.filter((tag) => tagSet.has(tag))
|
||||
const extraTags = [...tagSet].filter((tag) => !preferredOrder.includes(tag))
|
||||
|
||||
return [...preferredTags, ...extraTags]
|
||||
},
|
||||
currentSortLabel() {
|
||||
const labelMap = SORT_LABEL_MAP[this.currentLang] || SORT_LABEL_MAP.zh
|
||||
return labelMap[this.selectedSortKey] || labelMap.default
|
||||
},
|
||||
displayAreaSections() {
|
||||
const filteredAreas = this.areaList
|
||||
.map((area) => ({
|
||||
...area,
|
||||
roomTypes: this.filterAndSortRoomTypes(area)
|
||||
}))
|
||||
.filter((area) => (area.code || area.name) && area.roomTypes.length)
|
||||
|
||||
return this.sortAreaSections(filteredAreas)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentLang() {
|
||||
this.loadBannerList()
|
||||
this.loadAreaList()
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.checkLoginStatus()
|
||||
await Promise.all([this.loadBannerList(), this.loadAreaList()])
|
||||
},
|
||||
methods: {
|
||||
t(key) {
|
||||
return this.$tm('pages.index.component.SelectRoomTab', key)
|
||||
},
|
||||
getLocalizedBannerText(banner = {}, field = '') {
|
||||
const key = String(field || '').trim()
|
||||
if (!key) {
|
||||
return ''
|
||||
}
|
||||
const zhText = banner[key]
|
||||
const enText = banner[`${key}En`]
|
||||
if (this.currentLang === 'en') {
|
||||
return enText || zhText || ''
|
||||
}
|
||||
return zhText || ''
|
||||
},
|
||||
getLocalizedAreaText(area = {}) {
|
||||
if (this.currentLang === 'en') {
|
||||
return area.nameEnDisplay || area.nameEn || area.name || ''
|
||||
}
|
||||
return area.name || ''
|
||||
},
|
||||
getLocalizedRoomTypeText(roomType = {}) {
|
||||
if (this.currentLang === 'en') {
|
||||
return roomType.nameEnDisplay || roomType.nameEn || roomType.name || ''
|
||||
}
|
||||
return roomType.name || ''
|
||||
},
|
||||
toggleLang() {
|
||||
this.$toggleLocale()
|
||||
},
|
||||
checkLoginStatus() {
|
||||
const session = getSessionSnapshot()
|
||||
this.isLogin = session.isLogin
|
||||
this.userInfo = this.isLogin ? getStoredUser() : null
|
||||
},
|
||||
loadBannerList() {
|
||||
this.isBannerLoading = true
|
||||
this.bannerLoadError = ''
|
||||
|
||||
return bannerAll(this.currentLang)
|
||||
.then((res) => {
|
||||
return Array.isArray(res?.data)
|
||||
? res.data
|
||||
: Array.isArray(res)
|
||||
? res
|
||||
: []
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('bannerAll API failed, using fallback', e)
|
||||
return []
|
||||
})
|
||||
.then((rawList) => {
|
||||
const enabledList = rawList
|
||||
.filter((item) => item && item.enabled !== false)
|
||||
.sort((a, b) => (a.sort || 0) - (b.sort || 0))
|
||||
.map((item, index) => normalizeBannerItem(item, index))
|
||||
|
||||
this.bannerList = enabledList
|
||||
this.bannerLoadedFromApi = true
|
||||
this.currentBannerIndex = 0
|
||||
})
|
||||
.finally(() => {
|
||||
this.isBannerLoading = false
|
||||
})
|
||||
},
|
||||
loadAreaList() {
|
||||
this.isAreaLoading = true
|
||||
this.areaLoadError = ''
|
||||
|
||||
return areaPagination({ PageIndex: 1, PageSize: 10000 }, this.currentLang)
|
||||
.then((res) => {
|
||||
return Array.isArray(res?.data?.data)
|
||||
? res.data.data
|
||||
: Array.isArray(res?.data)
|
||||
? res.data
|
||||
: []
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('areaPagination API failed, using fallback', e)
|
||||
return []
|
||||
})
|
||||
.then((rawList) => {
|
||||
if (rawList && rawList.length) {
|
||||
this.areaList = rawList.map((item) => normalizeAreaItem(item))
|
||||
} else {
|
||||
this.areaList = []
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.isAreaLoading = false
|
||||
})
|
||||
},
|
||||
resolveBannerAreaCode() {
|
||||
if (!this.areaList.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (this.currentBanner.areaCode) {
|
||||
const codeMatched = this.areaList.find((item) => item.code === this.currentBanner.areaCode)
|
||||
if (codeMatched) {
|
||||
return codeMatched.code
|
||||
}
|
||||
}
|
||||
|
||||
const byName = this.areaList.find((item) => {
|
||||
const labels = [
|
||||
item.name,
|
||||
item.nameEn
|
||||
].filter(Boolean)
|
||||
return labels.includes(this.currentBanner.areaLabel) || labels.includes(this.currentBanner.areaLabelEn)
|
||||
})
|
||||
if (byName) {
|
||||
return byName.code
|
||||
}
|
||||
|
||||
const fuzzy = this.areaList.find((item) => {
|
||||
const labels = [
|
||||
this.currentBanner.areaLabel,
|
||||
this.currentBanner.areaLabelEn
|
||||
].filter(Boolean)
|
||||
return labels.some((label) => label.includes(item.name) || label.includes(item.nameEn || ''))
|
||||
})
|
||||
return fuzzy ? fuzzy.code : ''
|
||||
},
|
||||
applyBannerFilters() {
|
||||
const resolvedAreaCode = this.resolveBannerAreaCode()
|
||||
this.selectedAreaCode = resolvedAreaCode
|
||||
},
|
||||
selectArea(areaCode = '') {
|
||||
this.selectedAreaCode = areaCode
|
||||
},
|
||||
selectTag(tag = '') {
|
||||
this.selectedTag = this.selectedTag === tag ? '' : tag
|
||||
},
|
||||
selectSort(sortKey = 'default') {
|
||||
this.selectedSortKey = sortKey
|
||||
},
|
||||
getPriceValue(priceText) {
|
||||
if (!priceText) {
|
||||
return Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
const matched = String(priceText).match(/(\d+(\.\d+)?)/)
|
||||
return matched ? Number(matched[1]) : Number.MAX_SAFE_INTEGER
|
||||
},
|
||||
getRoomTypePriceValue(roomType = {}) {
|
||||
if (Number.isFinite(roomType.priceValue)) {
|
||||
return roomType.priceValue
|
||||
}
|
||||
return this.getPriceValue(roomType.price)
|
||||
},
|
||||
isTagMatched(tags = [], tag = '') {
|
||||
if (!tag) {
|
||||
return true
|
||||
}
|
||||
return Array.isArray(tags) && tags.includes(tag)
|
||||
},
|
||||
filterAndSortRoomTypes(area = {}) {
|
||||
const areaMatched = this.isTagMatched(area.tags, this.selectedTag)
|
||||
const roomTypes = areaMatched || !this.selectedTag
|
||||
? (area.roomTypes || [])
|
||||
: (area.roomTypes || []).filter((roomType) => this.isTagMatched(roomType.tags, this.selectedTag))
|
||||
|
||||
return this.sortRoomTypes(roomTypes)
|
||||
},
|
||||
sortRoomTypes(roomTypes = []) {
|
||||
const list = [...roomTypes]
|
||||
if (this.selectedSortKey === 'price-asc') {
|
||||
return list.sort((a, b) => this.getRoomTypePriceValue(a) - this.getRoomTypePriceValue(b))
|
||||
}
|
||||
if (this.selectedSortKey === 'price-desc') {
|
||||
return list.sort((a, b) => this.getRoomTypePriceValue(b) - this.getRoomTypePriceValue(a))
|
||||
}
|
||||
if (this.selectedSortKey === 'name-asc') {
|
||||
return list.sort((a, b) => String(this.getLocalizedRoomTypeText(a) || '').localeCompare(String(this.getLocalizedRoomTypeText(b) || ''), this.currentLang === 'en' ? 'en' : 'zh-Hans-CN'))
|
||||
}
|
||||
return list
|
||||
},
|
||||
getAreaSortPrice(area = {}, mode = 'min') {
|
||||
const values = (area.roomTypes || [])
|
||||
.map((roomType) => this.getRoomTypePriceValue(roomType))
|
||||
.filter((value) => Number.isFinite(value) && value !== Number.MAX_SAFE_INTEGER)
|
||||
|
||||
if (!values.length) {
|
||||
return Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
return mode === 'max' ? Math.max(...values) : Math.min(...values)
|
||||
},
|
||||
sortAreaSections(areas = []) {
|
||||
const list = [...areas]
|
||||
if (this.selectedSortKey === 'price-asc') {
|
||||
return list.sort((a, b) => this.getAreaSortPrice(a, 'min') - this.getAreaSortPrice(b, 'min'))
|
||||
}
|
||||
if (this.selectedSortKey === 'price-desc') {
|
||||
return list.sort((a, b) => this.getAreaSortPrice(b, 'max') - this.getAreaSortPrice(a, 'max'))
|
||||
}
|
||||
if (this.selectedSortKey === 'name-asc') {
|
||||
return list.sort((a, b) => String(this.getLocalizedAreaText(a) || '').localeCompare(String(this.getLocalizedAreaText(b) || ''), this.currentLang === 'en' ? 'en' : 'zh-Hans-CN'))
|
||||
}
|
||||
return list
|
||||
},
|
||||
openServiceEntry({ path, title, desc, key, payload }) {
|
||||
uni.navigateTo({
|
||||
url: `${path}?title=${encodeURIComponent(title)}&desc=${encodeURIComponent(desc)}&key=${encodeURIComponent(key)}&payload=${encodeURIComponent(JSON.stringify(payload || {}))}`
|
||||
})
|
||||
},
|
||||
handleBannerChange(index) {
|
||||
this.currentBannerIndex = index
|
||||
},
|
||||
switchBanner(index) {
|
||||
this.currentBannerIndex = index
|
||||
},
|
||||
collapseToTopBanner() {
|
||||
this.currentScreen = 1
|
||||
},
|
||||
collapseToHero() {
|
||||
this.currentScreen = 0
|
||||
},
|
||||
handleScreenChange(e) {
|
||||
this.currentScreen = e.detail.current
|
||||
this.isHeroCollapsed = this.currentScreen === 1
|
||||
this.navBarBg = this.isHeroCollapsed ? 'rgba(17, 17, 17, 0.92)' : 'transparent'
|
||||
},
|
||||
retryLoadBanner() {
|
||||
this.loadBannerList()
|
||||
},
|
||||
openRoomTypeEntry(area, roomType) {
|
||||
uni.navigateTo({
|
||||
url: `/pkgExplore/room_detail/room_detail?roomTypeData=${encodeURIComponent(JSON.stringify(roomType))}&areaData=${encodeURIComponent(JSON.stringify({
|
||||
code: area.code,
|
||||
name: area.name,
|
||||
nameEn: area.nameEn || '',
|
||||
address: area.address
|
||||
,
|
||||
addressEn: area.addressEn || ''
|
||||
}))}`
|
||||
})
|
||||
},
|
||||
handleConsult() {
|
||||
// 从 areaList 中匹配当前 banner 对应的小区信息
|
||||
const resolvedAreaCode = this.resolveBannerAreaCode()
|
||||
const area = this.areaList.find(item => item.code === resolvedAreaCode)
|
||||
|
||||
if (area) {
|
||||
uni.navigateTo({
|
||||
url: `/pkgExplore/store_detail/store_detail?storeData=${encodeURIComponent(JSON.stringify(area))}`
|
||||
})
|
||||
} else {
|
||||
// 容错处理
|
||||
uni.showToast({
|
||||
title: this.t('areaUnavailableTitle'),
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
},
|
||||
handleSubmit() {
|
||||
const bookingUrl = `/pkgBooking/book_viewing/book_viewing?payload=${encodeURIComponent(JSON.stringify({
|
||||
sourceType: 'banner',
|
||||
bannerTitle: this.getLocalizedBannerText(this.currentBanner, 'title'),
|
||||
bannerTitleEn: this.currentBanner.titleEn || '',
|
||||
bannerSubtitle: this.getLocalizedBannerText(this.currentBanner, 'subtitle'),
|
||||
bannerSubtitleEn: this.currentBanner.subtitleEn || '',
|
||||
bannerDesc: this.getLocalizedBannerText(this.currentBanner, 'desc'),
|
||||
bannerDescEn: this.currentBanner.descEn || '',
|
||||
areaLabel: this.currentAreaLabel,
|
||||
areaLabelEn: this.currentLang === 'en'
|
||||
? this.currentAreaLabel
|
||||
: ((this.areaList.find((item) => item.code === this.resolveBannerAreaCode()) || {}).nameEn || this.currentBanner.areaLabelEn || ''),
|
||||
areaCode: this.resolveBannerAreaCode()
|
||||
}))}`
|
||||
if (!ensureBookingAccess({
|
||||
language: this.currentLang,
|
||||
userInfo: this.userInfo,
|
||||
loginRedirectUrl: bookingUrl
|
||||
})) {
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: bookingUrl
|
||||
})
|
||||
}
|
||||
}
|
||||
,
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: this.t('shareTitle'),
|
||||
path: '/pages/index/index'
|
||||
}
|
||||
},
|
||||
onShareTimeline() {
|
||||
return {
|
||||
title: this.t('shareTitle')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.select-room-page {
|
||||
height: 100vh;
|
||||
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-swiper {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.panel-scroll {
|
||||
height: calc(100vh - env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
|
||||
}
|
||||
|
||||
/* 隐藏整个页面的滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-nav {
|
||||
width: 100%;
|
||||
padding: 0 40rpx;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.lang-text-toggle {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.lang-text {
|
||||
transition: all 0.3s ease;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
.lang-text--active {
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 0 12rpx rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.lang-split {
|
||||
margin: 0 12rpx;
|
||||
opacity: 0.3;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
</style>
|
||||
2227
pages/index/component/StayServiceTab.vue
Normal file
2227
pages/index/component/StayServiceTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
828
pages/index/index.vue
Normal file
828
pages/index/index.vue
Normal file
@@ -0,0 +1,828 @@
|
||||
<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>
|
||||
|
||||
<!-- 二级页面 -->
|
||||
<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 { checkRecordByChecked, notificationPage, applicationFormMePage } from '@/api/index.js'
|
||||
import { countPendingInspectionRecords, extractInspectionList, resolveNotificationUnreadTotal } from '../../utils/message-helper'
|
||||
import { syncLoginStayState } from '../../utils/session-sync'
|
||||
|
||||
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,
|
||||
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,
|
||||
i18n: {
|
||||
zh: {
|
||||
loadingBrand: '绿居公寓',
|
||||
shareTitle: '绿居公寓 - 国际化青年社区',
|
||||
tabSelectRoom: '选 房',
|
||||
tabStayService: '入 住',
|
||||
tabMyApplication: '申请单',
|
||||
tabMessageCenter: '消 息',
|
||||
tabProfileCenter: '我 的'
|
||||
},
|
||||
en: {
|
||||
loadingBrand: 'Luju Apartments',
|
||||
shareTitle: 'Luju Apartments - International Youth Community',
|
||||
tabSelectRoom: 'Explore',
|
||||
tabStayService: 'Stay',
|
||||
tabMyApplication: 'Applications',
|
||||
tabMessageCenter: 'Messages',
|
||||
tabProfileCenter: 'Profile'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
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) {
|
||||
tabs.push('my-application')
|
||||
} else {
|
||||
tabs.push('select-room')
|
||||
}
|
||||
if (this.sessionState.hasPendingApplication && !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()
|
||||
},
|
||||
onLoad(options) {
|
||||
clearLegacyDemoCache()
|
||||
uni.$on('messageBadgeCountChange', this.handleMessageBadgeCountChange)
|
||||
this.handlePageLoad(options)
|
||||
},
|
||||
onUnload() {
|
||||
uni.$off('messageBadgeCountChange', this.handleMessageBadgeCountChange)
|
||||
},
|
||||
onReady() {
|
||||
this.$nextTick(() => {
|
||||
this.getTabbarItemInfo()
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
t(key) {
|
||||
return this.$tm('pages.index.index', key)
|
||||
},
|
||||
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)
|
||||
})
|
||||
},
|
||||
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(
|
||||
applicationFormMePage({ Status: 0, PageIndex: 1, PageSize: 1 })
|
||||
.then(res => {
|
||||
const payload = res && res.data ? res.data : res
|
||||
const total = payload && payload.total ? Number(payload.total) : (payload && payload.data && payload.data.length ? payload.data.length : 0)
|
||||
this.sessionState.hasPendingApplication = total > 0
|
||||
})
|
||||
.catch(() => {
|
||||
this.sessionState.hasPendingApplication = false
|
||||
})
|
||||
)
|
||||
} else {
|
||||
this.sessionState.hasPendingApplication = 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,
|
||||
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
|
||||
}
|
||||
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()
|
||||
})
|
||||
},
|
||||
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;
|
||||
}
|
||||
|
||||
.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>
|
||||
Reference in New Issue
Block a user