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