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

837 lines
20 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<template>
<view class="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">{{ $tr('搜索区域、公寓或房型') }}</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">{{ $tr('标签筛选') }}</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', '')"
>
{{ $tr('全部') }}
</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 ? $tr('区域加载中') : $tr('区域状态') }}
</view>
<view class="room-status-card__desc">
{{ roomLoadError || areaLoadError || $tr('暂无符合筛选条件的区域') }}
</view>
</view>
<view v-if="!isAreaLoading && !displayAreaSections.length" class="empty-state-card tn-margin-bottom-lg">
<view class="empty-state-card__title">{{ bannerList.length ? $tr('暂无匹配房源') : $tr('暂无轮播图') }}</view>
<view class="empty-state-card__desc">
{{ bannerList.length ? $tr('当前筛选条件下暂无可用房源') : $tr('暂无可展示的轮播图') }}
</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">{{ $tr('查看详情') }}</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">{{ $tr('附近') }}</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) || $tr('待定') }}</text>
<text class="price-unit">{{ $tr('元/月起') }}</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
},
isActive: {
type: Boolean,
default: false
},
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)
},
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.$tr('全部标签'), ...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.$tr('默认排序'), value: 'default' },
{ label: this.$tr('价格从低到高'), value: 'price-asc' },
{ label: this.$tr('价格从高到低'), value: 'price-desc' },
{ label: this.$tr('名称排序'), 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) {
if (!this.isActive) {
return
}
const nextIndex = Number(e && e.detail && e.detail.current)
if (!Number.isFinite(nextIndex)) {
return
}
this.$emit('banner-change', nextIndex)
},
openStoreDetail(area) {
uni.navigateTo({
url: `/pkgExplore/store_detail/store_detail?storeData=${encodeURIComponent(JSON.stringify(area))}`
})
},
defaultAreaAddress(areaName = '') {
if (this.language === 'en') {
return `${this.$tr('位于')}${areaName} CBD`
}
return `${this.$tr('位于')}${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>