Files
QSY-lvjuxcx/pkgExplore/room_detail/room_detail.vue
2026-06-29 09:42:53 +08:00

1494 lines
50 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="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>