feat: 优化首页服务入口与租住业务流程

补齐首页待办跳转、押金支付引导和多类租住服务页面能力,统一租客端关键业务流程体验。
This commit is contained in:
liangjian
2026-08-25 13:53:12 +08:00
parent 1111ac148b
commit 9fee2c3a74
41 changed files with 6985 additions and 933 deletions

View File

@@ -149,27 +149,31 @@
<scroll-view scroll-y class="popup-body" @scrolltolower="onNoticeScrollToBottom">
<view class="notice-content">
<view class="notice-item">
<view class="notice-item-zh">1、缴纳线上定金可锁定房源并预约专属看房,平台收取定金后该房源临时下架,不再接待其他客户看房。</view>
<view class="notice-item-zh">1、请在 5 分钟内支付定金或者款项,否则系统将自动取消这个订单,释放锁定的房源。</view>
<view class="notice-item-en">Please pay the deposit or full payment within 5 minutes. Otherwise, the system will automatically cancel this order and release the reserved housing unit.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">2、缴纳线上定金可锁定房源并预约专属看房,平台收取定金后该房源临时下架,不再接待其他客户看房。</view>
<view class="notice-item-en">Payment of the online deposit reserves the housing unit and arranges exclusive on-site viewing for you. After receiving the deposit, we will temporarily remove the property from the rental list and stop arranging viewings for other clients.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">2、请于约定时间完成实地看房,并在看房结束后 3 个工作日内签署租赁合同;若逾期未看房或未完成签约,视作您主动放弃房源,定金不予退还。</view>
<view class="notice-item-zh">3、请于约定时间完成实地看房,并在看房结束后 3 个工作日内签署租赁合同;若逾期未看房或未完成签约,视作您主动放弃房源,定金不予退还。</view>
<view class="notice-item-en">You shall complete the on-site viewing at the agreed time and sign the rental contract within 3 working days after the viewing. Failure to complete the viewing or sign the contract within the time limit shall be deemed your voluntary waiver of the property, and the deposit will not be refunded.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">3、如因租客个人原因取消看房、看房后不愿承租房屋或拒绝签署租赁合同,定金不予退还。</view>
<view class="notice-item-zh">4、如因租客个人原因取消看房、看房后不愿承租房屋或拒绝签署租赁合同,定金不予退还。</view>
<view class="notice-item-en">The deposit is non-refundable if you cancel the viewing, refuse to rent the property or decline to sign the contract due to personal reasons.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">4、若因我方房源信息虚假、房屋无法正常交付等我方过错,造成无法看房或签约,定金将全额按原支付路径退还。</view>
<view class="notice-item-zh">5、若因我方房源信息虚假、房屋无法正常交付等我方过错,造成无法看房或签约,定金将全额按原支付路径退还。</view>
<view class="notice-item-en">If the viewing or contract signing cannot be conducted due to our liabilities such as false property information or failure to deliver the housing normally, the full deposit will be refunded via your original payment channel.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">5、待双方正式签署租赁合同后,该定金可直接抵扣首期租金。</view>
<view class="notice-item-zh">6、待双方正式签署租赁合同后,该定金可直接抵扣首期租金。</view>
<view class="notice-item-en">After both parties formally sign the rental contract, this deposit may be directly deducted from your initial rent.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">6、您支付定金,即代表已完整知晓房源全部信息,并自愿接受本须知全部条款约束。</view>
<view class="notice-item-zh">7、您支付定金,即代表已完整知晓房源全部信息,并自愿接受本须知全部条款约束。</view>
<view class="notice-item-en">By making the deposit payment, you confirm that you have fully reviewed all property information and voluntarily accept all terms and conditions set forth in this notice.</view>
</view>
</view>
@@ -180,18 +184,91 @@
:class="{ 'notice-action-btn--disabled': !hasReadNotice }"
@click="confirmAndPayDeposit"
>
<text class="tn-text-bold">{{ $tr('同意并支付') }}</text>
<text class="tn-text-bold">{{ $tr('同意并继续') }}</text>
<text v-if="!hasReadNotice" class="tn-margin-left-xs tn-text-sm">({{ $tr('请滑动阅读至底部') }})</text>
</view>
</view>
</view>
</tn-popup>
<tn-popup v-model="showPaymentMethodPopup" mode="bottom" length="72%" :borderRadius="32" :closeBtn="true" @close="closePaymentMethodPopup">
<view class="payment-method-popup">
<view class="payment-method-popup__header">
<view class="payment-method-popup__eyebrow">{{ $tr('支付方式') }}</view>
<view class="payment-method-popup__title">{{ $tr('选择定金支付方式') }}</view>
<view class="payment-method-popup__desc">
{{ language === 'en'
? 'Choose the channel you want to use for the room-type application deposit.'
: '请选择房型申请定金的支付渠道,提交后可在“我的入住”继续查看进度。' }}
</view>
</view>
<view class="payment-method-popup__summary">
<view class="payment-method-popup__summary-label">{{ $tr('支付号') }}</view>
<view class="payment-method-popup__summary-value">
{{ (pendingPaymentMethodContext && pendingPaymentMethodContext.paymentNo) || $tr('提交后生成') }}
</view>
</view>
<view class="payment-method-card payment-method-card--wechat" @click="handlePaymentMethodSelect('wechat')">
<view class="payment-method-card__main">
<view class="payment-method-card__icon payment-method-card__icon--wechat">
<text class="tn-icon-wechat"></text>
</view>
<view class="payment-method-card__copy">
<view class="payment-method-card__title-row">
<view class="payment-method-card__title">{{ $tr('微信支付') }}</view>
<view class="payment-method-card__tag">{{ $tr('推荐') }}</view>
</view>
<view class="payment-method-card__desc">
{{ language === 'en'
? 'Continue with the standard online payment flow.'
: '继续拉起线上支付,适合直接完成定金支付。' }}
</view>
</view>
</view>
<view class="payment-method-card__arrow">
<text class="tn-icon-right"></text>
</view>
</view>
<view class="payment-method-card payment-method-card--offline" @click="handlePaymentMethodSelect('offline')">
<view class="payment-method-card__main">
<view class="payment-method-card__icon payment-method-card__icon--offline">
<text class="tn-icon-bank-card"></text>
</view>
<view class="payment-method-card__copy">
<view class="payment-method-card__title-row">
<view class="payment-method-card__title">{{ $tr('其他支付方式') }}</view>
</view>
<view class="payment-method-card__desc">
{{ language === 'en'
? 'View the configured phone, QR code, and offline payment instructions.'
: '查看后台配置的联系电话、二维码与线下支付说明。' }}
</view>
</view>
</view>
<view class="payment-method-card__arrow">
<text class="tn-icon-right"></text>
</view>
</view>
</view>
</tn-popup>
<deposit-other-payment-popup
v-model="showOfflinePaymentNotice"
:config="depositOtherPaymentConfig"
:loading="depositOtherPaymentConfigLoading"
@input="handleOfflinePaymentPopupInput"
/>
</view>
</template>
<script>
import { accountQueryBill, accountQueryBills, applicationFormDetail, paymentBillPay, paymentPayingBills, submitApplicationForm, sysdictPage } from '@/api/index.js'
import DepositOtherPaymentPopup from '@/components/DepositOtherPaymentPopup.vue'
import { saveActiveApplicationCode, saveHomePageTabId } from '@/utils/app-state'
import { createDefaultDepositOtherPaymentConfig, loadDepositOtherPaymentConfig } from '@/utils/deposit-other-payment'
import { compactEnglishText, localizePayMethodText } from '../../utils/select-room-normalizers'
import { getStoredUser } from '../../utils/flow'
@@ -725,6 +802,7 @@ const BUSINESS_TYPE_CONFIG = {
export default {
components: {
DepositOtherPaymentPopup,
TagSelector,
DateRangeSelector,
RoomTypeSelector,
@@ -747,9 +825,15 @@ export default {
scrollTop: 0,
lastAutoRemark: '',
showDepositNotice: false,
showPaymentMethodPopup: false,
showOfflinePaymentNotice: false,
hasReadNotice: false,
isClosingDepositNoticeForPayment: false,
pendingDepositPaymentContext: null
isSwitchingPaymentMethodPopup: false,
pendingDepositPaymentContext: null,
pendingPaymentMethodContext: null,
depositOtherPaymentConfig: createDefaultDepositOtherPaymentConfig(),
depositOtherPaymentConfigLoading: false
}
},
computed: {
@@ -882,6 +966,9 @@ export default {
language() {
this.syncLocalizedDraftContent()
this.loadDictOptions()
if (this.showOfflinePaymentNotice) {
this.loadDepositOtherPaymentConfig()
}
}
},
onPageScroll(event) {
@@ -1227,14 +1314,66 @@ export default {
},
async resolvePaymentPreview(referenceTypeNo = '') {
const context = await this.resolveApplicationDetailPaymentContext(referenceTypeNo).catch(() => null)
const billNo = context && context.billNo ? context.billNo : ''
const pendingPaymentOrder = billNo ? await this.getPendingPaymentOrder(billNo).catch(() => null) : null
return {
referenceTypeNo: context && context.referenceTypeNo ? context.referenceTypeNo : String(referenceTypeNo || '').trim(),
billNo,
paymentNo: this.getPendingPaymentOrderNo(pendingPaymentOrder) || (context && context.paymentNo ? context.paymentNo : ''),
pendingPaymentOrder
const normalizedReferenceTypeNo = context && context.referenceTypeNo
? context.referenceTypeNo
: String(referenceTypeNo || '').trim()
let billNo = context && context.billNo ? context.billNo : ''
let pendingPaymentOrder = billNo ? await this.getPendingPaymentOrder(billNo).catch(() => null) : null
let requiresDepositPayment = !!billNo
if (normalizedReferenceTypeNo) {
const billRes = await accountQueryBills({
referenceTypeNo: normalizedReferenceTypeNo,
status: 10,
paymentStatus: 0,
ProviderType: 'WechatMiniProgram'
}, this.language)
const billList = this.normalizeBillList(billRes)
const matchedBill = Array.isArray(billList) && billList.length
? ((billNo
? billList.find(item => String(this.getBillNo(item) || '').trim() === String(billNo || '').trim())
: null) || billList[0])
: null
if (matchedBill) {
billNo = this.getBillNo(matchedBill)
requiresDepositPayment = !!billNo
if (!pendingPaymentOrder && billNo) {
pendingPaymentOrder = await this.getPendingPaymentOrder(billNo).catch(() => null)
}
if (!pendingPaymentOrder) {
pendingPaymentOrder = this.buildPendingPaymentOrderFromBillDetail(matchedBill)
}
} else {
billNo = ''
pendingPaymentOrder = null
requiresDepositPayment = false
}
}
return {
referenceTypeNo: normalizedReferenceTypeNo,
billNo,
paymentNo: requiresDepositPayment
? (this.getPendingPaymentOrderNo(pendingPaymentOrder) || (context && context.paymentNo ? context.paymentNo : ''))
: '',
pendingPaymentOrder,
requiresDepositPayment
}
},
confirmSubmitRequest() {
return new Promise((resolve) => {
uni.showModal({
title: this.$tr('提交前确认'),
content: this.$tr('确认提交当前申请单吗?'),
// WeChat Mini Program limits modal button text length.
// Keep English actions short so the modal does not fail silently.
confirmText: this.language === 'en' ? 'OK' : this.$tr('确认提交'),
cancelText: this.language === 'en' ? 'Back' : this.$tr('再想想'),
success: (res) => resolve(!!(res && res.confirm)),
fail: () => resolve(false)
})
})
},
openDepositNotice(paymentContext = {}) {
this.pendingDepositPaymentContext = {
@@ -1243,6 +1382,7 @@ export default {
paymentNo: String(paymentContext.paymentNo || '').trim(),
pendingPaymentOrder: paymentContext.pendingPaymentOrder || null
}
this.pendingPaymentMethodContext = null
this.hasReadNotice = false
this.showDepositNotice = true
},
@@ -1254,11 +1394,92 @@ export default {
this.showDepositNotice = false
this.hasReadNotice = false
this.pendingDepositPaymentContext = null
this.pendingPaymentMethodContext = null
this.goStayServiceFlow()
},
onNoticeScrollToBottom() {
this.hasReadNotice = true
},
openPaymentMethodPopup(paymentContext = {}) {
this.pendingPaymentMethodContext = {
referenceTypeNo: String(paymentContext.referenceTypeNo || '').trim(),
billNo: String(paymentContext.billNo || '').trim(),
paymentNo: String(paymentContext.paymentNo || '').trim(),
pendingPaymentOrder: paymentContext.pendingPaymentOrder || null
}
this.showPaymentMethodPopup = true
},
closePaymentMethodPopup() {
if (this.isSwitchingPaymentMethodPopup) {
this.isSwitchingPaymentMethodPopup = false
return
}
this.showPaymentMethodPopup = false
this.pendingPaymentMethodContext = null
this.goStayServiceFlow()
},
loadDepositOtherPaymentConfig() {
this.depositOtherPaymentConfigLoading = true
return loadDepositOtherPaymentConfig(this.language)
.then((config) => {
this.depositOtherPaymentConfig = config || createDefaultDepositOtherPaymentConfig()
})
.catch((error) => {
console.error('load deposit other payment config failed', error)
this.depositOtherPaymentConfig = createDefaultDepositOtherPaymentConfig()
})
.finally(() => {
this.depositOtherPaymentConfigLoading = false
})
},
async handlePaymentMethodSelect(type = '') {
const paymentContext = this.pendingPaymentMethodContext || {}
this.isSwitchingPaymentMethodPopup = true
this.showPaymentMethodPopup = false
if (type === 'offline') {
setTimeout(() => {
this.showOfflinePaymentNotice = true
this.loadDepositOtherPaymentConfig()
}, 40)
return
}
try {
await this.payByPaymentContext({
referenceTypeNo: paymentContext.referenceTypeNo,
billNo: paymentContext.billNo,
pendingPaymentOrder: paymentContext.pendingPaymentOrder,
description: this.$tr('支付定金')
})
uni.showToast({ title: this.$tr('支付成功'), icon: 'success' })
} catch (error) {
const errMsg = error && (error.errMsg || error.message || error.msg)
? (error.errMsg || error.message || error.msg)
: this.$tr('支付失败')
if (/cancel/i.test(errMsg)) {
uni.showToast({ title: this.$tr('已取消支付'), icon: 'none' })
} else {
uni.showToast({ title: errMsg, icon: 'none' })
}
} finally {
uni.hideLoading()
this.pendingPaymentMethodContext = null
setTimeout(() => {
this.goStayServiceFlow()
}, 300)
}
},
handleOfflinePaymentPopupInput(value) {
this.showOfflinePaymentNotice = !!value
if (value) {
return
}
this.pendingPaymentMethodContext = null
setTimeout(() => {
this.goStayServiceFlow()
}, 300)
},
async payByPaymentContext(context = {}) {
let referenceTypeNo = String(context.referenceTypeNo || '').trim()
let billNo = String(context.billNo || '').trim()
@@ -1367,31 +1588,66 @@ export default {
const paymentContext = this.pendingDepositPaymentContext || {}
this.isClosingDepositNoticeForPayment = true
this.showDepositNotice = false
this.hasReadNotice = false
this.pendingDepositPaymentContext = null
setTimeout(() => {
this.openPaymentMethodPopup(paymentContext)
}, 40)
},
async executeSubmitRequest() {
this.isSubmitting = true
uni.showLoading({
title: this.$tr('提交中...')
})
try {
await this.payByPaymentContext({
referenceTypeNo: paymentContext.referenceTypeNo,
billNo: paymentContext.billNo,
pendingPaymentOrder: paymentContext.pendingPaymentOrder,
description: this.$tr('支付定金')
})
uni.showToast({ title: this.$tr('支付成功'), icon: 'success' })
} catch (error) {
const errMsg = error && (error.errMsg || error.message || error.msg)
? (error.errMsg || error.message || error.msg)
: this.$tr('支付失败')
if (/cancel/i.test(errMsg)) {
uni.showToast({ title: this.$tr('已取消支付'), icon: 'none' })
} else {
uni.showToast({ title: errMsg, icon: 'none' })
const payload = this.buildSubmitPayload()
const response = await submitApplicationForm(payload, this.language)
const applicationCode = extractSubmitApplicationCode(response)
if (this.isRoomTypeApply && !applicationCode) {
throw new Error(this.$tr('申请单编号缺失'))
}
if (applicationCode) {
saveActiveApplicationCode(applicationCode)
}
} finally {
uni.hideLoading()
this.hasReadNotice = false
this.isClosingDepositNoticeForPayment = false
this.pendingDepositPaymentContext = null
if (this.isRoomTypeApply) {
const paymentContext = await this.resolvePaymentPreview(applicationCode).catch(() => null)
if (paymentContext && paymentContext.requiresDepositPayment === false) {
uni.showToast({
title: this.$tr('申请单提交成功'),
icon: 'success'
})
setTimeout(() => {
this.goStayServiceFlow()
}, 800)
return
}
this.openDepositNotice({
...(paymentContext || {}),
referenceTypeNo: (paymentContext && paymentContext.referenceTypeNo) || applicationCode
})
return
}
uni.showToast({
title: this.$tr('申请单提交成功'),
icon: 'success'
})
setTimeout(() => {
this.goStayServiceFlow()
}, 300)
saveHomePageTabId('stay-service')
uni.reLaunch({
url: '/pages/index/index?pageId=stay-service'
})
}, 800)
} catch (error) {
console.error('submit application form failed', error)
uni.hideLoading()
throw error
} finally {
this.isSubmitting = false
}
},
submitRequest() {
@@ -1408,58 +1664,21 @@ export default {
return
}
this.isSubmitting = true
uni.showLoading({
title: this.$tr('提交中...')
})
const payload = this.buildSubmitPayload()
return submitApplicationForm(payload, this.language)
.then(async (response) => {
const applicationCode = extractSubmitApplicationCode(response)
if (this.isRoomTypeApply && !applicationCode) {
throw new Error(this.$tr('申请单编号缺失'))
}
if (applicationCode) {
saveActiveApplicationCode(applicationCode)
}
uni.hideLoading()
if (this.isRoomTypeApply) {
const paymentContext = await this.resolvePaymentPreview(applicationCode).catch(() => ({
referenceTypeNo: applicationCode,
billNo: '',
paymentNo: '',
pendingPaymentOrder: null
}))
this.openDepositNotice({
...paymentContext,
referenceTypeNo: paymentContext.referenceTypeNo || applicationCode
})
return
}
uni.showToast({
title: this.$tr('申请单提交成功'),
icon: 'success'
})
setTimeout(() => {
saveHomePageTabId('stay-service')
uni.reLaunch({
url: '/pages/index/index?pageId=stay-service'
})
}, 800)
return this.confirmSubmitRequest()
.then((confirmed) => {
if (!confirmed) {
return null
}
return this.executeSubmitRequest()
})
.catch((error) => {
console.error('submit application form failed', error)
uni.hideLoading()
uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('申请单提交失败'),
icon: 'none'
})
})
.finally(() => {
this.isSubmitting = false
if (!error) {
return
}
uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('申请单提交失败'),
icon: 'none'
})
})
}
}
@@ -1805,6 +2024,172 @@ $shadow-active: 0 8rpx 24rpx rgba($brand-primary, 0.22);
margin-top: 16rpx;
}
.payment-method-popup {
padding: 30rpx 30rpx calc(28rpx + env(safe-area-inset-bottom));
background:
radial-gradient(circle at top right, rgba($brand-primary, 0.16) 0%, rgba($brand-primary, 0) 36%),
linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
border-radius: 32rpx 32rpx 0 0;
}
.payment-method-popup__header {
padding: 8rpx 4rpx 20rpx;
}
.payment-method-popup__eyebrow {
display: inline-flex;
align-items: center;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba($brand-primary, 0.1);
color: $brand-primary;
font-size: 22rpx;
font-weight: 700;
}
.payment-method-popup__title {
margin-top: 18rpx;
font-size: 40rpx;
line-height: 1.25;
color: $text-primary;
font-weight: 700;
}
.payment-method-popup__desc {
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.7;
color: $text-secondary;
}
.payment-method-popup__summary {
margin-bottom: 22rpx;
padding: 22rpx 26rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.92);
border: 1rpx solid rgba($brand-primary, 0.08);
box-shadow: 0 12rpx 28rpx rgba($brand-primary, 0.08);
}
.payment-method-popup__summary-label {
font-size: 22rpx;
color: $text-tertiary;
}
.payment-method-popup__summary-value {
margin-top: 8rpx;
font-size: 28rpx;
line-height: 1.5;
color: $text-primary;
font-weight: 700;
word-break: break-all;
}
.payment-method-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
margin-top: 18rpx;
padding: 26rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.92);
border: 1rpx solid rgba($brand-primary, 0.08);
box-shadow: 0 14rpx 30rpx rgba($brand-primary, 0.08);
}
.payment-method-card:active {
transform: scale(0.985);
}
.payment-method-card--wechat {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(239, 253, 244, 0.96) 100%);
border-color: rgba(34, 197, 94, 0.14);
}
.payment-method-card--offline {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 250, 255, 0.96) 100%);
}
.payment-method-card__main {
display: flex;
align-items: center;
gap: 18rpx;
flex: 1;
min-width: 0;
}
.payment-method-card__icon {
width: 88rpx;
height: 88rpx;
border-radius: 26rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 42rpx;
flex-shrink: 0;
}
.payment-method-card__icon--wechat {
background: rgba(34, 197, 94, 0.12);
color: #16A34A;
}
.payment-method-card__icon--offline {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
}
.payment-method-card__copy {
flex: 1;
min-width: 0;
}
.payment-method-card__title-row {
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
}
.payment-method-card__title {
font-size: 30rpx;
line-height: 1.35;
color: $text-primary;
font-weight: 700;
}
.payment-method-card__tag {
padding: 6rpx 14rpx;
border-radius: 999rpx;
background: rgba(34, 197, 94, 0.12);
color: #15803D;
font-size: 20rpx;
font-weight: 700;
}
.payment-method-card__desc {
margin-top: 10rpx;
font-size: 22rpx;
line-height: 1.7;
color: $text-secondary;
}
.payment-method-card__arrow {
width: 56rpx;
height: 56rpx;
border-radius: 999rpx;
background: rgba(148, 163, 184, 0.12);
color: #64748B;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
flex-shrink: 0;
}
/* 导航栏背景色滚动过渡 */
.submit-request-page ::v-deep .tn-custom-nav-bar__bar {
transition: background-color 0.3s ease, box-shadow 0.3s ease;