Files
QSY-lvjuxcx/pkgExplore/room_detail/room_detail.vue
liangjian 9fee2c3a74 feat: 优化首页服务入口与租住业务流程
补齐首页待办跳转、押金支付引导和多类租住服务页面能力,统一租客端关键业务流程体验。
2026-08-25 13:53:12 +08:00

1493 lines
51 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 || $tr('房型详情') }}</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">{{ $tr('暂无图片') }}</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">{{ $tr('暂无图片') }}</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')"
>
{{ $tr('图片') }}
</view>
<view
class="compact-media-tab"
:class="{ 'compact-media-tab--active': currentMediaType === 'vr' }"
@click="switchMedia('vr')"
>
{{ $tr('VR') }}
</view>
</view>
<view v-if="galleryList.length" class="compact-hero-counter">{{ $tr('{count} 张图片', { 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">{{ $tr('VR预览') }}</view>
<view class="compact-hero-action-desc">{{ $tr('切换到沉浸式看房模式。') }}</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">{{ $tr('查看大图') }}</view>
<view class="compact-hero-action-desc">{{ $tr('点击查看当前图片的大图预览。') }}</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">{{ $tr('分享') }}</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>{{ $tr('申请入住') }}</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)
const assetNameEn = normalizeText(item.assetNameEn || item.nameEn || item.labelEn)
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,
assetNameEn,
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.$tr('预约看房') : this.$tr('立即预订')
},
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.$tr('需审核'))
}
if (this.isRoomType && this.detailData.needApproval === false) {
badges.push(this.$tr('无需审核'))
}
return badges
},
basicInfoRows() {
const rows = this.isRoomType
? [
{ label: this.$tr('房型名称'), value: this.displayName },
{ label: this.$tr('英文名称'), value: this.showEnglishName ? (this.detailData.nameEnDisplay || this.detailData.nameEn) : '' },
{ label: this.$tr('所属区域'), value: this.language === 'en' ? (this.detailData.areaNameEnDisplay || this.detailData.areaNameEn || this.detailData.areaName) : (this.detailData.areaName || '') },
{ label: this.$tr('支付方式'), value: localizePayMethodText(this.detailData.pricePlan.matchedStrategies || this.detailData.payMethod, this.language) },
{ label: this.$tr('价格'), value: this.hasPrice ? `¥${this.detailData.priceValue}` : '' }
]
: [
{ label: this.$tr('房号'), value: this.detailData.roomCode },
{ label: this.$tr('房型'), value: this.language === 'en' ? (this.detailData.roomTypeNameEnDisplay || this.detailData.roomTypeNameEn || this.detailData.roomTypeName) : (this.detailData.roomTypeName || '') },
{ label: this.$tr('楼栋'), value: this.language === 'en' ? (this.detailData.buildingNameEnDisplay || this.detailData.buildingNameEn || this.detailData.buildingName) : (this.detailData.buildingName || '') },
{ label: this.$tr('单元'), value: this.language === 'en' ? (this.detailData.buildingSectionNameEnDisplay || this.detailData.buildingSectionNameEn || this.detailData.buildingSectionName) : (this.detailData.buildingSectionName || '') },
{ label: this.$tr('楼层'), value: this.language === 'en' ? (this.detailData.floorNameEnDisplay || this.detailData.floorNameEn || this.detailData.floorName) : (this.detailData.floorName || '') },
{ label: this.$tr('朝向'), value: this.language === 'en' ? (this.detailData.orientationEn || this.detailData.orientation) : (this.detailData.orientation || '') },
{ label: this.$tr('状态'), 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.$tr('房型详情'),
path: this.buildSharePath(),
imageUrl: this.detailData.image || ''
}
},
onShareTimeline() {
return {
title: this.displayName || this.$tr('房型详情'),
query: this.buildShareQuery(),
imageUrl: this.detailData.image || ''
}
},
methods: {
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.$tr('暂无预览图片'), 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.$tr('暂无位置信息'), 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.$tr('公寓'))
: (detail.buildingName || detail.areaName || this.displayName || this.$tr('公寓')),
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.$tr('房型')} ${this.$tr('VR看房')}`
const englishName = this.detailData.roomTypeNameEnDisplay
|| this.detailData.roomTypeNameEn
|| this.detailData.nameEnDisplay
|| this.detailData.nameEn
|| this.displayName
|| this.$tr('房型')
const titleEn = `${englishName} ${this.$tr('VR看房')}`
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.$tr('请先登录'),
loginContent: this.isRoomType
? this.$tr('请先登录后再提交该房型的看房申请。')
: this.$tr('请先登录后再预订该房间。'),
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.$tr('房型')} ${this.$tr('入住申请')}`,
desc: `${this.$tr('房型编码:')}${this.detailData.roomTypeCode || ''}.${this.hasPrice ? ` ${this.$tr('价格:')}¥${this.detailData.priceValue}.` : ''}${this.detailData.pricePlan.matchedStrategies || this.detailData.payMethod ? ` ${this.$tr('支付方式:')}${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.$tr('请先登录'),
loginContent: this.$tr('请先登录后再提交入住申请。'),
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>