chore: initial import

This commit is contained in:
liangjian
2026-06-29 09:42:53 +08:00
commit 9fea127de1
241 changed files with 82767 additions and 0 deletions

View File

@@ -0,0 +1,705 @@
<template>
<view class="book-viewing-page tn-safe-area-inset-bottom">
<tn-nav-bar :isBack="true" :backTitle="t('backTitle')" backgroundColor="#F0F6FF" :bottomShadow="false" :fixed="true">
<view class="nav-title">{{ t('navTitle') }}</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">{{ t('selectViewingTime') }}</text>
<text class="card-subtitle">{{ t('selectViewingTimeDesc') }}</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>
<view class="time-grid">
<view
v-for="(item, index) in timeSlots"
:key="index"
class="time-slot"
:class="{ 'time-slot--active': currentTimeIndex === index }"
@click="selectTimeSlot(index)"
>
{{ item }}
</view>
</view>
</view>
<!-- 租住需求卡片 -->
<view class="form-card">
<view class="card-header">
<view class="header-left tn-flex tn-flex-col-bottom">
<text class="card-title">{{ t('rentalNeeds') }}</text>
<text class="card-subtitle">{{ t('rentalNeedsDesc') }}</text>
</view>
</view>
<view class="field-item">
<view class="field-label">{{ t('durationLabel') }}</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">{{ t('budgetLabel') }}</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="t('budgetPlaceholder')"
placeholder-style="color: #A1B0C4;"
/>
</view>
</view>
<view class="field-item">
<view class="field-label">{{ t('workplaceLabel') }}</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="t('workplacePlaceholder')"
placeholder-style="color: #A1B0C4;"
/>
</view>
</view>
</view>
</view>
<!-- 底部操作区 -->
<view class="footer-bar tn-safe-area-inset-bottom">
<view class="submit-btn" @click="handleSubmit">
<text>{{ t('submitBooking') }}</text>
</view>
</view>
</view>
</template>
<script>
import { submitApplicationForm } from '@/api/index.js'
import { compactEnglishText, localizePayMethodText } from '../../utils/select-room-normalizers'
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`
}
export default {
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.t('defaultInfoDesc')
}
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.t('defaultInfoDesc')
}
},
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: {
budget: '',
duration: '',
workplace: ''
},
sourceData: null,
isSubmitting: false,
i18n: {
zh: {
backTitle: '返回',
navTitle: '预约现场带看',
selectViewingTime: '选择看房时间',
selectViewingTimeDesc: '请选择您方便的时间段',
rentalNeeds: '您的租住需求',
rentalNeedsDesc: '帮助管家更好地为您推荐房源',
durationLabel: '预计租住时长',
budgetLabel: '租金预算 (元/月)',
budgetPlaceholder: '请输入您的心理价位',
workplaceLabel: '工作/通勤地点',
workplacePlaceholder: '请输入主要通勤地点',
submitBooking: '提交预约',
defaultInfoDesc: '专属管家为您提供一对一带看服务',
duration1: '1-3个月',
duration2: '4-6个月',
duration3: '7-9个月',
duration4: '10-12个月',
duration5: '1年以上',
week0: '周日',
week1: '周一',
week2: '周二',
week3: '周三',
week4: '周四',
week5: '周五',
week6: '周六',
today: '今天',
tomorrow: '明天',
remarkTitle: '预约看房申请',
remarkTime: '看房时间:{date} {time}',
remarkDuration: '预计租住时长:{duration}',
remarkBudget: '租金预算:{budget} 元/月',
remarkWorkplace: '工作/通勤地点:{workplace}',
remarkArea: '意向区域:{area}',
remarkRoomType: '意向房型:{roomType}',
remarkSource: '当前来源:{room}',
remarkPayMethod: '支付方式:{payMethod}',
remarkTail: '备注:请宿管确认带看安排;若带看完成并锁定房号,可转为预定单通知租客支付。',
selectTimeRequired: '请选择看房时间段',
selectDurationRequired: '请选择预计租住时长',
submitting: '提交中...',
submitSuccessTitle: '预约成功',
submitSuccessContent: '您的预约看房申请已提交。\n系统会自动转发给对应区域有房间权限的宿管,宿管确认带看后可直接完结订单,或选定房型房号后转为预定单发送给租客支付。',
submitFailed: '预约提交失败'
},
en: {
backTitle: 'Back',
navTitle: 'On-site Viewing',
selectViewingTime: 'Select Viewing Time',
selectViewingTimeDesc: 'Choose a time slot that works for you',
rentalNeeds: 'Rental Needs',
rentalNeedsDesc: 'Help the manager recommend a suitable room',
durationLabel: 'Expected Stay Duration',
budgetLabel: 'Budget (CNY/month)',
budgetPlaceholder: 'Enter your expected budget',
workplaceLabel: 'Work / Commute Location',
workplacePlaceholder: 'Enter your main commute location',
submitBooking: 'Submit Request',
defaultInfoDesc: 'A dedicated manager will arrange a one-on-one viewing for you',
duration1: '1-3 months',
duration2: '4-6 months',
duration3: '7-9 months',
duration4: '10-12 months',
duration5: 'Over 1 year',
week0: 'Sun',
week1: 'Mon',
week2: 'Tue',
week3: 'Wed',
week4: 'Thu',
week5: 'Fri',
week6: 'Sat',
today: 'Today',
tomorrow: 'Tomorrow',
remarkTitle: 'Viewing Request',
remarkTime: 'Viewing Time: {date} {time}',
remarkDuration: 'Expected Stay: {duration}',
remarkBudget: 'Budget: {budget} CNY/month',
remarkWorkplace: 'Work / Commute Location: {workplace}',
remarkArea: 'Preferred Area: {area}',
remarkRoomType: 'Preferred Room Type: {roomType}',
remarkSource: 'Current Source: {room}',
remarkPayMethod: 'Payment Method: {payMethod}',
remarkTail: 'Note: Please confirm the viewing schedule with the manager. After viewing and locking a room number, the order can be converted into a booking for payment.',
selectTimeRequired: 'Please select a viewing time slot',
selectDurationRequired: 'Please select the expected stay duration',
submitting: 'Submitting...',
submitSuccessTitle: 'Booking Submitted',
submitSuccessContent: 'Your viewing request has been submitted.\nThe system will forward it to the authorized manager of the selected area. After the manager confirms the viewing, the order can be completed directly or converted into a booking for payment once a room is selected.',
submitFailed: 'Failed to submit the booking'
}
}
}
},
onLoad(options) {
this.syncDurationOptions()
this.generateDates()
if (options.payload) {
try {
this.sourceData = JSON.parse(decodeURIComponent(options.payload))
} catch (e) {
console.error('Failed to parse payload', e)
}
}
},
methods: {
t(key, params) {
return this.$tm('pkgBooking.book_viewing.book_viewing', key, params)
},
syncDurationOptions() {
this.durationOptions = [
this.t('duration1'),
this.t('duration2'),
this.t('duration3'),
this.t('duration4'),
this.t('duration5')
]
},
generateDates() {
const dates = []
const today = new Date()
const weeks = [
this.t('week0'),
this.t('week1'),
this.t('week2'),
this.t('week3'),
this.t('week4'),
this.t('week5'),
this.t('week6')
]
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.t('today');
else if (i === 1) weekStr = this.t('tomorrow');
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
},
selectDuration(item) {
this.formData.duration = item
},
buildRemark(selectedDate, selectedTime) {
const lines = [
this.t('remarkTitle'),
this.t('remarkTime', { date: selectedDate, time: selectedTime }),
this.t('remarkDuration', { duration: this.formData.duration })
]
if (this.formData.budget) {
lines.push(this.t('remarkBudget', { budget: this.formData.budget }))
}
if (this.formData.workplace) {
lines.push(this.t('remarkWorkplace', { workplace: this.formData.workplace }))
}
if (this.sourceData && (this.sourceData.areaName || this.sourceData.areaNameEn)) {
lines.push(this.t('remarkArea', {
area: this.language === 'en'
? (this.sourceData.areaNameEnDisplay || this.sourceData.areaLabelEnDisplay || this.sourceData.areaNameEn || this.sourceData.areaLabelEn || this.sourceData.areaName || this.sourceData.areaLabel || '')
: (this.sourceData.areaName || this.sourceData.areaLabel || this.sourceData.areaNameEn || this.sourceData.areaLabelEn || '')
}))
}
if (this.sourceData && (this.sourceData.roomTypeName || this.sourceData.roomTypeNameEn)) {
lines.push(this.t('remarkRoomType', {
roomType: this.language === 'en'
? (this.sourceData.roomTypeNameEnDisplay || this.sourceData.roomTypeNameEn || this.sourceData.roomTypeName || '')
: (this.sourceData.roomTypeName || this.sourceData.roomTypeNameEn || '')
}))
}
if (this.sourceData && (this.sourceData.roomName || this.sourceData.roomNameEn)) {
lines.push(this.t('remarkSource', {
room: this.language === 'en'
? (this.sourceData.roomNameEnDisplay || this.sourceData.roomNameEn || this.sourceData.roomName || '')
: (this.sourceData.roomName || this.sourceData.roomNameEn || '')
}))
}
if (this.sourceData && this.sourceData.payMethod) {
lines.push(this.t('remarkPayMethod', { payMethod: localizePayMethodText(this.sourceData.payMethod, this.language) }))
}
lines.push(this.t('remarkTail'))
return lines.join('\n')
},
handleSubmit() {
if (this.isSubmitting) {
return
}
if (this.currentTimeIndex === -1) {
uni.showToast({ title: this.t('selectTimeRequired'), icon: 'none' })
return
}
if (!this.formData.duration) {
uni.showToast({ title: this.t('selectDurationRequired'), icon: 'none' })
return
}
const selectedDate = this.dateList[this.currentDateIndex].date
const selectedTime = this.timeSlots[this.currentTimeIndex]
const payload = {
intendedAreaCode: this.sourceData && this.sourceData.areaCode ? this.sourceData.areaCode : null,
intendedRoomTypeCode: this.sourceData && this.sourceData.roomTypeCode ? this.sourceData.roomTypeCode : null,
roomSlotHeight: 1,
hobbiesAndInterestsTagKeys: null,
lifestyleHabitsTagKeys: null,
remark: this.buildRemark(selectedDate, selectedTime),
start: buildViewingDateTime(selectedDate, selectedTime, false),
end: buildViewingDateTime(selectedDate, selectedTime, true)
};
this.isSubmitting = true
uni.showLoading({ title: this.t('submitting'), mask: true })
return submitApplicationForm(payload)
.then(() => {
uni.hideLoading()
uni.showModal({
title: this.t('submitSuccessTitle'),
content: this.t('submitSuccessContent'),
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.t('submitFailed'),
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-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.time-slot {
width: calc(33.33% - 14rpx);
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;
}
.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>