Files
QSY-lvjuxcx/pkgExplore/store_detail/store_detail.vue
liangjian 1111ac148b feat: 重构核心租住流程页面与交互体验
统一注册、看房、申请与入住等核心页面的视觉和流程衔接,补齐支付、签约与状态流转闭环,提升移动端操作连贯性。
2026-07-29 09:05:38 +08:00

642 lines
18 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">
<!-- 顶部导航栏:滚动前透明/白色元素,滚动后白底深海蓝标题 -->
<tn-nav-bar
:isBack="true"
:bottomShadow="false"
:backgroundColor="navBarBackgroundColor"
:customBackStyle="navBarBackStyle"
:backIconColor="navBarIconColor"
:fixed="true"
class="glass-nav-bar"
>
<view class="nav-title" :style="{ opacity: navTitleOpacity, color: navTitleColor }">{{ 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">
<!-- Hero 大图区 -->
<view class="hero-panel">
<swiper
v-if="storeGalleryList.length"
class="hero-swiper"
circular
autoplay
:interval="5000"
:duration="1000"
@change="onHeroSwiperChange"
>
<swiper-item v-for="(image, index) in storeGalleryList" :key="`${image}-${index}`">
<image class="hero-image" :src="image" mode="aspectFill"></image>
</swiper-item>
</swiper>
<view v-else class="hero-empty tn-flex tn-flex-col-center tn-flex-row-center">{{ $tr('暂无图片') }}</view>
<view v-if="storeGalleryList.length > 1" class="hero-counter">
<text class="tn-icon-image counter-icon"></text>
<text>{{ currentImageIndex + 1 }} / {{ storeGalleryList.length }}</text>
</view>
</view>
<!-- 店名主卡片 -->
<view class="store-title-card">
<view class="store-title">{{ displayStoreName }}</view>
<view class="title-divider"></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 class="content-body">
<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--secondary" open-type="share">
<text class="tn-icon-share action-btn__icon"></text>
<text>{{ $tr('分享') }}</text>
</button>
<button class="action-btn action-btn--primary" @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('电话咨询')
},
navBarBackgroundColor() {
const opacity = Math.min(Math.max((this.scrollTop - 20) / 80, 0), 1);
return `rgba(255, 255, 255, ${opacity})`;
},
navBarBackStyle() {
const isTop = this.scrollTop < 60;
return {
transition: 'all 0.3s',
background: isTop ? 'rgba(255, 255, 255, 0.92)' : '#FFFFFF',
backdropFilter: 'blur(12rpx)',
WebkitBackdropFilter: 'blur(12rpx)',
borderRadius: '50%',
width: '68rpx',
height: '68rpx',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: '12rpx',
boxShadow: isTop
? '0 8rpx 24rpx rgba(30, 58, 95, 0.12), inset 0 1rpx 0 rgba(255, 255, 255, 0.6)'
: '0 8rpx 20rpx rgba(15, 23, 42, 0.08)'
};
},
navBarIconColor() {
return '#1E3A5F';
},
navTitleColor() {
return '#1E3A5F';
},
navTitleOpacity() {
return Math.min(Math.max((this.scrollTop - 60) / 80, 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">
/* 品牌色变量本地化(深海蓝 + 香槟金) */
$navy-deep: #1E3A5F;
$navy-light: #2E4A73;
$gold: #C9A96E;
$gold-light: #D4BC8C;
$bg-blue: #F0F5FF;
$bg-page: #F8FAFF;
$text-primary: #0F172A;
$text-secondary: #64748B;
$text-tertiary: #94A3B8;
.store-detail-page {
height: 100vh;
background: linear-gradient(180deg, $bg-blue 0%, $bg-page 25%, #FFFFFF 100%);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
.nav-title {
font-size: 34rpx;
font-weight: 700;
transition: opacity 0.3s;
max-width: 420rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.glass-nav-bar ::v-deep .tn-nav-bar {
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
box-shadow: none !important;
border: none !important;
}
/* 可滚动内容区 */
.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%;
padding-top: calc(100rpx + env(safe-area-inset-top));
}
/* Hero 大图区 */
.hero-panel {
position: relative;
height: 460rpx;
margin: 0 32rpx;
border-radius: 32rpx;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(30, 58, 95, 0.16);
background: linear-gradient(135deg, #E2E8F0 0%, #CBD5E1 100%);
}
.hero-swiper,
.hero-image,
.hero-empty {
width: 100%;
height: 100%;
}
.hero-empty {
color: $text-tertiary;
font-size: 28rpx;
}
.hero-counter {
position: absolute;
right: 24rpx;
bottom: 24rpx;
height: 52rpx;
padding: 0 20rpx;
border-radius: 999rpx;
background: rgba($navy-deep, 0.85);
backdrop-filter: blur(14px);
color: $gold-light;
font-size: 24rpx;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(30, 58, 95, 0.2);
}
.counter-icon {
margin-right: 8rpx;
font-size: 26rpx;
}
/* 店名主卡片 */
.store-title-card {
position: relative;
margin: -60rpx 32rpx 0;
padding: 40rpx;
background: #FFFFFF;
border-radius: 32rpx;
box-shadow: 0 12rpx 40rpx rgba(30, 58, 95, 0.08);
z-index: 20;
}
.store-title {
font-size: 48rpx;
font-weight: 800;
color: $navy-deep;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.title-divider {
width: 48rpx;
height: 4rpx;
margin: 24rpx 0;
background: linear-gradient(90deg, $gold 0%, $gold-light 100%);
border-radius: 2rpx;
}
.store-tags {
gap: 16rpx;
}
.tag-item {
font-size: 24rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba($navy-deep, 0.06);
border: 1rpx solid rgba($navy-deep, 0.12);
color: $navy-deep;
font-weight: 500;
}
/* 信息流 */
.content-body {
padding: 24rpx 0 0;
}
/* 底部操作栏 */
.action-bar-wrap {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 18rpx 32rpx calc(18rpx + env(safe-area-inset-bottom));
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
box-shadow: 0 -8rpx 30rpx rgba(30, 58, 95, 0.06);
z-index: 100;
}
.action-bar {
display: flex;
gap: 24rpx;
}
.action-btn {
height: 96rpx;
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 700;
padding: 0;
line-height: 1;
transition: all 0.2s;
}
button.action-btn::after {
border: none;
}
.action-btn:active {
transform: scale(0.97);
}
.action-btn__icon {
margin-right: 12rpx;
font-size: 34rpx;
}
.action-btn--secondary {
flex: 0 0 200rpx;
background: rgba($navy-deep, 0.06);
color: $navy-deep;
border: 1rpx solid rgba($navy-deep, 0.12);
}
.action-btn--primary {
flex: 1;
background: linear-gradient(135deg, $navy-light 0%, $navy-deep 100%);
color: #FFFFFF;
box-shadow: 0 12rpx 32rpx rgba($navy-deep, 0.25);
}
.action-btn--primary .action-btn__icon {
color: $gold-light;
}
.bottom-spacer {
height: calc(160rpx + env(safe-area-inset-bottom));
}
</style>