chore: initial import

This commit is contained in:
liangjian
2026-06-29 09:42:53 +08:00
commit 9fea127de1
241 changed files with 82767 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

828
pages/index/index.vue Normal file
View 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>