1494 lines
50 KiB
Vue
1494 lines
50 KiB
Vue
<template>
|
||
<view class="room-detail-page tn-safe-area-inset-bottom">
|
||
<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 }">{{ displayName || t('navDetail') }}</view>
|
||
</tn-nav-bar>
|
||
|
||
<swiper
|
||
class="fullscreen-swiper"
|
||
vertical
|
||
:current="currentSwiperIndex"
|
||
@change="onSwiperChange"
|
||
>
|
||
<!-- 第一屏:沉浸式封面 -->
|
||
<swiper-item>
|
||
<room-hero
|
||
:detailData="detailData"
|
||
:galleryList="galleryList"
|
||
:hasVR="hasVR"
|
||
:showVrPanel="showVrPanel"
|
||
:vrPoster="vrPoster"
|
||
:showEnglishName="showEnglishName"
|
||
:currentMediaType="currentMediaType"
|
||
:language="language"
|
||
@swiper-change="onImageSwiperChange"
|
||
@open-vr="openVR"
|
||
@switch-media="switchMedia"
|
||
/>
|
||
</swiper-item>
|
||
|
||
<!-- 第二屏:长内容详情 -->
|
||
<swiper-item>
|
||
<scroll-view
|
||
class="detail-scroll"
|
||
scroll-y
|
||
:show-scrollbar="false"
|
||
@scroll="handleScroll"
|
||
enable-back-to-top
|
||
>
|
||
<view class="scroll-content">
|
||
<view class="second-screen-shell">
|
||
<view class="second-screen-top-gap"></view>
|
||
<view class="compact-hero-panel">
|
||
<view v-if="showVrPanel" class="compact-vr-panel" @click="openVR">
|
||
<image v-if="vrPoster" :src="vrPoster" mode="aspectFill" class="compact-hero-image"></image>
|
||
<view v-else class="compact-hero-empty tn-flex tn-flex-col-center tn-flex-row-center">{{ t('noImage') }}</view>
|
||
<view class="compact-vr-glow"></view>
|
||
</view>
|
||
<swiper
|
||
v-else-if="galleryList.length"
|
||
class="compact-hero-swiper"
|
||
circular
|
||
autoplay
|
||
:interval="4200"
|
||
:duration="700"
|
||
>
|
||
<swiper-item v-for="(image, index) in galleryList" :key="`compact-${image}-${index}`">
|
||
<image class="compact-hero-image" :src="image" mode="aspectFill"></image>
|
||
</swiper-item>
|
||
</swiper>
|
||
<view v-else class="compact-hero-empty tn-flex tn-flex-col-center tn-flex-row-center">{{ t('noImage') }}</view>
|
||
<view class="compact-hero-mask"></view>
|
||
<view class="compact-hero-topbar tn-flex tn-flex-row-between tn-flex-col-center">
|
||
<view v-if="hasVR" class="compact-media-tabs">
|
||
<view
|
||
class="compact-media-tab"
|
||
:class="{ 'compact-media-tab--active': currentMediaType === 'image' }"
|
||
@click="switchMedia('image')"
|
||
>
|
||
{{ t('imageTab') }}
|
||
</view>
|
||
<view
|
||
class="compact-media-tab"
|
||
:class="{ 'compact-media-tab--active': currentMediaType === 'vr' }"
|
||
@click="switchMedia('vr')"
|
||
>
|
||
{{ t('vrTab') }}
|
||
</view>
|
||
</view>
|
||
<view v-if="galleryList.length" class="compact-hero-counter">{{ t('imageCount', { count: galleryList.length }) }}</view>
|
||
</view>
|
||
<view class="compact-hero-action-layer">
|
||
<view v-if="showVrPanel" class="compact-hero-center-action" @click="openVR">
|
||
<view class="compact-hero-ring compact-hero-ring--outer"></view>
|
||
<view class="compact-hero-ring compact-hero-ring--inner"></view>
|
||
<view class="compact-hero-core">
|
||
<text class="tn-icon-vr compact-hero-core-icon"></text>
|
||
</view>
|
||
<view class="compact-hero-action-text">
|
||
<view class="compact-hero-action-title">{{ t('vrPreview') }}</view>
|
||
<view class="compact-hero-action-desc">{{ t('vrPreviewDesc') }}</view>
|
||
</view>
|
||
</view>
|
||
<view v-else class="compact-hero-center-action" @click="previewImage">
|
||
<view class="compact-hero-ring compact-hero-ring--outer"></view>
|
||
<view class="compact-hero-ring compact-hero-ring--inner"></view>
|
||
<view class="compact-hero-core">
|
||
<text class="tn-icon-image compact-hero-core-icon"></text>
|
||
</view>
|
||
<view class="compact-hero-action-text">
|
||
<view class="compact-hero-action-title">{{ t('viewLargeImage') }}</view>
|
||
<view class="compact-hero-action-desc">{{ t('viewLargeImageDesc') }}</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="second-screen-body">
|
||
<room-summary
|
||
:detailData="detailData"
|
||
:hasPrice="hasPrice"
|
||
:summaryBadges="summaryBadges"
|
||
:language="language"
|
||
@open-map="openMap"
|
||
/>
|
||
|
||
<room-info
|
||
:basicInfoRows="basicInfoRows"
|
||
:displayDescriptions="displayDescriptions"
|
||
:isRoomType="isRoomType"
|
||
:shouldShowDescToggle="shouldShowDescToggle"
|
||
:language="language"
|
||
>
|
||
<room-assets
|
||
v-if="assetList.length"
|
||
:assetList="assetList"
|
||
:language="language"
|
||
/>
|
||
</room-info>
|
||
|
||
<view class="bottom-spacer"></view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</scroll-view>
|
||
</swiper-item>
|
||
</swiper>
|
||
|
||
<view class="action-bar-wrap tn-safe-area-inset-bottom" :class="{ 'action-bar--visible': currentSwiperIndex === 1 }">
|
||
<view class="action-bar tn-flex tn-flex-row-between tn-flex-col-center">
|
||
<view class="action-left tn-flex tn-flex-col-center">
|
||
<button class="action-icon-btn" open-type="share">
|
||
<text class="tn-icon-share-circle action-icon-font"></text>
|
||
<text class="action-icon-text">{{ t('share') }}</text>
|
||
</button>
|
||
</view>
|
||
<view class="action-right tn-flex tn-flex-col-center">
|
||
<view class="action-btn action-btn--secondary" @click="handleBooking">
|
||
<text>{{ primaryActionText }}</text>
|
||
</view>
|
||
<view
|
||
v-if="isRoomType"
|
||
class="action-btn action-btn--primary"
|
||
@click="handleApplyStay"
|
||
>
|
||
<text>{{ t('applyStay') }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import { ensureBookingAccess } from '../../utils/booking-guard'
|
||
import { roomDetail, roomTypeDetail } from '@/api/index.js'
|
||
import { compactEnglishText, localizePayMethodText, normalizeImageList } from '../../utils/select-room-normalizers'
|
||
import { addFilePrefix } from '../../utils/common'
|
||
import { normalizeRichTextImages } from '../../utils/rich-text'
|
||
import { getCurrentLanguage } from '@/utils/i18n'
|
||
import { getSessionSnapshot } from '@/utils/app-state'
|
||
|
||
import RoomHero from './components/RoomHero.vue'
|
||
import RoomSummary from './components/RoomSummary.vue'
|
||
import RoomInfo from './components/RoomInfo.vue'
|
||
import RoomAssets from './components/RoomAssets.vue'
|
||
|
||
function normalizeText(value) {
|
||
if (typeof value !== 'string') {
|
||
return value || ''
|
||
}
|
||
return value.replace(/[\r\n]+/g, ' ').trim()
|
||
}
|
||
|
||
function normalizeRichText(value) {
|
||
if (typeof value !== 'string') {
|
||
return value || ''
|
||
}
|
||
let html = value.trim()
|
||
|
||
// 如果是纯文本(不包含常见的 html 标签),则将换行符替换为 <br>
|
||
if (!/<\/?(p|div|img|br|span|h[1-6]|ul|li|ol|table|td|tr)[^>]*>/i.test(html)) {
|
||
html = html.replace(/[\r\n]+/g, '<br/>')
|
||
}
|
||
|
||
html = normalizeRichTextImages(
|
||
html,
|
||
'display:block;width:100%;max-width:100%;height:auto;box-sizing:border-box;border-radius:12px;margin:8px 0;vertical-align:top;'
|
||
)
|
||
html = html.replace(/<p/gi, '<p style="line-height:1.8;color:#4E5969;margin-bottom:12px;"')
|
||
return html
|
||
}
|
||
|
||
function stripHtmlTags(html) {
|
||
if (!html) return ''
|
||
return html.replace(/<[^>]+>/g, '')
|
||
}
|
||
|
||
function normalizeAssetIconClass(icon) {
|
||
const rawIcon = String(icon || '').trim()
|
||
if (!rawIcon) {
|
||
return ''
|
||
}
|
||
if (/^ri-[a-z0-9-]+$/i.test(rawIcon)) {
|
||
return rawIcon
|
||
}
|
||
if (
|
||
/^[a-z0-9-]+$/i.test(rawIcon) &&
|
||
!/[\\/]/.test(rawIcon) &&
|
||
!/\.[a-z0-9]+$/i.test(rawIcon)
|
||
) {
|
||
return `ri-${rawIcon}`
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function resolveNumericValue(...values) {
|
||
for (let i = 0; i < values.length; i += 1) {
|
||
const value = values[i]
|
||
if (value === '' || value === null || typeof value === 'undefined') {
|
||
continue
|
||
}
|
||
if (typeof value === 'number') {
|
||
return value
|
||
}
|
||
const matched = String(value).match(/(\d+(\.\d+)?)/)
|
||
if (matched) {
|
||
return matched[1]
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
function parseEncodedJson(value, fallback = {}, errorMessage = 'parse encoded json error') {
|
||
return Promise.resolve(value)
|
||
.then(decodeURIComponent)
|
||
.then(JSON.parse)
|
||
.catch((error) => {
|
||
console.error(errorMessage, error)
|
||
return fallback
|
||
})
|
||
}
|
||
|
||
function buildNavigationUrl(path = '', query = {}) {
|
||
const queryString = Object.keys(query).reduce((result, key) => {
|
||
const value = query[key]
|
||
if (value === '' || value === null || typeof value === 'undefined') {
|
||
return result
|
||
}
|
||
result.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
||
return result
|
||
}, []).join('&')
|
||
return `${path}${queryString ? `?${queryString}` : ''}`
|
||
}
|
||
|
||
function normalizePricePlan(pricePlan = {}, fallback = {}) {
|
||
const source = pricePlan && typeof pricePlan === 'object' ? pricePlan : {}
|
||
const fallbackPlan = fallback && typeof fallback === 'object' ? fallback : {}
|
||
return {
|
||
finalPrice: resolveNumericValue(
|
||
source.finalPrice,
|
||
source.price,
|
||
fallbackPlan.finalPrice,
|
||
fallbackPlan.price
|
||
),
|
||
matchedStrategies: normalizeText(
|
||
source.matchedStrategies || source.payMethod || fallbackPlan.matchedStrategies || fallbackPlan.payMethod
|
||
)
|
||
}
|
||
}
|
||
|
||
function createInitialDetail() {
|
||
return {
|
||
roomCode: '',
|
||
roomTypeCode: '',
|
||
roomTypeName: '',
|
||
roomTypeNameEn: '',
|
||
roomTypeNameEnDisplay: '',
|
||
areaCode: '',
|
||
name: '',
|
||
nameEn: '',
|
||
nameEnDisplay: '',
|
||
description: '',
|
||
descriptionEn: '',
|
||
pricePlan: {
|
||
finalPrice: '',
|
||
matchedStrategies: ''
|
||
},
|
||
priceValue: '',
|
||
payMethod: '',
|
||
areaName: '',
|
||
areaNameEn: '',
|
||
areaNameEnDisplay: '',
|
||
address: '',
|
||
addressEn: '',
|
||
addressEnDisplay: '',
|
||
latitude: '',
|
||
longitude: '',
|
||
buildingName: '',
|
||
buildingNameEn: '',
|
||
buildingNameEnDisplay: '',
|
||
buildingSectionName: '',
|
||
buildingSectionNameEn: '',
|
||
buildingSectionNameEnDisplay: '',
|
||
floorName: '',
|
||
floorNameEn: '',
|
||
floorNameEnDisplay: '',
|
||
orientation: '',
|
||
orientationEn: '',
|
||
statusText: '',
|
||
image: '',
|
||
images: [],
|
||
vrMockEnabled: false,
|
||
needApproval: null,
|
||
maxMinutes: '',
|
||
bookRoomCountOnDay: '',
|
||
bookLimitTime: '',
|
||
isFunction: null,
|
||
assets: []
|
||
}
|
||
}
|
||
|
||
function getStatusText(status) {
|
||
const language = getCurrentLanguage()
|
||
const map = language === 'en'
|
||
? {
|
||
0: 'Unpublished',
|
||
10: 'Published',
|
||
11: 'Under Maintenance',
|
||
999: 'Disabled'
|
||
}
|
||
: {
|
||
0: '未上架',
|
||
10: '已上架',
|
||
11: '维护中',
|
||
999: '已禁用'
|
||
}
|
||
return map[status] || ''
|
||
}
|
||
|
||
function localizeKnownStatusText(text = '', language = 'zh') {
|
||
const normalized = String(text || '').trim()
|
||
if (!normalized) {
|
||
return ''
|
||
}
|
||
const map = {
|
||
'未上架': { zh: '未上架', en: 'Unpublished' },
|
||
'已上架': { zh: '已上架', en: 'Published' },
|
||
'维护中': { zh: '维护中', en: 'Under Maintenance' },
|
||
'已禁用': { zh: '已禁用', en: 'Disabled' },
|
||
'Unpublished': { zh: '未上架', en: 'Unpublished' },
|
||
'Published': { zh: '已上架', en: 'Published' },
|
||
'Under Maintenance': { zh: '维护中', en: 'Under Maintenance' },
|
||
'Disabled': { zh: '已禁用', en: 'Disabled' }
|
||
}
|
||
if (!map[normalized]) {
|
||
return normalized
|
||
}
|
||
return language === 'en' ? map[normalized].en : map[normalized].zh
|
||
}
|
||
|
||
function normalizeRoomTypeDetail(area = {}, roomType = {}) {
|
||
const extra = roomType.extra || {}
|
||
const images = normalizeImageList(roomType)
|
||
const pricePlan = normalizePricePlan(roomType.pricePlan, roomType.pricePlanDetail)
|
||
const priceValue = resolveNumericValue(
|
||
pricePlan.finalPrice,
|
||
roomType && roomType.pricePlanDetail ? roomType.pricePlanDetail.price : '',
|
||
roomType.price
|
||
)
|
||
|
||
return {
|
||
...createInitialDetail(),
|
||
roomTypeCode: roomType.code || '',
|
||
roomTypeName: roomType.name || '',
|
||
roomTypeNameEn: roomType.nameEn || '',
|
||
roomTypeNameEnDisplay: compactEnglishText(roomType.nameEn || '', 22),
|
||
areaCode: area.code || '',
|
||
name: roomType.name || '',
|
||
nameEn: roomType.nameEn || '',
|
||
nameEnDisplay: compactEnglishText(roomType.nameEn || '', 24),
|
||
description: normalizeRichText(roomType.description),
|
||
descriptionEn: normalizeRichText(roomType.descriptionEn),
|
||
pricePlan,
|
||
priceValue,
|
||
payMethod: pricePlan.matchedStrategies || roomType.payMethod || '',
|
||
areaName: area.name || '',
|
||
areaNameEn: area.nameEn || '',
|
||
areaNameEnDisplay: compactEnglishText(area.nameEn || '', 22),
|
||
address: area.address || '',
|
||
addressEn: area.addressEn || '',
|
||
addressEnDisplay: compactEnglishText(area.addressEn || '', 32),
|
||
latitude: area.latitude || area.lat || '',
|
||
longitude: area.longitude || area.lng || '',
|
||
image: images[0] || '',
|
||
images,
|
||
// 保留 VR 预览入口,后续可按真实接口字段进一步控制展示条件
|
||
vrMockEnabled: true,
|
||
needApproval: typeof extra.needApproval === 'boolean' ? extra.needApproval : null,
|
||
maxMinutes: resolveNumericValue(extra.maxMinutes, roomType.maxMinutes),
|
||
bookRoomCountOnDay: resolveNumericValue(extra.bookRoomCountOnDay, roomType.bookRoomCountOnDay),
|
||
bookLimitTime: resolveNumericValue(extra.bookLimitTime, roomType.bookLimitTime),
|
||
isFunction: typeof roomType.isFunction === 'boolean' ? roomType.isFunction : null,
|
||
assets: normalizeRoomAssets(roomType.assets)
|
||
}
|
||
}
|
||
|
||
function normalizeRoomAssets(list = []) {
|
||
if (!Array.isArray(list)) {
|
||
return []
|
||
}
|
||
|
||
return list
|
||
.map((item, index) => {
|
||
if (!item || typeof item !== 'object') {
|
||
return null
|
||
}
|
||
const assetName = normalizeText(item.assetName || item.name || item.label)
|
||
if (!assetName) {
|
||
return null
|
||
}
|
||
const rawIcon = String(item.icon || '').trim()
|
||
const rawIconClass = String(item.iconClass || '').trim()
|
||
const iconClass = normalizeAssetIconClass(rawIconClass || rawIcon)
|
||
return {
|
||
key: `${item.roomTypeCode || 'asset'}-${item.assetName || item.name || index}`,
|
||
roomTypeCode: item.roomTypeCode || '',
|
||
assetName,
|
||
brand: normalizeText(item.brand),
|
||
icon: iconClass ? '' : addFilePrefix(rawIcon),
|
||
iconClass,
|
||
defaultQuantity: Number(item.defaultQuantity) > 0 ? Number(item.defaultQuantity) : 1,
|
||
isRequired: Boolean(item.isRequired),
|
||
sort: Number.isFinite(Number(item.sort)) ? Number(item.sort) : 9999
|
||
}
|
||
})
|
||
.filter(Boolean)
|
||
.sort((prev, next) => {
|
||
if (prev.sort !== next.sort) {
|
||
return prev.sort - next.sort
|
||
}
|
||
return prev.assetName.localeCompare(next.assetName)
|
||
})
|
||
}
|
||
|
||
function createRoomFallbackDetail(raw = {}) {
|
||
const images = normalizeImageList(raw)
|
||
const pricePlan = normalizePricePlan(raw.pricePlan)
|
||
return {
|
||
...createInitialDetail(),
|
||
roomCode: raw.roomCode || raw.code || '',
|
||
roomTypeCode: raw.roomTypeCode || '',
|
||
roomTypeName: raw.roomTypeName || '',
|
||
roomTypeNameEn: raw.roomTypeNameEn || '',
|
||
roomTypeNameEnDisplay: compactEnglishText(raw.roomTypeNameEn || '', 22),
|
||
name: raw.name || raw.no || '',
|
||
nameEn: raw.nameEn || '',
|
||
nameEnDisplay: compactEnglishText(raw.nameEn || '', 24),
|
||
description: normalizeRichText(raw.description || raw.remark),
|
||
pricePlan,
|
||
priceValue: resolveNumericValue(pricePlan.finalPrice, raw.price),
|
||
payMethod: pricePlan.matchedStrategies || raw.payMethod || '',
|
||
areaName: raw.areaName || '',
|
||
areaNameEn: raw.areaNameEn || '',
|
||
areaNameEnDisplay: compactEnglishText(raw.areaNameEn || '', 22),
|
||
address: raw.address || [raw.areaName, raw.buildingName, raw.buildingSectionName].filter(Boolean).join(' · '),
|
||
addressEn: raw.addressEn || '',
|
||
addressEnDisplay: compactEnglishText(raw.addressEn || '', 32),
|
||
buildingName: raw.buildingName || '',
|
||
buildingNameEn: raw.buildingNameEn || '',
|
||
buildingNameEnDisplay: compactEnglishText(raw.buildingNameEn || '', 20),
|
||
buildingSectionName: raw.buildingSectionName || '',
|
||
buildingSectionNameEn: raw.buildingSectionNameEn || '',
|
||
buildingSectionNameEnDisplay: compactEnglishText(raw.buildingSectionNameEn || '', 18),
|
||
floorName: raw.floorName || '',
|
||
floorNameEn: raw.floorNameEn || '',
|
||
floorNameEnDisplay: compactEnglishText(raw.floorNameEn || '', 16),
|
||
orientation: raw.orientation || '',
|
||
orientationEn: raw.orientationEn || '',
|
||
statusText: raw.statusText || '',
|
||
image: images[0] || '',
|
||
images
|
||
}
|
||
}
|
||
|
||
function normalizeRoomDetailResponse(raw = {}, fallback = {}) {
|
||
const images = normalizeImageList(raw)
|
||
const pricePlan = normalizePricePlan(raw.pricePlan, fallback.pricePlan)
|
||
return {
|
||
...createInitialDetail(),
|
||
...fallback,
|
||
roomCode: raw.code || fallback.roomCode || '',
|
||
roomTypeCode: raw.roomTypeCode || fallback.roomTypeCode || '',
|
||
roomTypeName: raw.roomTypeName || fallback.roomTypeName || '',
|
||
roomTypeNameEn: raw.roomTypeNameEn || fallback.roomTypeNameEn || '',
|
||
roomTypeNameEnDisplay: compactEnglishText(raw.roomTypeNameEn || fallback.roomTypeNameEn || '', 22),
|
||
name: raw.no || fallback.name || '',
|
||
nameEn: raw.nameEn || fallback.nameEn || '',
|
||
nameEnDisplay: compactEnglishText(raw.nameEn || fallback.nameEn || '', 24),
|
||
description: normalizeRichText(raw.description || raw.remark || fallback.description),
|
||
pricePlan,
|
||
priceValue: resolveNumericValue(pricePlan.finalPrice, raw.monthlyRent, raw.price, raw.rent, fallback.priceValue),
|
||
payMethod: pricePlan.matchedStrategies || raw.payMethod || fallback.payMethod || '',
|
||
areaName: raw.areaName || fallback.areaName || '',
|
||
areaNameEn: raw.areaNameEn || fallback.areaNameEn || '',
|
||
areaNameEnDisplay: compactEnglishText(raw.areaNameEn || fallback.areaNameEn || '', 22),
|
||
address: [raw.areaName, raw.buildingName, raw.buildingSectionName].filter(Boolean).join(' · ') || fallback.address || '',
|
||
addressEn: raw.addressEn || fallback.addressEn || '',
|
||
addressEnDisplay: compactEnglishText(raw.addressEn || fallback.addressEn || '', 32),
|
||
buildingName: raw.buildingName || fallback.buildingName || '',
|
||
buildingNameEn: raw.buildingNameEn || fallback.buildingNameEn || '',
|
||
buildingNameEnDisplay: compactEnglishText(raw.buildingNameEn || fallback.buildingNameEn || '', 20),
|
||
buildingSectionName: raw.buildingSectionName || fallback.buildingSectionName || '',
|
||
buildingSectionNameEn: raw.buildingSectionNameEn || fallback.buildingSectionNameEn || '',
|
||
buildingSectionNameEnDisplay: compactEnglishText(raw.buildingSectionNameEn || fallback.buildingSectionNameEn || '', 18),
|
||
floorName: raw.floorName || fallback.floorName || '',
|
||
floorNameEn: raw.floorNameEn || fallback.floorNameEn || '',
|
||
floorNameEnDisplay: compactEnglishText(raw.floorNameEn || fallback.floorNameEn || '', 16),
|
||
orientation: raw.orientation || fallback.orientation || '',
|
||
orientationEn: raw.orientationEn || fallback.orientationEn || '',
|
||
statusText: getStatusText(raw.status) || fallback.statusText || '',
|
||
image: images[0] || fallback.image || '',
|
||
images: images.length ? images : (fallback.images || [])
|
||
}
|
||
}
|
||
|
||
function normalizeRoomTypeDetailResponse(raw = {}, fallback = {}) {
|
||
const extra = raw.extra || {}
|
||
const images = normalizeImageList(raw)
|
||
const pricePlan = normalizePricePlan(raw.pricePlan, raw.pricePlanDetail || fallback.pricePlan)
|
||
const priceValue = resolveNumericValue(
|
||
pricePlan.finalPrice,
|
||
raw && raw.pricePlanDetail ? raw.pricePlanDetail.price : '',
|
||
raw.price,
|
||
fallback.priceValue
|
||
)
|
||
|
||
return {
|
||
...createInitialDetail(),
|
||
...fallback,
|
||
roomTypeCode: raw.code || fallback.roomTypeCode || '',
|
||
roomTypeName: raw.name || fallback.roomTypeName || fallback.name || '',
|
||
roomTypeNameEn: raw.nameEn || raw.roomTypeNameEn || fallback.roomTypeNameEn || fallback.nameEn || '',
|
||
roomTypeNameEnDisplay: compactEnglishText(raw.nameEn || raw.roomTypeNameEn || fallback.roomTypeNameEn || fallback.nameEn || '', 22),
|
||
name: raw.name || fallback.name || '',
|
||
nameEn: raw.nameEn || fallback.nameEn || '',
|
||
nameEnDisplay: compactEnglishText(raw.nameEn || fallback.nameEn || '', 24),
|
||
description: normalizeRichText(raw.description || fallback.description),
|
||
descriptionEn: normalizeRichText(raw.descriptionEn || fallback.descriptionEn),
|
||
pricePlan,
|
||
priceValue,
|
||
payMethod: pricePlan.matchedStrategies || raw.payMethod || fallback.payMethod || '',
|
||
image: images[0] || fallback.image || '',
|
||
images: images.length ? images : (fallback.images || []),
|
||
vrMockEnabled: fallback.vrMockEnabled !== false,
|
||
needApproval: typeof extra.needApproval === 'boolean' ? extra.needApproval : fallback.needApproval,
|
||
maxMinutes: resolveNumericValue(extra.maxMinutes, raw.maxMinutes, fallback.maxMinutes),
|
||
bookRoomCountOnDay: resolveNumericValue(extra.bookRoomCountOnDay, raw.bookRoomCountOnDay, fallback.bookRoomCountOnDay),
|
||
bookLimitTime: resolveNumericValue(extra.bookLimitTime, raw.bookLimitTime, fallback.bookLimitTime),
|
||
isFunction: typeof raw.isFunction === 'boolean' ? raw.isFunction : fallback.isFunction,
|
||
assets: normalizeRoomAssets(raw.assets).length ? normalizeRoomAssets(raw.assets) : (fallback.assets || [])
|
||
}
|
||
}
|
||
|
||
export default {
|
||
components: {
|
||
RoomHero,
|
||
RoomSummary,
|
||
RoomInfo,
|
||
RoomAssets
|
||
},
|
||
data() {
|
||
return {
|
||
detailMode: 'room',
|
||
roomCode: '',
|
||
detailData: createInitialDetail(),
|
||
detailLoadedFromApi: false,
|
||
isDetailLoading: false,
|
||
scrollTop: 0,
|
||
currentSwiperIndex: 0,
|
||
currentMediaType: 'image',
|
||
autoSwitchTimer: null,
|
||
hasAutoSwitched: false,
|
||
sourceRoomTypeData: null,
|
||
sourceAreaData: null,
|
||
sourceRoomData: null
|
||
}
|
||
},
|
||
computed: {
|
||
language() {
|
||
return this.$getLanguage()
|
||
},
|
||
isRoomType() {
|
||
return this.detailMode === 'room-type'
|
||
},
|
||
navBarBackgroundColor() {
|
||
if (this.currentSwiperIndex === 0) return 'rgba(255, 255, 255, 0)';
|
||
return '#FFFFFF'
|
||
},
|
||
navBarBackStyle() {
|
||
const isDarkBg = this.currentSwiperIndex === 0 || this.scrollTop < 40;
|
||
return {
|
||
transition: 'all 0.2s ease',
|
||
background: isDarkBg ? 'rgba(0, 0, 0, 0.4)' : '#FFFFFF',
|
||
borderRadius: '50%',
|
||
width: '64rpx',
|
||
height: '64rpx',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
marginLeft: '12rpx',
|
||
boxShadow: isDarkBg ? 'none' : '0 8rpx 20rpx rgba(15, 23, 42, 0.12)'
|
||
}
|
||
},
|
||
navBarIconColor() {
|
||
const isDarkBg = this.currentSwiperIndex === 0 || this.scrollTop < 40;
|
||
return isDarkBg ? '#FFFFFF' : '#000000';
|
||
},
|
||
navTitleColor() {
|
||
return '#000000';
|
||
},
|
||
navTitleOpacity() {
|
||
if (this.currentSwiperIndex === 0) return 0;
|
||
return Math.min(Math.max((this.scrollTop - 40) / 50, 0), 1)
|
||
},
|
||
galleryList() {
|
||
return Array.isArray(this.detailData.images) ? this.detailData.images.filter(Boolean) : []
|
||
},
|
||
hasVR() {
|
||
return this.isRoomType && this.detailData.vrMockEnabled
|
||
},
|
||
showVrPanel() {
|
||
return this.hasVR && this.currentMediaType === 'vr'
|
||
},
|
||
vrPoster() {
|
||
return this.galleryList[1] || this.galleryList[0] || ''
|
||
},
|
||
showEnglishName() {
|
||
return this.detailData.nameEn && this.detailData.nameEn !== this.detailData.name
|
||
},
|
||
displayName() {
|
||
return this.language === 'en'
|
||
? (this.detailData.nameEnDisplay || this.detailData.nameEn || this.detailData.name || '')
|
||
: (this.detailData.name || '')
|
||
},
|
||
displayCode() {
|
||
return this.isRoomType ? this.detailData.roomTypeCode : this.detailData.roomCode
|
||
},
|
||
hasPrice() {
|
||
return this.detailData.priceValue !== '' && this.detailData.priceValue !== null
|
||
},
|
||
primaryActionText() {
|
||
return this.isRoomType ? this.t('applyViewing') : this.t('bookRoom')
|
||
},
|
||
summaryBadges() {
|
||
const badges = []
|
||
if (this.detailData.areaName) {
|
||
badges.push(this.language === 'en'
|
||
? (this.detailData.areaNameEnDisplay || this.detailData.areaNameEn || this.detailData.areaName)
|
||
: (this.detailData.areaName || ''))
|
||
}
|
||
if (this.detailData.payMethod) {
|
||
badges.push(localizePayMethodText(this.detailData.payMethod, this.language))
|
||
}
|
||
if (this.isRoomType && this.detailData.needApproval === true) {
|
||
badges.push(this.t('needApproval'))
|
||
}
|
||
if (this.isRoomType && this.detailData.needApproval === false) {
|
||
badges.push(this.t('noApprovalNeeded'))
|
||
}
|
||
return badges
|
||
},
|
||
basicInfoRows() {
|
||
const rows = this.isRoomType
|
||
? [
|
||
{ label: this.t('roomTypeNameLabel'), value: this.displayName },
|
||
{ label: this.t('englishNameLabel'), value: this.showEnglishName ? (this.detailData.nameEnDisplay || this.detailData.nameEn) : '' },
|
||
{ label: this.t('areaLabel'), value: this.language === 'en' ? (this.detailData.areaNameEnDisplay || this.detailData.areaNameEn || this.detailData.areaName) : (this.detailData.areaName || '') },
|
||
{ label: this.t('payMethodLabel'), value: localizePayMethodText(this.detailData.pricePlan.matchedStrategies || this.detailData.payMethod, this.language) },
|
||
{ label: this.t('priceLabel'), value: this.hasPrice ? `¥${this.detailData.priceValue}` : '' }
|
||
]
|
||
: [
|
||
{ label: this.t('roomCodeLabel'), value: this.detailData.roomCode },
|
||
{ label: this.t('roomTypeLabel'), value: this.language === 'en' ? (this.detailData.roomTypeNameEnDisplay || this.detailData.roomTypeNameEn || this.detailData.roomTypeName) : (this.detailData.roomTypeName || '') },
|
||
{ label: this.t('buildingLabel'), value: this.language === 'en' ? (this.detailData.buildingNameEnDisplay || this.detailData.buildingNameEn || this.detailData.buildingName) : (this.detailData.buildingName || '') },
|
||
{ label: this.t('unitLabel'), value: this.language === 'en' ? (this.detailData.buildingSectionNameEnDisplay || this.detailData.buildingSectionNameEn || this.detailData.buildingSectionName) : (this.detailData.buildingSectionName || '') },
|
||
{ label: this.t('floorLabel'), value: this.language === 'en' ? (this.detailData.floorNameEnDisplay || this.detailData.floorNameEn || this.detailData.floorName) : (this.detailData.floorName || '') },
|
||
{ label: this.t('orientationLabel'), value: this.language === 'en' ? (this.detailData.orientationEn || this.detailData.orientation) : (this.detailData.orientation || '') },
|
||
{ label: this.t('statusLabel'), value: localizeKnownStatusText(this.detailData.statusText, this.language) }
|
||
]
|
||
|
||
return rows.filter((item) => item.value)
|
||
},
|
||
displayDescriptions() {
|
||
const primary = this.language === 'en'
|
||
? (this.detailData.descriptionEn || this.detailData.description || '')
|
||
: (this.detailData.description || '')
|
||
const secondary = this.language === 'en'
|
||
? (this.detailData.description || '')
|
||
: ''
|
||
const list = [primary]
|
||
if (this.showEnglishName && secondary && secondary !== primary) {
|
||
list.push(secondary)
|
||
}
|
||
return list.filter(Boolean)
|
||
},
|
||
shouldShowDescToggle() {
|
||
if (!this.displayDescriptions.length) return false
|
||
const combinedHtml = this.displayDescriptions.join('')
|
||
return stripHtmlTags(combinedHtml).length > 72 || /<img/i.test(combinedHtml)
|
||
},
|
||
assetList() {
|
||
return this.isRoomType && Array.isArray(this.detailData.assets)
|
||
? this.detailData.assets.filter(Boolean)
|
||
: []
|
||
}
|
||
},
|
||
watch: {
|
||
language() {
|
||
if (this.isRoomType) {
|
||
const roomTypeCode = this.detailData.roomTypeCode || (this.sourceRoomTypeData && this.sourceRoomTypeData.code) || ''
|
||
if (roomTypeCode && this.canLoadRoomTypeDetail()) {
|
||
this.loadRoomTypeDetail(roomTypeCode)
|
||
}
|
||
return
|
||
}
|
||
if (this.roomCode) {
|
||
this.loadDetail()
|
||
}
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
this.roomCode = options.roomCode || ''
|
||
|
||
if (options.roomTypeData || options.roomTypeCode) {
|
||
const roomTypeTask = options.roomTypeData
|
||
? parseEncodedJson(options.roomTypeData, {}, 'parse room type detail error')
|
||
: Promise.resolve({ code: options.roomTypeCode || '' })
|
||
const areaTask = options.areaData
|
||
? parseEncodedJson(options.areaData, {}, 'parse room area detail error')
|
||
: Promise.resolve({})
|
||
|
||
Promise.all([roomTypeTask, areaTask]).then(([roomType, area]) => {
|
||
this.detailMode = 'room-type'
|
||
this.sourceRoomTypeData = roomType
|
||
this.sourceAreaData = area
|
||
this.detailData = normalizeRoomTypeDetail(area, roomType)
|
||
if (roomType && roomType.code && this.canLoadRoomTypeDetail()) {
|
||
this.loadRoomTypeDetail(roomType.code)
|
||
}
|
||
})
|
||
}
|
||
|
||
if (options.roomData) {
|
||
parseEncodedJson(options.roomData, {}, 'parse room detail fallback error').then((parsed) => {
|
||
this.sourceRoomData = parsed
|
||
this.detailData = createRoomFallbackDetail(parsed)
|
||
})
|
||
}
|
||
|
||
if (this.roomCode && !this.isRoomType) {
|
||
this.loadDetail()
|
||
}
|
||
this.scheduleAutoSwitch()
|
||
},
|
||
onUnload() {
|
||
this.clearAutoSwitchTimer()
|
||
},
|
||
onShareAppMessage() {
|
||
return {
|
||
title: this.displayName || this.t('shareTitle'),
|
||
path: this.buildSharePath(),
|
||
imageUrl: this.detailData.image || ''
|
||
}
|
||
},
|
||
onShareTimeline() {
|
||
return {
|
||
title: this.displayName || this.t('shareTitle'),
|
||
query: this.buildShareQuery(),
|
||
imageUrl: this.detailData.image || ''
|
||
}
|
||
},
|
||
methods: {
|
||
t(key, params = {}) {
|
||
return this.$tm('pkgExplore.room_detail.room_detail', key, params)
|
||
},
|
||
scheduleAutoSwitch() {
|
||
if (this.hasAutoSwitched || this.currentSwiperIndex !== 0) {
|
||
return
|
||
}
|
||
this.clearAutoSwitchTimer()
|
||
this.autoSwitchTimer = setTimeout(() => {
|
||
if (this.currentSwiperIndex === 0) {
|
||
this.currentSwiperIndex = 1
|
||
this.hasAutoSwitched = true
|
||
}
|
||
this.clearAutoSwitchTimer()
|
||
}, 1000)
|
||
},
|
||
clearAutoSwitchTimer() {
|
||
if (this.autoSwitchTimer) {
|
||
clearTimeout(this.autoSwitchTimer)
|
||
this.autoSwitchTimer = null
|
||
}
|
||
},
|
||
buildShareQuery() {
|
||
if (this.isRoomType && this.sourceRoomTypeData) {
|
||
const roomTypeData = encodeURIComponent(JSON.stringify(this.sourceRoomTypeData))
|
||
const areaData = this.sourceAreaData ? encodeURIComponent(JSON.stringify(this.sourceAreaData)) : ''
|
||
return `roomTypeData=${roomTypeData}${areaData ? `&areaData=${areaData}` : ''}`
|
||
}
|
||
if (this.isRoomType && this.detailData.roomTypeCode) {
|
||
const areaData = this.sourceAreaData ? `&areaData=${encodeURIComponent(JSON.stringify(this.sourceAreaData))}` : ''
|
||
return `roomTypeCode=${encodeURIComponent(this.detailData.roomTypeCode)}${areaData}`
|
||
}
|
||
if (this.roomCode) {
|
||
return `roomCode=${encodeURIComponent(this.roomCode)}`
|
||
}
|
||
return ''
|
||
},
|
||
buildSharePath() {
|
||
const query = this.buildShareQuery()
|
||
return `/pkgExplore/room_detail/room_detail${query ? `?${query}` : ''}`
|
||
},
|
||
onSwiperChange(e) {
|
||
this.currentSwiperIndex = e.detail.current
|
||
if (e.detail.current !== 0) {
|
||
this.hasAutoSwitched = true
|
||
}
|
||
this.clearAutoSwitchTimer()
|
||
},
|
||
handleScroll(e) {
|
||
this.scrollTop = e.detail.scrollTop
|
||
},
|
||
onImageSwiperChange(e) {
|
||
// event bubbled from RoomHero if needed
|
||
},
|
||
switchMedia(type) {
|
||
this.currentMediaType = type
|
||
},
|
||
previewImage() {
|
||
if (!this.galleryList.length) {
|
||
uni.showToast({ title: this.t('noPreviewImage'), icon: 'none' })
|
||
return
|
||
}
|
||
uni.previewImage({
|
||
urls: this.galleryList,
|
||
current: this.galleryList[0]
|
||
})
|
||
},
|
||
openMap(detail) {
|
||
const latitude = parseCoordinate(
|
||
detail && detail.latitude,
|
||
detail && detail.lat,
|
||
this.sourceAreaData && this.sourceAreaData.latitude,
|
||
this.sourceAreaData && this.sourceAreaData.lat
|
||
)
|
||
const longitude = parseCoordinate(
|
||
detail && detail.longitude,
|
||
detail && detail.lng,
|
||
this.sourceAreaData && this.sourceAreaData.longitude,
|
||
this.sourceAreaData && this.sourceAreaData.lng
|
||
)
|
||
if (latitude === null || longitude === null) {
|
||
uni.showToast({ title: this.t('noLocation'), icon: 'none' });
|
||
return;
|
||
}
|
||
uni.openLocation({
|
||
latitude,
|
||
longitude,
|
||
name: this.language === 'en'
|
||
? (detail.buildingNameEnDisplay || detail.buildingNameEn || detail.areaNameEnDisplay || detail.areaNameEn || detail.buildingName || detail.areaName || this.displayName || this.t('apartmentName'))
|
||
: (detail.buildingName || detail.areaName || this.displayName || this.t('apartmentName')),
|
||
address: this.language === 'en'
|
||
? (detail.addressEnDisplay || detail.addressEn || detail.address || (this.sourceAreaData && (this.sourceAreaData.addressEnDisplay || this.sourceAreaData.addressEn || this.sourceAreaData.address)) || '')
|
||
: (detail.address || (this.sourceAreaData && this.sourceAreaData.address) || ''),
|
||
scale: 18
|
||
});
|
||
},
|
||
openVR() {
|
||
const titleZh = `${this.displayName || this.t('roomTypeFallback')} ${this.t('vrViewingTitle')}`
|
||
const englishName = this.detailData.roomTypeNameEnDisplay
|
||
|| this.detailData.roomTypeNameEn
|
||
|| this.detailData.nameEnDisplay
|
||
|| this.detailData.nameEn
|
||
|| this.displayName
|
||
|| this.t('roomTypeFallback')
|
||
const titleEn = `${englishName} ${this.t('vrViewingTitle')}`
|
||
uni.navigateTo({
|
||
url: `/pkgExplore/vr_preview/vr_preview?title=${encodeURIComponent(this.language === 'en' ? titleEn : titleZh)}&titleZh=${encodeURIComponent(titleZh)}&titleEn=${encodeURIComponent(titleEn)}&poster=${encodeURIComponent(this.vrPoster || '')}`
|
||
})
|
||
},
|
||
openServiceEntry({ path, title, desc, key, payload }) {
|
||
uni.navigateTo({
|
||
url: this.buildServiceEntryUrl({ path, title, desc, key, payload })
|
||
})
|
||
},
|
||
buildServiceEntryUrl({ path, title, desc, key, payload }) {
|
||
return buildNavigationUrl(path, {
|
||
title,
|
||
desc,
|
||
key,
|
||
payload: JSON.stringify(payload || {})
|
||
})
|
||
},
|
||
canLoadRoomTypeDetail() {
|
||
const session = getSessionSnapshot()
|
||
return !!(session && session.isLogin)
|
||
},
|
||
loadDetail() {
|
||
this.isDetailLoading = true
|
||
|
||
return roomDetail({ code: this.roomCode }, this.language)
|
||
.then((res) => {
|
||
const rawData = res && res.data && res.data.data ? res.data.data : (res && res.data ? res.data : res)
|
||
if (rawData && (rawData.code || rawData.no || rawData.roomTypeName)) {
|
||
this.detailData = normalizeRoomDetailResponse(rawData, this.detailData)
|
||
this.detailLoadedFromApi = true
|
||
} else {
|
||
this.detailLoadedFromApi = false
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error('load room detail error', error)
|
||
this.detailLoadedFromApi = false
|
||
})
|
||
.finally(() => {
|
||
this.isDetailLoading = false
|
||
})
|
||
},
|
||
loadRoomTypeDetail(roomTypeCode = '') {
|
||
if (!roomTypeCode || !this.canLoadRoomTypeDetail()) {
|
||
return Promise.resolve()
|
||
}
|
||
|
||
this.isDetailLoading = true
|
||
return roomTypeDetail({ code: roomTypeCode }, this.$getLanguage())
|
||
.then((res) => {
|
||
const rawData = res && res.data && res.data.data ? res.data.data : (res && res.data ? res.data : res)
|
||
if (rawData && (rawData.code || rawData.name)) {
|
||
this.detailData = normalizeRoomTypeDetailResponse(rawData, this.detailData)
|
||
this.sourceRoomTypeData = {
|
||
...(this.sourceRoomTypeData || {}),
|
||
...rawData
|
||
}
|
||
this.detailLoadedFromApi = true
|
||
} else {
|
||
this.detailLoadedFromApi = false
|
||
}
|
||
})
|
||
.catch((error) => {
|
||
console.error('load room type detail error', error)
|
||
this.detailLoadedFromApi = false
|
||
})
|
||
.finally(() => {
|
||
this.isDetailLoading = false
|
||
})
|
||
},
|
||
handleBooking() {
|
||
const bookingPayload = this.isRoomType
|
||
? {
|
||
sourceType: 'room-detail-viewing',
|
||
areaCode: this.detailData.areaCode,
|
||
areaLabel: this.detailData.areaName,
|
||
areaLabelEn: this.detailData.areaNameEn || '',
|
||
areaLabelEnDisplay: this.detailData.areaNameEnDisplay || this.detailData.areaNameEn || '',
|
||
areaName: this.detailData.areaName,
|
||
areaNameEn: this.detailData.areaNameEn || '',
|
||
areaNameEnDisplay: this.detailData.areaNameEnDisplay || this.detailData.areaNameEn || '',
|
||
roomTypeCode: this.detailData.roomTypeCode,
|
||
roomTypeName: this.detailData.roomTypeName || this.detailData.name,
|
||
roomTypeNameEn: this.detailData.roomTypeNameEn || this.detailData.nameEn || '',
|
||
roomTypeNameEnDisplay: this.detailData.roomTypeNameEnDisplay || this.detailData.roomTypeNameEn || this.detailData.nameEnDisplay || this.detailData.nameEn || '',
|
||
roomName: this.detailData.name,
|
||
roomNameEn: this.detailData.nameEn || '',
|
||
roomNameEnDisplay: this.detailData.nameEnDisplay || this.detailData.nameEn || '',
|
||
price: this.detailData.priceValue,
|
||
payMethod: this.detailData.pricePlan.matchedStrategies || this.detailData.payMethod,
|
||
image: this.detailData.image
|
||
}
|
||
: null
|
||
const applyData = this.isRoomType
|
||
? null
|
||
: {
|
||
styleNameZh: this.detailData.name,
|
||
styleNameEn: this.detailData.nameEn || '',
|
||
styleNameEnDisplay: this.detailData.nameEnDisplay || this.detailData.nameEn || '',
|
||
roomCode: this.detailData.roomCode,
|
||
roomTypeCode: this.detailData.roomTypeCode,
|
||
roomTypeName: this.detailData.roomTypeName,
|
||
roomTypeNameEn: this.detailData.roomTypeNameEn || this.detailData.nameEn || '',
|
||
roomTypeNameEnDisplay: this.detailData.roomTypeNameEnDisplay || this.detailData.roomTypeNameEn || this.detailData.nameEnDisplay || this.detailData.nameEn || '',
|
||
building: this.detailData.buildingName || this.detailData.buildingSectionName || this.detailData.areaName || '',
|
||
buildingEn: this.detailData.buildingNameEn || this.detailData.buildingSectionNameEn || this.detailData.areaNameEn || '',
|
||
buildingEnDisplay: this.detailData.buildingNameEnDisplay || this.detailData.buildingNameEn || this.detailData.buildingSectionNameEnDisplay || this.detailData.buildingSectionNameEn || this.detailData.areaNameEnDisplay || this.detailData.areaNameEn || '',
|
||
floor: this.detailData.floorName || '',
|
||
floorEn: this.detailData.floorNameEn || '',
|
||
floorEnDisplay: this.detailData.floorNameEnDisplay || this.detailData.floorNameEn || '',
|
||
image: this.detailData.image,
|
||
roomArea: this.detailData.address || '',
|
||
roomAreaEn: this.detailData.addressEn || '',
|
||
roomAreaEnDisplay: this.detailData.addressEnDisplay || this.detailData.addressEn || '',
|
||
areaCode: this.detailData.areaCode || '',
|
||
areaName: this.detailData.areaName || '',
|
||
areaNameEn: this.detailData.areaNameEn || '',
|
||
areaNameEnDisplay: this.detailData.areaNameEnDisplay || this.detailData.areaNameEn || ''
|
||
}
|
||
const targetUrl = this.isRoomType
|
||
? buildNavigationUrl('/pkgBooking/book_viewing/book_viewing', {
|
||
payload: JSON.stringify(bookingPayload || {})
|
||
})
|
||
: buildNavigationUrl('/pkgBooking/book/book', {
|
||
applyData: JSON.stringify(applyData || {})
|
||
})
|
||
|
||
if (!ensureBookingAccess({
|
||
language: this.$getLanguage(),
|
||
loginTitle: this.t('loginTitle'),
|
||
loginContent: this.isRoomType
|
||
? this.t('roomTypeApplyLoginContent')
|
||
: this.t('allRoomsLoginContent'),
|
||
loginRedirectUrl: targetUrl
|
||
})) {
|
||
return
|
||
}
|
||
|
||
if (this.isRoomType) {
|
||
uni.navigateTo({
|
||
url: targetUrl
|
||
})
|
||
return
|
||
}
|
||
|
||
uni.navigateTo({
|
||
url: targetUrl
|
||
})
|
||
},
|
||
handleApplyStay() {
|
||
const serviceEntryUrl = this.buildServiceEntryUrl({
|
||
path: '/pkgService/submit_request/submit_request',
|
||
title: `${this.displayName || this.t('roomTypeFallback')} ${this.t('stayApplicationSuffix')}`,
|
||
desc: `${this.t('roomTypeCodePrefix')}${this.detailData.roomTypeCode || ''}.${this.hasPrice ? ` ${this.t('pricePrefix')}¥${this.detailData.priceValue}.` : ''}${this.detailData.pricePlan.matchedStrategies || this.detailData.payMethod ? ` ${this.t('payMethodPrefix')}${localizePayMethodText(this.detailData.pricePlan.matchedStrategies || this.detailData.payMethod, this.language)}.` : ''}`,
|
||
key: 'roomTypeSubmitList',
|
||
payload: {
|
||
sourceType: 'area-room-type',
|
||
areaCode: this.detailData.areaCode,
|
||
roomTypeCode: this.detailData.roomTypeCode,
|
||
roomTypeName: this.detailData.roomTypeName || this.detailData.name,
|
||
roomTypeNameEn: this.detailData.roomTypeNameEn || this.detailData.nameEn || '',
|
||
roomTypeNameEnDisplay: this.detailData.roomTypeNameEnDisplay || this.detailData.roomTypeNameEn || this.detailData.nameEnDisplay || this.detailData.nameEn || '',
|
||
roomName: this.detailData.name,
|
||
roomNameEn: this.detailData.nameEn || '',
|
||
roomNameEnDisplay: this.detailData.nameEnDisplay || this.detailData.nameEn || '',
|
||
areaName: this.detailData.areaName,
|
||
areaNameEn: this.detailData.areaNameEn || '',
|
||
areaNameEnDisplay: this.detailData.areaNameEnDisplay || this.detailData.areaNameEn || '',
|
||
payMethod: this.detailData.pricePlan.matchedStrategies || this.detailData.payMethod,
|
||
price: this.detailData.priceValue
|
||
}
|
||
})
|
||
|
||
if (!ensureBookingAccess({
|
||
language: this.$getLanguage(),
|
||
loginTitle: this.t('loginTitle'),
|
||
loginContent: this.t('stayLoginContent'),
|
||
loginRedirectUrl: serviceEntryUrl
|
||
})) {
|
||
return
|
||
}
|
||
|
||
uni.navigateTo({
|
||
url: serviceEntryUrl
|
||
})
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.room-detail-page {
|
||
height: 100vh;
|
||
background-color: #f6f7fb;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.nav-title {
|
||
font-size: 34rpx;
|
||
font-weight: 700;
|
||
transition: opacity 0.2s, color 0.2s;
|
||
}
|
||
|
||
/* 毛玻璃导航栏覆盖样式 */
|
||
.glass-nav-bar ::v-deep .tn-nav-bar {
|
||
backdrop-filter: blur(20rpx);
|
||
-webkit-backdrop-filter: blur(20rpx);
|
||
}
|
||
|
||
.fullscreen-swiper {
|
||
flex: 1;
|
||
height: 0;
|
||
}
|
||
|
||
.detail-scroll {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.detail-scroll ::-webkit-scrollbar {
|
||
display: none;
|
||
width: 0;
|
||
height: 0;
|
||
color: transparent;
|
||
}
|
||
|
||
.scroll-content {
|
||
background: #ffffff;
|
||
min-height: 101%;
|
||
}
|
||
|
||
.second-screen-shell {
|
||
min-height: 100%;
|
||
}
|
||
|
||
.second-screen-top-gap {
|
||
height: calc(108rpx + env(safe-area-inset-top));
|
||
}
|
||
|
||
.compact-hero-panel {
|
||
position: relative;
|
||
height: 680rpx;
|
||
margin: 0;
|
||
border-radius: 0;
|
||
overflow: hidden;
|
||
box-shadow: none;
|
||
border: none;
|
||
}
|
||
|
||
.compact-hero-swiper,
|
||
.compact-hero-image,
|
||
.compact-hero-empty,
|
||
.compact-vr-panel {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.compact-hero-empty {
|
||
background: linear-gradient(135deg, #2b2f35 0%, #15181d 100%);
|
||
color: rgba(255, 255, 255, 0.82);
|
||
font-size: 26rpx;
|
||
}
|
||
|
||
.compact-hero-mask {
|
||
position: absolute;
|
||
inset: 0;
|
||
background:
|
||
linear-gradient(180deg, rgba(8, 11, 18, 0.08) 0%, rgba(8, 11, 18, 0.24) 34%, rgba(8, 11, 18, 0.84) 100%),
|
||
linear-gradient(135deg, rgba($brand-primary, 0.14) 0%, rgba(0, 0, 0, 0) 46%);
|
||
}
|
||
|
||
.compact-hero-topbar {
|
||
position: absolute;
|
||
top: 80rpx;
|
||
left: 26rpx;
|
||
right: 26rpx;
|
||
z-index: 5;
|
||
}
|
||
|
||
.compact-hero-action-layer {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 3;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.compact-media-tabs {
|
||
display: flex;
|
||
gap: 10rpx;
|
||
padding: 8rpx;
|
||
border-radius: 999rpx;
|
||
background: rgba(8, 11, 18, 0.26);
|
||
border: 1rpx solid rgba(255, 255, 255, 0.14);
|
||
backdrop-filter: blur(14rpx);
|
||
}
|
||
|
||
.compact-hero-counter {
|
||
height: 52rpx;
|
||
padding: 0 18rpx;
|
||
border-radius: 999rpx;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(255, 255, 255, 0.16);
|
||
border: 1rpx solid rgba(255, 255, 255, 0.16);
|
||
backdrop-filter: blur(14rpx);
|
||
color: #ffffff;
|
||
font-size: 22rpx;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.compact-media-tab {
|
||
min-width: 88rpx;
|
||
height: 52rpx;
|
||
padding: 0 18rpx;
|
||
border-radius: 999rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 22rpx;
|
||
color: rgba(255, 255, 255, 0.78);
|
||
}
|
||
|
||
.compact-media-tab--active {
|
||
background: rgba(255, 255, 255, 0.92);
|
||
color: #0f172a;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.compact-hero-center-action {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
transform: translate(-50%, -46%);
|
||
width: 260rpx;
|
||
height: 260rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.compact-hero-ring {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
border: 2rpx solid rgba($brand-primary, 0.3);
|
||
box-shadow: 0 0 20rpx rgba($brand-primary, 0.1) inset;
|
||
}
|
||
|
||
.compact-hero-ring--outer {
|
||
width: 260rpx;
|
||
height: 260rpx;
|
||
animation: compactVrPulseOuter 3s cubic-bezier(0.25, 0.8, 0.25, 1) infinite;
|
||
}
|
||
|
||
.compact-hero-ring--inner {
|
||
width: 200rpx;
|
||
height: 200rpx;
|
||
animation: compactVrPulseInner 3s cubic-bezier(0.25, 0.8, 0.25, 1) infinite 0.4s;
|
||
}
|
||
|
||
.compact-hero-core {
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
border-radius: 50%;
|
||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.25) 0%, rgba(255, 255, 255, 0.05) 100%);
|
||
border: 2rpx solid rgba(255, 255, 255, 0.4);
|
||
backdrop-filter: blur(24rpx);
|
||
box-shadow:
|
||
0 16rpx 36rpx rgba(0, 0, 0, 0.2),
|
||
inset 0 4rpx 12rpx rgba(255, 255, 255, 0.5),
|
||
0 0 30rpx rgba($brand-primary, 0.2);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||
}
|
||
|
||
.compact-hero-center-action:active .compact-hero-core {
|
||
transform: scale(0.9);
|
||
}
|
||
|
||
.compact-hero-core-icon {
|
||
font-size: 52rpx;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.compact-hero-action-text {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: -48rpx;
|
||
transform: translateX(-50%);
|
||
width: 280rpx;
|
||
text-align: center;
|
||
}
|
||
|
||
.compact-hero-action-title {
|
||
font-size: 32rpx;
|
||
font-weight: 700;
|
||
color: #ffffff;
|
||
text-shadow: 0 4rpx 14rpx rgba(0, 0, 0, 0.24);
|
||
}
|
||
|
||
.compact-hero-action-desc {
|
||
margin-top: 8rpx;
|
||
font-size: 22rpx;
|
||
color: rgba(255, 255, 255, 0.72);
|
||
}
|
||
|
||
@keyframes compactVrPulseOuter {
|
||
0% {
|
||
transform: scale(0.85);
|
||
opacity: 0.8;
|
||
}
|
||
100% {
|
||
transform: scale(1.3);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
@keyframes compactVrPulseInner {
|
||
0% {
|
||
transform: scale(0.85);
|
||
opacity: 0.8;
|
||
}
|
||
100% {
|
||
transform: scale(1.2);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
.compact-vr-panel {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.compact-vr-glow {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: radial-gradient(circle at 50% 50%, rgba($brand-primary, 0.2) 0%, rgba(0, 0, 0, 0) 65%);
|
||
pointer-events: none;
|
||
animation: compactVrGlowBreathe 3s ease-in-out infinite alternate;
|
||
}
|
||
|
||
.compact-vr-glow::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: -50%;
|
||
left: -50%;
|
||
width: 200%;
|
||
height: 200%;
|
||
background: conic-gradient(from 0deg at 50% 50%, transparent 0deg, rgba($brand-primary, 0.2) 60deg, transparent 120deg);
|
||
animation: compactVrScanner 6s linear infinite;
|
||
pointer-events: none;
|
||
}
|
||
|
||
@keyframes compactVrGlowBreathe {
|
||
0% {
|
||
opacity: 0.5;
|
||
transform: scale(0.95);
|
||
}
|
||
100% {
|
||
opacity: 1;
|
||
transform: scale(1.05);
|
||
}
|
||
}
|
||
|
||
@keyframes compactVrScanner {
|
||
0% {
|
||
transform: rotate(0deg);
|
||
}
|
||
100% {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.second-screen-body {
|
||
padding-top: 0;
|
||
}
|
||
|
||
.bottom-spacer {
|
||
height: calc(180rpx + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
.action-bar-wrap {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
|
||
background: rgba(255, 255, 255, 0.98);
|
||
backdrop-filter: blur(20rpx);
|
||
box-shadow: 0 -8rpx 30rpx rgba(0, 0, 0, 0.04);
|
||
z-index: 100;
|
||
transform: translateY(150%);
|
||
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||
}
|
||
|
||
.action-bar--visible {
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.action-bar {
|
||
width: 100%;
|
||
}
|
||
|
||
.action-left {
|
||
flex-shrink: 0;
|
||
padding-right: 24rpx;
|
||
}
|
||
|
||
.action-icon-btn {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: transparent;
|
||
padding: 0 16rpx;
|
||
margin: 0;
|
||
line-height: 1;
|
||
}
|
||
|
||
.action-icon-btn::after {
|
||
border: none;
|
||
}
|
||
|
||
.action-icon-font {
|
||
font-size: 42rpx;
|
||
color: #1D2129;
|
||
margin-bottom: 6rpx;
|
||
}
|
||
|
||
.action-icon-text {
|
||
font-size: 20rpx;
|
||
color: #4E5969;
|
||
}
|
||
|
||
.action-right {
|
||
flex: 1;
|
||
display: flex;
|
||
gap: 16rpx;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.action-btn {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
border-radius: 999rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
padding: 0;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.action-btn:active {
|
||
transform: scale(0.96);
|
||
}
|
||
|
||
.action-btn--secondary {
|
||
background: rgba($brand-primary, 0.08);
|
||
color: $brand-primary;
|
||
}
|
||
|
||
.action-btn--primary {
|
||
background: linear-gradient(135deg, #40cbf2 0%, $brand-primary 100%);
|
||
color: #ffffff;
|
||
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.2);
|
||
}
|
||
</style>
|