Files
QSY-lvjuxcx/pkgExplore/store_detail/store_detail.vue
liangjian 6e721ccc7e refactor: 国际化文本迁移至全局 locales/messages.js
将组件级 i18n 对象重构为统一由全局 locales/messages.js 管理,确保中英文语言切换时正确显示对应文本。

- 新增 utils/i18n.js 国际化工具函数
- 更新 store/$t.mixin.js 翻译混入逻辑
- 迁移 pkgService、pkgBooking、pkgAccount、pkgExplore、pages 等共 58 个组件的中英文文本至全局消息表
2026-07-24 13:43:41 +08:00

611 lines
17 KiB
Vue
Raw 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="store-detail-page">
<!-- 底层:全屏轮播背景 -->
<view class="fullscreen-bg">
<swiper
v-if="storeGalleryList.length"
class="bg-swiper"
circular
autoplay
:interval="5000"
:duration="1000"
@change="onHeroSwiperChange"
>
<swiper-item v-for="(image, index) in storeGalleryList" :key="`${image}-${index}`">
<image class="bg-image" :src="image" mode="aspectFill"></image>
</swiper-item>
</swiper>
<view v-else class="bg-empty tn-flex tn-flex-col-center tn-flex-row-center">{{ $tr('暂无图片') }}</view>
<!-- 渐变暗化遮罩,确保上层文字卡片清晰 -->
<view class="bg-mask"></view>
</view>
<!-- 顶部导航栏 -->
<tn-nav-bar
:isBack="true"
:bottomShadow="false"
backgroundColor="transparent"
:customBackStyle="navBarBackStyle"
backIconColor="#FFFFFF"
:fixed="true"
class="transparent-nav-bar"
>
<view class="nav-title" :style="{ opacity: navTitleOpacity }">{{ displayStoreName }}</view>
</tn-nav-bar>
<!-- 上层:透明可滚动内容区 -->
<scroll-view
class="content-scroll"
scroll-y
:show-scrollbar="false"
@scroll="handleScroll"
enable-back-to-top
>
<view class="scroll-inner">
<!-- 留出足够的顶部空间,让背景图展示出来 -->
<view class="hero-space tn-flex tn-flex-direction-column tn-flex-col-bottom">
<!-- 照片计数器移到店名上方,避免与导航栏重叠 -->
<view v-if="storeGalleryList.length > 1" class="hero-counter tn-flex tn-flex-col-center">
<text class="tn-icon-image counter-icon"></text>
<text>{{ currentImageIndex + 1 }} / {{ storeGalleryList.length }}</text>
</view>
<view class="store-title-wrap">
<view class="store-title">{{ displayStoreName }}</view>
<view v-if="storeTags.length" class="store-tags tn-flex tn-flex-wrap">
<view class="tag-item" v-for="(tag, index) in storeTags" :key="index">{{ tag }}</view>
</view>
</view>
</view>
<!-- 悬浮毛玻璃信息流 -->
<view class="floating-content-wrap">
<store-info
:storeData="storeData"
:language="language"
@open-map="openMap"
/>
<facility-list
:facilityList="facilityList"
:language="language"
/>
<room-list
v-if="roomTypes.length"
:roomTypes="roomTypes"
:language="language"
@go-detail="goToRoomDetail"
/>
<view class="bottom-spacer"></view>
</view>
</view>
</scroll-view>
<!-- 悬浮操作栏 (固定在底部,透明风格) -->
<view class="action-bar-wrap tn-safe-area-inset-bottom">
<view class="action-bar">
<button class="action-btn action-btn--glass" open-type="share">
<text class="tn-icon-share action-btn__icon"></text>
<text>{{ $tr('分享') }}</text>
</button>
<button class="action-btn action-btn--primary-glass" @click="handleCall">
<text class="tn-icon-phone action-btn__icon"></text>
<text>{{ contactButtonText }}</text>
</button>
</view>
</view>
</view>
</template>
<script>
import StoreInfo from './components/StoreInfo.vue';
import FacilityList from './components/FacilityList.vue';
import RoomList from './components/RoomList.vue';
import { facilityManagers } from '@/api/index.js'
import { localizeFacilityName, localizeHousingTag } from '../../utils/select-room-normalizers'
function parseCoordinate(...values) {
for (let i = 0; i < values.length; i += 1) {
const value = values[i]
if (value === '' || value === null || typeof value === 'undefined') {
continue
}
const parsed = Number(value)
if (Number.isFinite(parsed)) {
return parsed
}
}
return null
}
export default {
components: {
StoreInfo,
FacilityList,
RoomList
},
data() {
return {
storeData: {},
roomTypes: [],
areaManagers: [],
areaManager: null,
isAreaManagerLoading: false,
scrollTop: 0,
currentImageIndex: 0
}
},
computed: {
language() {
return this.$getLanguage()
},
storeGalleryList() {
if (Array.isArray(this.storeData.images) && this.storeData.images.length) {
return this.storeData.images.filter(Boolean)
}
if (this.heroImage) {
return [this.heroImage]
}
return []
},
heroImage() {
if (this.storeData.images && this.storeData.images[0]) {
return this.storeData.images[0]
}
if (this.roomTypes.length > 0 && this.roomTypes[0].images && this.roomTypes[0].images[0]) {
return this.roomTypes[0].images[0]
}
return ''
},
storeTags() {
const tags = Array.isArray(this.storeData.tags) ? this.storeData.tags.filter(Boolean) : []
return tags.map((tag) => localizeHousingTag(tag, this.language))
},
displayStoreName() {
if (this.language === 'en') {
return this.storeData.nameEnDisplay || this.storeData.nameEn || this.storeData.name || this.$tr('公寓详情')
}
return this.storeData.name || this.storeData.nameEn || this.$tr('公寓详情')
},
displayStoreAddress() {
if (this.language === 'en') {
return this.storeData.addressEnDisplay || this.storeData.addressEn || this.storeData.address || ''
}
return this.storeData.address || this.storeData.addressEn || ''
},
facilityList() {
const rawList = this.storeData.facilities
if (!Array.isArray(rawList)) {
return []
}
return rawList
.map((item) => {
if (!item) {
return null
}
if (typeof item === 'string') {
return { name: item, icon: 'tn-icon-shop' }
}
return {
name: localizeFacilityName(item.name || item.label || '', this.language),
icon: item.icon || 'tn-icon-shop'
}
})
.filter((item) => item && item.name)
},
phoneNumber() {
return this.storeData.phone || this.storeData.tel || this.storeData.mobile || ''
},
areaManagerName() {
const source = this.areaManager || {}
return source.displayName || source.name || source.userName || source.nickname || ''
},
areaManagerOptions() {
if (!Array.isArray(this.areaManagers)) {
return []
}
return this.areaManagers.filter((item) => item && item.phone)
},
contactPhoneNumber() {
const source = this.areaManager || {}
return source.phone || source.mobile || source.tel || source.managerPhone || this.phoneNumber || ''
},
contactButtonText() {
if (this.isAreaManagerLoading) {
return this.$tr('加载中...')
}
if (this.areaManagerOptions.length > 1) {
return this.$tr('选择联系人')
}
if (this.areaManagerName) {
return this.$tr('联系管家')
}
return this.$tr('电话咨询')
},
navBarBackStyle() {
const isDarkBg = this.scrollTop < 100;
return {
transition: 'all 0.3s',
background: isDarkBg ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.15)',
backdropFilter: 'blur(10px)',
borderRadius: '50%',
width: '64rpx',
height: '64rpx',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: '12rpx',
border: '1rpx solid rgba(255, 255, 255, 0.25)'
};
},
navTitleOpacity() {
return Math.min(Math.max((this.scrollTop - 80) / 100, 0), 1);
}
},
onLoad(options) {
if (options.storeData) {
Promise.resolve(options.storeData)
.then(decodeURIComponent)
.then(JSON.parse)
.then((data) => {
this.applyStoreData(data)
})
.catch((e) => {
console.error(this.$tr('解析小区数据失败'), e);
})
return
}
},
onShareAppMessage() {
return {
title: this.displayStoreName || this.$tr('小区详情'),
path: `/pkgExplore/store_detail/store_detail?storeData=${encodeURIComponent(JSON.stringify(this.storeData || {}))}`,
imageUrl: this.heroImage || ''
}
},
onShareTimeline() {
return {
title: this.displayStoreName || this.$tr('小区详情'),
query: `storeData=${encodeURIComponent(JSON.stringify(this.storeData || {}))}`,
imageUrl: this.heroImage || ''
}
},
methods: {
applyStoreData(data) {
this.storeData = data && typeof data === 'object' ? data : {}
this.roomTypes = this.storeData.roomTypes || []
this.loadAreaManager()
},
loadAreaManager() {
const areaCode = this.storeData.code || this.storeData.areaCode || ''
if (!areaCode) {
this.areaManagers = []
this.areaManager = null
return Promise.resolve()
}
this.isAreaManagerLoading = true
return facilityManagers({
FacilityType: 10,
FacilityCode: areaCode
}, this.language)
.then((res) => {
const rawData = res && res.data && typeof res.data === 'object' ? res.data : res
const managerList = Array.isArray(rawData)
? rawData
: (Array.isArray(rawData && rawData.data)
? rawData.data
: (Array.isArray(rawData && rawData.managers) ? rawData.managers : []))
const normalizedManagers = managerList
.map((item) => this.normalizeManager(item))
.filter(Boolean)
this.areaManagers = normalizedManagers
this.areaManager = normalizedManagers.find((item) => item.phone) || normalizedManagers[0] || null
})
.catch((error) => {
console.warn('查询区域宿管失败', error)
this.areaManagers = []
this.areaManager = null
})
.finally(() => {
this.isAreaManagerLoading = false
})
},
normalizeManager(manager) {
if (!manager || typeof manager !== 'object') {
return null
}
const phoneSource = manager.phone || manager.mobile || manager.tel || manager.managerPhone || ''
const phone = typeof phoneSource === 'string' ? phoneSource.trim() : phoneSource
const userCode = manager.userCode || manager.code || ''
const name = manager.name || manager.userName || manager.nickname || userCode || ''
return {
...manager,
phone,
userCode,
displayName: name
}
},
getManagerActionText(manager) {
if (!manager) {
return this.$tr('电话咨询')
}
if (manager.displayName && manager.phone) {
return `${manager.displayName}(${manager.phone})`
}
return manager.displayName || manager.phone || this.$tr('电话咨询')
},
makePhoneCall(phoneNumber) {
if (!phoneNumber) {
uni.showToast({ title: this.$tr('暂无联系电话'), icon: 'none' })
return
}
uni.makePhoneCall({
phoneNumber
});
},
onHeroSwiperChange(e) {
this.currentImageIndex = e.detail.current;
},
handleScroll(e) {
this.scrollTop = e.detail.scrollTop;
},
openMap() {
const latitude = parseCoordinate(this.storeData.latitude, this.storeData.lat)
const longitude = parseCoordinate(this.storeData.longitude, this.storeData.lng)
if (latitude === null || longitude === null) {
uni.showToast({
title: this.$tr('暂无位置信息'),
icon: 'none'
})
return
}
uni.openLocation({
latitude,
longitude,
name: this.displayStoreName || this.$tr('公寓详情'),
address: this.displayStoreAddress || '',
scale: 18
});
},
goToRoomDetail(room) {
uni.navigateTo({
url: `/pkgExplore/room_detail/room_detail?roomTypeData=${encodeURIComponent(JSON.stringify(room))}&areaData=${encodeURIComponent(JSON.stringify({
code: this.storeData.code,
name: this.storeData.name,
nameEn: this.storeData.nameEn || this.storeData.name,
nameEnDisplay: this.storeData.nameEnDisplay || this.storeData.nameEn || this.storeData.name,
address: this.storeData.address,
addressEn: this.storeData.addressEn || this.storeData.address,
addressEnDisplay: this.storeData.addressEnDisplay || this.storeData.addressEn || this.storeData.address,
latitude: this.storeData.latitude || this.storeData.lat || '',
longitude: this.storeData.longitude || this.storeData.lng || ''
}))}`
});
},
handleCall() {
if (this.isAreaManagerLoading && !this.contactPhoneNumber) {
uni.showToast({ title: this.$tr('正在加载管家信息'), icon: 'none' })
return
}
const managerOptions = this.areaManagerOptions
if (managerOptions.length > 1) {
uni.showActionSheet({
itemList: managerOptions.map((item) => this.getManagerActionText(item)),
success: ({ tapIndex }) => {
const selectedManager = managerOptions[tapIndex]
if (selectedManager && selectedManager.phone) {
this.makePhoneCall(selectedManager.phone)
}
}
})
return
}
this.makePhoneCall(this.contactPhoneNumber)
}
}
}
</script>
<style scoped lang="scss">
.store-detail-page {
height: 100vh;
background-color: #000; /* 全屏黑色底色,防止滑动露白 */
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.nav-title {
font-size: 34rpx;
font-weight: 700;
color: #fff;
transition: opacity 0.3s;
max-width: 420rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.transparent-nav-bar ::v-deep .tn-nav-bar {
background: transparent !important;
box-shadow: none !important;
border: none !important;
}
/* 底层背景 */
.fullscreen-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.bg-swiper, .bg-image, .bg-empty {
width: 100%;
height: 100%;
}
.bg-empty {
background: #111;
color: rgba(255, 255, 255, 0.4);
}
.bg-mask {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.4) 60%, rgba(0,0,0,0.85) 100%);
pointer-events: none;
}
/* 上层滚动内容区 */
.content-scroll {
position: relative;
z-index: 10;
width: 100%;
height: 100%;
}
.content-scroll ::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
color: transparent;
}
.scroll-inner {
min-height: 101%;
}
/* 留出背景展示区 */
.hero-space {
width: 100%;
height: 35vh; /* 进一步降低留白高度,确保底部房型列表至少透出一条数据 */
padding: 0 32rpx 40rpx;
justify-content: flex-end; /* 内容沉底 */
}
.hero-counter {
height: 52rpx;
padding: 0 20rpx;
border-radius: 999rpx;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(14px);
color: #ffffff;
font-size: 24rpx;
font-weight: 500;
border: 1rpx solid rgba(255, 255, 255, 0.2);
margin-bottom: 24rpx;
align-self: flex-end; /* 靠右对齐 */
}
.counter-icon {
margin-right: 8rpx;
font-size: 26rpx;
}
.store-title-wrap {
width: 100%;
color: #fff;
}
.store-title {
font-size: 64rpx;
font-weight: bold;
margin-bottom: 24rpx;
text-shadow: 0 4rpx 16rpx rgba(0,0,0,0.6);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.store-tags {
gap: 16rpx;
}
.tag-item {
font-size: 24rpx;
padding: 8rpx 20rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border: 1rpx solid rgba(255, 255, 255, 0.2);
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.3);
}
/* 悬浮毛玻璃信息流 */
.floating-content-wrap {
position: relative;
padding-bottom: 180rpx;
}
/* 底部操作栏 - 毛玻璃悬浮 */
.action-bar-wrap {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 18rpx 32rpx calc(18rpx + env(safe-area-inset-bottom));
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 100%);
z-index: 100;
pointer-events: none; /* 让渐变层不阻挡点击 */
}
.action-bar {
display: flex;
gap: 24rpx;
pointer-events: auto; /* 恢复按钮区域点击 */
}
.action-btn {
height: 96rpx;
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: bold;
padding: 0;
border: 1rpx solid rgba(255, 255, 255, 0.3);
line-height: 1;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
button.action-btn::after {
border: none;
}
.action-btn__icon {
margin-right: 12rpx;
font-size: 34rpx;
}
.action-btn--glass {
flex: 0 0 200rpx;
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.action-btn--primary-glass {
flex: 1;
background: rgba($brand-primary, 0.85);
color: #fff;
border-color: rgba($brand-primary, 0.5);
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.3);
}
.bottom-spacer {
height: calc(160rpx + env(safe-area-inset-bottom));
}
</style>