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

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

682 lines
18 KiB
Vue

<template>
<view class="book-viewing-page tn-safe-area-inset-bottom">
<tn-nav-bar :isBack="true" :backTitle="$tr('返回')" backgroundColor="#F0F6FF" :bottomShadow="false" :fixed="true">
<view class="nav-title">{{ $tr('预约现场带看') }}</view>
</tn-nav-bar>
<view class="content-wrapper" :style="{ paddingTop: vuex_custom_bar_height + 'px' }">
<!-- 房型/区域信息 -->
<view class="info-card tn-flex tn-flex-row-between tn-flex-col-center" v-if="displayInfoTitle">
<view class="info-left">
<view class="info-title">{{ displayInfoTitle }}</view>
<view class="info-desc">{{ displayInfoDesc }}</view>
</view>
<view class="info-icon">
<text class="tn-icon-company"></text>
</view>
</view>
<!-- 看房时间卡片 -->
<view class="form-card">
<view class="card-header tn-flex tn-flex-row-between tn-flex-col-bottom">
<view class="header-left tn-flex tn-flex-col-bottom">
<text class="card-title">{{ $tr('选择看房时间') }}</text>
<text class="card-subtitle">{{ $tr('请选择您方便的时间段') }}</text>
</view>
</view>
<scroll-view class="date-scroll" scroll-x :show-scrollbar="false">
<view class="date-flex">
<view
v-for="(item, index) in dateList"
:key="index"
class="date-item tn-flex tn-flex-direction-column tn-flex-col-center tn-flex-row-center"
:class="{ 'date-item--active': currentDateIndex === index }"
@click="selectDate(index)"
>
<text class="date-week">{{ item.week }}</text>
<text class="date-day">{{ item.label }}</text>
</view>
</view>
</scroll-view>
<scroll-view class="time-scroll" scroll-x :show-scrollbar="false">
<view class="time-flex">
<view
v-for="(item, index) in timeSlots"
:key="index"
class="time-slot"
:class="{ 'time-slot--active': currentTimeIndex === index }"
@click="selectTimeSlot(index)"
>
{{ item }}
</view>
</view>
</scroll-view>
</view>
<!-- 租住需求卡片 -->
<view class="form-card">
<view class="card-header">
<view class="header-left tn-flex tn-flex-col-bottom">
<text class="card-title">{{ $tr('您的租住需求') }}</text>
<text class="card-subtitle">{{ $tr('帮助管家更好地为您推荐房源') }}</text>
</view>
</view>
<view class="field-item">
<view class="field-label">{{ $tr('意向房型') }}</view>
<room-type-selector
v-model="formData.intendedRoomTypeCode"
:initial-area-code="formData.intendedAreaCode"
@change="handleRoomTypeChange"
/>
</view>
<view class="field-item">
<view class="field-label">{{ $tr('预计租住时长') }}</view>
<view class="tag-grid">
<view
v-for="(item, index) in durationOptions"
:key="index"
class="tag-item"
:class="{ 'tag-item--active': formData.duration === item }"
@click="selectDuration(item)"
>
{{ item }}
</view>
</view>
</view>
<view class="field-item">
<view class="field-label">{{ $tr('租金预算 (元/月)') }}</view>
<view class="input-box tn-flex tn-flex-col-center">
<text class="tn-icon-money input-icon"></text>
<input
class="custom-input"
v-model="formData.budget"
type="digit"
:placeholder="$tr('请输入您的心理价位')"
placeholder-style="color: #A1B0C4;"
/>
</view>
</view>
<view class="field-item">
<view class="field-label">{{ $tr('工作/通勤地点') }}</view>
<view class="input-box tn-flex tn-flex-col-center">
<text class="tn-icon-location input-icon"></text>
<input
class="custom-input"
v-model="formData.workplace"
type="text"
:placeholder="$tr('请输入主要通勤地点')"
placeholder-style="color: #A1B0C4;"
/>
</view>
</view>
</view>
</view>
<!-- 底部操作区 -->
<view class="footer-bar tn-safe-area-inset-bottom">
<view class="submit-btn" @click="handleSubmit">
<text>{{ $tr('提交预约') }}</text>
</view>
</view>
</view>
</template>
<script>
import { submitViewingApplicationForm } from '@/api/index.js'
import { resolveExistingBookingNavigationTarget } from '../../utils/booking-entry'
import { getSessionSnapshot } from '../../utils/app-state'
import { syncLoginStayState } from '../../utils/session-sync'
import { compactEnglishText, localizePayMethodText } from '../../utils/select-room-normalizers'
import RoomTypeSelector from './components/RoomTypeSelector.vue'
function buildViewingDateTime(date = '', time = '', isEnd = false) {
if (!date || !time) {
return null
}
const range = String(time).split('-')
const selected = isEnd ? range[1] : range[0]
if (!selected) {
return null
}
return `${date}T${selected}:00`
}
function formatDatePart(value) {
return String(value).padStart(2, '0')
}
function shiftDate(date = '', days = 0) {
if (!date) {
return ''
}
const next = new Date(`${date}T00:00:00`)
if (!Number.isFinite(next.getTime())) {
return ''
}
next.setDate(next.getDate() + days)
return `${next.getFullYear()}-${formatDatePart(next.getMonth() + 1)}-${formatDatePart(next.getDate())}`
}
function buildExpectedStayRange(date = '', durationIndex = 0) {
if (!date) {
return {
start: null,
end: null
}
}
const durationDays = [90, 180, 270, 365, 365]
const normalizedIndex = durationIndex >= 0 ? durationIndex : 0
const endDate = shiftDate(date, durationDays[normalizedIndex] || durationDays[0])
return {
start: `${date}T00:00:00`,
end: endDate ? `${endDate}T23:59:59` : null
}
}
export default {
components: {
RoomTypeSelector
},
computed: {
language() {
return this.$getLanguage()
},
displayInfoTitle() {
if (!this.sourceData) {
return ''
}
if (this.language === 'en') {
return this.sourceData.roomTypeNameEnDisplay
|| this.sourceData.roomNameEnDisplay
|| compactEnglishText(this.sourceData.roomTypeNameEn || this.sourceData.roomNameEn || this.sourceData.bannerTitleEn || this.sourceData.bannerTitle || this.sourceData.areaLabelEn || this.sourceData.areaLabel || this.sourceData.areaNameEn || this.sourceData.areaName || '', 24)
}
return this.sourceData.roomTypeName || this.sourceData.roomName || this.sourceData.bannerTitle || this.sourceData.areaLabel || this.sourceData.areaName || ''
},
displayInfoDesc() {
if (!this.sourceData) {
return this.$tr('专属管家为您提供一对一带看服务')
}
const bannerCopy = this.language === 'en'
? compactEnglishText(this.sourceData.bannerSubtitleEn || this.sourceData.bannerDescEn || this.sourceData.bannerSubtitle || this.sourceData.bannerDesc || '', 48)
: (this.sourceData.bannerSubtitle || this.sourceData.bannerDesc || this.sourceData.bannerSubtitleEn || this.sourceData.bannerDescEn || '')
const areaLabel = this.language === 'en'
? (this.sourceData.areaLabelEnDisplay || this.sourceData.areaNameEnDisplay || compactEnglishText(this.sourceData.areaLabelEn || this.sourceData.areaLabel || this.sourceData.areaNameEn || this.sourceData.areaName || '', 26))
: (this.sourceData.areaLabel || this.sourceData.areaName || this.sourceData.areaLabelEn || this.sourceData.areaNameEn || '')
const meta = [bannerCopy, areaLabel, localizePayMethodText(this.sourceData.payMethod, this.language)].filter(Boolean).join(' · ')
return meta || this.$tr('专属管家为您提供一对一带看服务')
}
},
data() {
return {
dateList: [],
timeSlots: [
'10:00-11:00', '11:00-12:00', '12:00-13:00',
'13:00-14:00', '14:00-15:00', '15:00-16:00',
'16:00-17:00', '17:00-18:00', '18:00-19:00',
'19:00-20:00'
],
durationOptions: [],
currentDateIndex: 0,
currentTimeIndex: -1,
formData: {
intendedAreaCode: '',
intendedRoomTypeCode: '',
budget: '',
duration: '',
workplace: ''
},
sourceData: null,
isSubmitting: false
}
},
onLoad(options) {
this.syncDurationOptions()
this.generateDates()
if (options.payload) {
try {
this.sourceData = JSON.parse(decodeURIComponent(options.payload))
this.formData.intendedAreaCode = this.sourceData && this.sourceData.areaCode ? this.sourceData.areaCode : ''
this.formData.intendedRoomTypeCode = this.sourceData && this.sourceData.roomTypeCode ? this.sourceData.roomTypeCode : ''
} catch (e) {
console.error('Failed to parse payload', e)
}
}
this.redirectIfBookingUnavailable()
},
methods: {
redirectIfBookingUnavailable() {
const session = getSessionSnapshot()
if (!session.isLogin) {
return Promise.resolve(false)
}
return syncLoginStayState({
language: this.$getLanguage(),
force: true
})
.then((stayState) => {
const targetUrl = resolveExistingBookingNavigationTarget(stayState)
if (!targetUrl) {
return false
}
if (/^\/pages\/index\/index(?:\?|$)/.test(targetUrl)) {
uni.reLaunch({ url: targetUrl })
return true
}
uni.redirectTo({ url: targetUrl })
return true
})
.catch((error) => {
console.error('check viewing booking entry failed', error)
return false
})
},
syncDurationOptions() {
this.durationOptions = [
this.$tr('1-3个月'),
this.$tr('4-6个月'),
this.$tr('7-9个月'),
this.$tr('10-12个月'),
this.$tr('1年以上')
]
},
generateDates() {
const dates = []
const today = new Date()
const weeks = [
this.$tr('周日'),
this.$tr('周一'),
this.$tr('周二'),
this.$tr('周三'),
this.$tr('周四'),
this.$tr('周五'),
this.$tr('周六')
]
for (let i = 0; i < 7; i++) {
const d = new Date(today)
d.setDate(today.getDate() + i)
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
let weekStr = weeks[d.getDay()]
if (i === 0) weekStr = this.$tr('今天');
else if (i === 1) weekStr = this.$tr('明天');
dates.push({
date: `${d.getFullYear()}-${month}-${day}`,
label: `${month}/${day}`,
week: weekStr
})
}
this.dateList = dates
},
selectDate(index) {
this.currentDateIndex = index
this.currentTimeIndex = -1
},
selectTimeSlot(index) {
this.currentTimeIndex = index
},
handleRoomTypeChange({ area, roomType }) {
this.formData.intendedAreaCode = area && area.code ? area.code : ''
this.formData.intendedRoomTypeCode = roomType && roomType.code ? roomType.code : ''
if (!this.sourceData) {
this.sourceData = {}
}
if (area) {
this.sourceData.areaCode = area.code || ''
this.sourceData.areaName = area.name || ''
this.sourceData.areaNameEn = area.nameEn || ''
}
if (roomType) {
this.sourceData.roomTypeCode = roomType.code || ''
this.sourceData.roomTypeName = roomType.name || ''
this.sourceData.roomTypeNameEn = roomType.nameEn || ''
}
},
selectDuration(item) {
this.formData.duration = item
},
handleSubmit() {
if (this.isSubmitting) {
return
}
if (this.currentTimeIndex === -1) {
uni.showToast({ title: this.$tr('请选择看房时间段'), icon: 'none' })
return
}
if (!this.formData.duration) {
uni.showToast({ title: this.$tr('请选择预计租住时长'), icon: 'none' })
return
}
const selectedDate = this.dateList[this.currentDateIndex].date
const selectedTime = this.timeSlots[this.currentTimeIndex]
const appointmentViewingTime = buildViewingDateTime(selectedDate, selectedTime, false)
const durationIndex = this.durationOptions.findIndex(item => item === this.formData.duration)
const expectedStayRange = buildExpectedStayRange(selectedDate, durationIndex)
const payload = {
intendedAreaCode: this.formData.intendedAreaCode || (this.sourceData && this.sourceData.areaCode ? this.sourceData.areaCode : null),
intendedRoomTypeCode: this.formData.intendedRoomTypeCode || (this.sourceData && this.sourceData.roomTypeCode ? this.sourceData.roomTypeCode : null),
roomSlotHeight: 1,
hobbiesAndInterestsTagKeys: null,
lifestyleHabitsTagKeys: null,
remark: null,
start: expectedStayRange.start,
end: expectedStayRange.end,
appointmentViewingTime
}
this.isSubmitting = true
uni.showLoading({ title: this.$tr('提交中...'), mask: true })
return submitViewingApplicationForm(payload)
.then(() => {
uni.hideLoading()
uni.showModal({
title: this.$tr('预约成功'),
content: this.$tr('您的预约看房申请已提交,请等待管家联系'),
showCancel: false,
confirmColor: '#01BEFF',
success: () => {
uni.redirectTo({
url: '/pkgService/my_application/my_application'
})
}
})
})
.catch((error) => {
console.error('submit viewing application failed', error)
uni.hideLoading()
uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('预约提交失败'),
icon: 'none'
})
})
.finally(() => {
this.isSubmitting = false
})
}
}
}
</script>
<style scoped lang="scss">
.book-viewing-page {
min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
display: flex;
flex-direction: column;
}
.nav-title {
font-size: 34rpx;
font-weight: bold;
color: #1D2129;
}
.content-wrapper {
flex: 1;
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
}
/* 顶部信息卡片 */
.info-card {
margin: 30rpx;
padding: 40rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #01BEFF 0%, #0099FF 100%);
box-shadow: 0 16rpx 32rpx rgba(1, 190, 255, 0.2);
color: #ffffff;
}
.info-left {
flex: 1;
min-width: 0;
}
.info-title {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 12rpx;
letter-spacing: 2rpx;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.info-desc {
font-size: 24rpx;
opacity: 0.9;
letter-spacing: 1rpx;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.info-icon {
font-size: 80rpx;
opacity: 0.2;
margin-left: 20rpx;
}
/* 通用表单卡片 */
.form-card {
margin: 0 30rpx 30rpx;
padding: 40rpx 30rpx;
border-radius: 24rpx;
background: #ffffff;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.03);
}
.card-header {
margin-bottom: 30rpx;
}
.card-title {
font-size: 34rpx;
font-weight: bold;
color: #1D2129;
margin-right: 16rpx;
}
.card-subtitle {
font-size: 24rpx;
color: #86909C;
}
/* 日期选择 */
.date-scroll {
width: 100%;
white-space: nowrap;
margin-bottom: 30rpx;
}
.date-flex {
display: inline-flex;
gap: 20rpx;
padding-bottom: 10rpx;
}
.date-item {
width: 110rpx;
height: 130rpx;
background: #F4F8FF;
border-radius: 16rpx;
flex-shrink: 0;
border: 2rpx solid transparent;
transition: all 0.3s ease;
}
.date-item--active {
background: #EAF0FF;
border-color: #01BEFF;
}
.date-week {
font-size: 24rpx;
color: #86909C;
margin-bottom: 8rpx;
transition: all 0.3s ease;
}
.date-item--active .date-week {
color: #01BEFF;
font-weight: bold;
}
.date-day {
font-size: 28rpx;
color: #1D2129;
font-weight: 500;
transition: all 0.3s ease;
}
.date-item--active .date-day {
color: #01BEFF;
}
/* 时间段选择 */
.time-scroll {
width: 100%;
}
.time-flex {
display: inline-flex;
gap: 20rpx;
padding-bottom: 8rpx;
}
.time-slot {
width: 220rpx;
height: 72rpx;
line-height: 72rpx;
text-align: center;
background: #F4F8FF;
border-radius: 12rpx;
font-size: 26rpx;
color: #4E5969;
border: 2rpx solid transparent;
transition: all 0.3s ease;
flex-shrink: 0;
}
.time-slot--active {
background: #01BEFF;
color: #ffffff;
font-weight: bold;
box-shadow: 0 8rpx 16rpx rgba(1, 190, 255, 0.2);
}
/* 需求字段 */
.field-item {
margin-bottom: 40rpx;
}
.field-item:last-child {
margin-bottom: 0;
}
.field-label {
font-size: 28rpx;
color: #1D2129;
font-weight: bold;
margin-bottom: 24rpx;
}
/* 标签选择 */
.tag-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.tag-item {
padding: 0 32rpx;
height: 68rpx;
line-height: 68rpx;
background: #F4F8FF;
border-radius: 34rpx;
font-size: 26rpx;
color: #4E5969;
border: 2rpx solid transparent;
transition: all 0.3s ease;
}
.tag-item--active {
background: #EAF0FF;
border-color: #01BEFF;
color: #01BEFF;
font-weight: bold;
}
/* 输入框 */
.input-box {
background: #F4F8FF;
border-radius: 16rpx;
height: 88rpx;
padding: 0 30rpx;
}
.input-icon {
font-size: 36rpx;
color: #A1B0C4;
margin-right: 16rpx;
}
.custom-input {
flex: 1;
font-size: 28rpx;
color: #1D2129;
}
/* 底部按钮 */
.footer-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
background: #ffffff;
padding: 24rpx 40rpx;
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.04);
z-index: 100;
}
.submit-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, #01BEFF 0%, #0099FF 100%);
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 32rpx;
font-weight: bold;
letter-spacing: 2rpx;
box-shadow: 0 12rpx 24rpx rgba(1, 190, 255, 0.25);
transition: all 0.3s ease;
}
.submit-btn:active {
transform: scale(0.98);
opacity: 0.9;
}
</style>