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

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>