Files
QSY-lvjuxcx/pages/index/component/MyApplicationTab.vue
2026-06-29 09:42:53 +08:00

1422 lines
46 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="my-application-page tn-flex tn-flex-direction-column" :style="{ height: 'calc(100vh - 100rpx - env(safe-area-inset-bottom))', boxSizing: 'border-box', paddingTop: (vuex_custom_bar_height) + 'px' }">
<view class="page-header tn-padding">
<view class="tn-text-bold tn-text-xl tn-color-black page-title">{{ t('pageTitle') }}</view>
<view class="tn-color-gray tn-text-sm tn-margin-top-xs">{{ t('pageDesc') }}</view>
</view>
<scroll-view v-if="list.length" scroll-y class="list-scroll-view tn-padding" @scrolltolower="loadMore">
<view
v-for="(item, index) in list"
:key="getApplicationKey(item)"
class="application-card tn-margin-bottom-lg"
@click="goToApplicationDetail(item)"
>
<view class="card-head tn-flex tn-flex-row-between tn-flex-col-center">
<view class="card-title-wrap tn-flex tn-flex-col-center">
<view class="title-icon tn-flex tn-flex-col-center tn-flex-row-center tn-margin-right-sm">
<text :class="item.isViewingApplication ? 'tn-icon-calendar' : 'tn-icon-safe'"></text>
</view>
<view class="card-title-block">
<view class="card-title">{{ getApplicationTitle(item) }}</view>
</view>
</view>
<view :class="['status-badge', 'status-' + resolveStatusType(item)]">
{{ getDisplayStatusText(item) }}
</view>
</view>
<view class="card-body tn-margin-top-lg">
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('apartmentLabel') }}</text>
<text class="info-value">{{ item.intendedAreaName || '--' }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('occupantLabel') }}</text>
<text class="info-value">{{ item.userName || item.phone || '--' }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ item.isViewingApplication ? t('viewingTimeLabel') : t('leaseTermLabel') }}</text>
<text class="info-value">{{ formatDateRange(item.start, item.end) }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('submittedAtLabel') }}</text>
<text class="info-value">{{ formatDateTime(item.createTime) }}</text>
</view>
</view>
<view v-if="item.remark" class="remark-panel tn-margin-top-lg">
<view class="remark-line">
<text class="remark-label">{{ t('remarkLabel') }}</text>
<text class="remark-text">{{ item.remark }}</text>
</view>
</view>
<view v-if="getStatusReminderText(item) && !shouldPayDeposit(item)" class="reminder-panel tn-margin-top-lg">
<view class="reminder-title">{{ t('statusReminderTitle') }}</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('statusReminderTitle') }}</text>
<text class="info-value">{{ getStatusReminderText(item) }}</text>
</view>
</view>
<view v-if="!isPaidApplication(item) && getApplicationPendingPaymentOrder(item)" class="pending-payment-panel tn-margin-top-lg" @click.stop>
<view class="pending-payment-title">{{ t('pendingPaymentTitle') }}</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('pendingPaymentAmountLabel') }}</text>
<text class="info-value info-value--highlight">¥{{ formatMoney(getApplicationPendingPaymentAmount(item)) }}</text>
</view>
<view v-if="getApplicationPendingPaymentExpireText(item)" class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('pendingPaymentExpireLabel') }}</text>
<text class="info-value" :class="{ 'info-value--expired': isApplicationPendingPaymentExpired(item) }">
{{ getApplicationPendingPaymentExpireText(item) }}
</text>
</view>
<view v-if="getApplicationPendingPaymentDescription(item)" class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ t('pendingPaymentDescLabel') }}</text>
<text class="info-value">{{ getApplicationPendingPaymentDescription(item) }}</text>
</view>
</view>
<view class="card-footer tn-margin-top-xl" @click.stop>
<view
v-if="canCancelOrder(item)"
class="cancel-btn"
:class="{ 'cancel-btn--loading': cancelingReferenceTypeNo === resolveReferenceTypeNo(item) }"
:data-reference-type-no="resolveReferenceTypeNo(item)"
@click.stop="cancelOrderAction"
>
<text class="tn-text-bold">{{ getCancelButtonText(item) }}</text>
</view>
<view
v-if="!isPaidApplication(item) && getApplicationPendingPaymentOrder(item)"
class="pay-btn"
:data-reference-type-no="resolveReferenceTypeNo(item)"
@click.stop="continuePendingPaymentAction"
>
<text class="tn-text-bold">{{ getPendingPaymentActionText(item) }}</text>
</view>
<view
v-else-if="canViewBill(item)"
class="pay-btn"
:data-reference-type-no="resolveReferenceTypeNo(item)"
@click.stop="payDepositAction"
>
<text class="tn-text-bold">{{ t('payDeposit') }}</text>
</view>
</view>
</view>
<view v-if="hasMore || loadingMore" class="load-more-item tn-flex tn-flex-direction-column tn-flex-col-center tn-flex-row-center tn-padding-bottom">
<tn-loading v-if="loadingMore" mode="circle" color="#00BBE8"></tn-loading>
<text v-else-if="hasMore" class="tn-color-gray">{{ t('pullUpMore') }}</text>
<text v-else class="tn-color-gray">{{ t('noMore') }}</text>
</view>
</scroll-view>
<view v-else-if="!loading" class="empty-panel tn-flex tn-flex-direction-column tn-flex-col-center tn-flex-row-center animation-fade-in tn-padding-left tn-padding-right">
<tn-empty icon="order" :text="t('emptyTitle')"></tn-empty>
<view class="empty-desc tn-margin-top tn-text-center">{{ t('emptyDesc') }}</view>
<view class="empty-action tn-margin-top-xl" @click="goSelectRoom">
<text class="tn-text-bold">{{ t('goSelectRoom') }}</text>
</view>
</view>
<view v-if="loading && !list.length" class="empty-panel tn-flex tn-flex-direction-column tn-flex-col-center tn-flex-row-center tn-padding-left tn-padding-right">
<tn-loading mode="circle" color="#00BBE8" size="60"></tn-loading>
<view class="empty-desc tn-margin-top-lg">{{ t('loadingDesc') }}</view>
</view>
<!-- 支付定金须知弹窗 -->
<tn-popup v-model="showDepositNotice" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true" @close="closeDepositNotice">
<view class="deposit-notice-popup">
<view class="popup-header">
<view class="popup-title">{{ t('depositNoticeTitle') }}</view>
</view>
<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-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-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-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-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-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-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>
</scroll-view>
<view class="popup-footer">
<view
class="action-btn"
:class="{ 'action-btn--disabled': !hasReadNotice }"
@click="confirmAndPayDeposit"
>
<text class="tn-text-bold">{{ t('agreeAndPay') }}</text>
<text v-if="!hasReadNotice" class="tn-margin-left-xs tn-text-sm">({{ t('scrollToRead') }})</text>
</view>
</view>
</view>
</tn-popup>
</view>
</template>
<script>
import { applicationFormMePage, accountQueryBills, paymentBillPay, paymentPayingBills, userDetailCancel } from '@/api/index.js'
import { getMyApplicationList, saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state'
function formatPageResponse(response) {
const payload = response && response.data ? response.data : response
return payload && typeof payload === 'object' ? payload : {}
}
function normalizeApplicationList(list = []) {
if (!Array.isArray(list)) {
return []
}
return list
.filter(item => item && typeof item === 'object')
.map((item) => {
const remark = item && item.remark ? String(item.remark) : ''
const isViewingApplication = /预约看房申请|看房时间[::]|意向房型[::]|意向区域[::]|viewing request|viewing time[::]|preferred room type[::]|preferred area[::]/i.test(remark)
return {
...item,
isViewingApplication
}
})
}
function parseExpireTimeToTimestamp(value) {
if (value === null || typeof value === 'undefined' || value === '') {
return 0
}
if (typeof value === 'number' && Number.isFinite(value)) {
return value > 1e12 ? value : value * 1000
}
const text = String(value).trim()
if (!text) {
return 0
}
if (/^\d+$/.test(text)) {
const numericValue = Number(text)
if (!Number.isFinite(numericValue)) {
return 0
}
return numericValue > 1e12 ? numericValue : numericValue * 1000
}
const normalizedText = text.includes('T') ? text : text.replace(/-/g, '/')
const timestamp = Date.parse(normalizedText)
return Number.isFinite(timestamp) ? timestamp : 0
}
function padCountdownUnit(value) {
return String(Math.max(0, Number(value) || 0)).padStart(2, '0')
}
export default {
i18n: {
zh: {
pageTitle: '我的申请',
pageDesc: '您的入住和预约看房申请记录',
toolbarSummary: '共 {total} 条申请',
refreshing: '刷新中...',
refresh: '刷新状态',
apartmentLabel: '所属公寓',
occupantLabel: '入住人',
viewingTimeLabel: '预约时间',
leaseTermLabel: '预计租期',
submittedAtLabel: '提交时间',
remarkLabel: '补充说明',
payDeposit: '支付定金',
continuePay: '继续支付',
pendingPaymentTitle: '未完成支付订单',
pendingPaymentOrderNoLabel: '支付单号',
pendingPaymentAmountLabel: '订单金额',
pendingPaymentDescLabel: '支付说明',
pendingPaymentExpireLabel: '剩余支付时间',
pendingPaymentExpired: '已失效',
noMore: '没有更多申请了',
pullUpMore: '上拉加载更多',
emptyTitle: '暂无申请记录',
emptyDesc: '您还没有提交任何申请,去选房后可发起入住申请或预约看房。',
goSelectRoom: '去选房',
loadingDesc: '正在同步您的入住申请记录,请稍候。',
statusPending: '待审核',
statusApproved: '已通过',
statusRejected: '已拒绝',
statusFinished: '已结束',
statusProcessing: '处理中',
statusPendingPayment: '请支付定金',
statusWaitingRoomAssign: '等待预订中心分配房间',
statusReminderTitle: '当前进度提醒',
refreshEmptySuccess: '未查到申请单,已同步预订单',
refreshSuccess: '申请状态已刷新',
loadError: '申请记录加载失败',
cancelOrder: '取消订单',
cancelOrderLoading: '取消中...',
cancelOrderTitle: '取消订单',
cancelOrderContent: '取消后将关闭当前未支付的定金订单,是否继续?',
cancelOrderSuccess: '订单已取消',
cancelOrderFailed: '取消订单失败',
viewingApplicationTitle: '预约看房申请',
stayApplicationTitle: '入住申请',
missingBillCode: '申请单编号缺失',
missingApplicationCode: '申请编号缺失',
dateRangeJoiner: ' 至 ',
depositNoticeTitle: '租房定金须知',
agreeAndPay: '同意并支付',
scrollToRead: '请滑动阅读至底部',
fetchPaymentParamsFailed: '获取支付参数失败',
fetchWechatLoginCodeFailed: '获取微信登录凭证失败',
loadingBill: '正在获取账单...',
noPendingDepositBill: '暂无待支付定金账单',
loadingPayment: '正在拉起支付...',
depositPaymentDesc: '支付定金',
paymentSuccess: '支付成功',
paymentCanceled: '已取消支付',
paymentFailed: '支付失败'
},
en: {
pageTitle: 'My Applications',
pageDesc: 'Your stay and viewing application records',
toolbarSummary: '{total} applications',
refreshing: 'Refreshing...',
refresh: 'Refresh Status',
apartmentLabel: 'Apartment',
occupantLabel: 'Resident',
viewingTimeLabel: 'Viewing Time',
leaseTermLabel: 'Lease Term',
submittedAtLabel: 'Submitted At',
remarkLabel: 'Remarks',
payDeposit: 'Pay Deposit',
continuePay: 'Continue Pay',
pendingPaymentTitle: 'Unfinished Payment',
pendingPaymentOrderNoLabel: 'Payment Order No.',
pendingPaymentAmountLabel: 'Order Amount',
pendingPaymentDescLabel: 'Description',
pendingPaymentExpireLabel: 'Time Left',
pendingPaymentExpired: 'Expired',
noMore: 'No more applications',
pullUpMore: 'Pull up to load more',
emptyTitle: 'No applications yet',
emptyDesc: 'You have not submitted any application yet. Go select a room to start a stay or viewing application.',
goSelectRoom: 'Select Room',
loadingDesc: 'Syncing your application records. Please wait.',
statusPending: 'Pending',
statusApproved: 'Approved',
statusRejected: 'Rejected',
statusFinished: 'Closed',
statusProcessing: 'Processing',
statusPendingPayment: 'Please Pay Deposit',
statusWaitingRoomAssign: 'Waiting for Room Assignment',
statusReminderTitle: 'Current Progress',
refreshEmptySuccess: 'No application found. Reservation records synced.',
refreshSuccess: 'Application status refreshed',
loadError: 'Failed to load applications',
cancelOrder: 'Cancel Order',
cancelOrderLoading: 'Canceling...',
cancelOrderTitle: 'Cancel Order',
cancelOrderContent: 'This will close the current unpaid deposit order. Continue?',
cancelOrderSuccess: 'Order canceled',
cancelOrderFailed: 'Failed to cancel order',
viewingApplicationTitle: 'Viewing Request',
stayApplicationTitle: 'Stay Application',
missingBillCode: 'Missing application bill number',
missingApplicationCode: 'Missing application number',
dateRangeJoiner: ' to ',
depositNoticeTitle: 'Rental Deposit Notice',
agreeAndPay: 'Agree and Pay',
scrollToRead: 'Scroll to bottom',
fetchPaymentParamsFailed: 'Failed to get payment parameters',
fetchWechatLoginCodeFailed: 'Failed to get WeChat login code',
loadingBill: 'Loading bill...',
noPendingDepositBill: 'No pending deposit bill available',
loadingPayment: 'Opening payment...',
depositPaymentDesc: 'Deposit Payment',
paymentSuccess: 'Payment succeeded',
paymentCanceled: 'Payment canceled',
paymentFailed: 'Payment failed'
}
},
data() {
return {
currentIndex: 0,
status: 0,
pageIndex: 1,
pageSize: 10,
total: 0,
list: [],
loading: false,
loadingMore: false,
hasMore: true,
showDepositNotice: false,
hasReadNotice: false,
pendingDepositReferenceTypeNo: '',
countdownNow: Date.now(),
cancelingReferenceTypeNo: ''
}
},
watch: {
language() {
this.reloadList(true)
}
},
computed: {
language() {
return this.$getLanguage()
}
},
onShow() {
this.load()
},
mounted() {
this.startCountdownTimer()
},
beforeDestroy() {
this.clearCountdownTimer()
},
methods: {
t(key, params) {
return this.$tm('pages.index.component.MyApplicationTab', key, params)
},
load(force = false) {
return this.reloadList(force)
},
getStatusText(status) {
const map = {
0: this.t('statusPending'),
10: this.t('statusApproved'),
20: this.t('statusRejected'),
99: this.t('statusFinished')
}
return map[status] || this.t('statusProcessing')
},
getDisplayStatusText(item = {}) {
if (this.$getLanguage() !== 'en' && item.statusDesc) {
return item.statusDesc
}
return this.getStatusText(item.status)
},
resolveStatusType(source = {}) {
const item = source && typeof source === 'object'
? source
: { status: source }
const status = Number(item.status)
if (status === 10) {
return 'approved'
}
if (status === 20) {
return 'rejected'
}
if (status === 99) {
return 'finished'
}
return 'pending'
},
isPaidApplication(item = {}) {
return !item.isViewingApplication && Number(item.payStatus) === 10
},
shouldPayDeposit(item = {}) {
return !item.isViewingApplication
&& Number(item.status) === 0
&& Number(item.payStatus) !== 10
},
isWaitingRoomAssignment(item = {}) {
return !item.isViewingApplication
&& Number(item.status) === 0
&& Number(item.payStatus) === 10
},
getStatusReminderText(item = {}) {
if (this.shouldPayDeposit(item)) {
return this.t('statusPendingPayment')
}
if (this.isWaitingRoomAssignment(item)) {
return this.t('statusWaitingRoomAssign')
}
return ''
},
formatDateTime(value) {
if (!value) {
return '--'
}
const text = String(value).replace('T', ' ')
return text.length > 16 ? text.slice(0, 16) : text
},
formatDate(value) {
if (!value) {
return '--'
}
return String(value).slice(0, 10)
},
formatDateRange(start, end) {
if (!start && !end) {
return '--'
}
if (start && end) {
const startText = this.formatDateTime(start)
const endText = this.formatDateTime(end)
if (String(start).includes('T') || String(end).includes('T')) {
return `${startText}${this.t('dateRangeJoiner')}${endText}`
}
}
return `${this.formatDate(start)}${this.t('dateRangeJoiner')}${this.formatDate(end)}`
},
formatMoney(value) {
const amount = Number(value || 0)
return amount.toFixed(2)
},
getBillNo(item = {}) {
return item.no || item.billNo || ''
},
normalizeBillList(response) {
const payload = response && response.data ? response.data : response
if (Array.isArray(payload)) {
return payload.filter(item => item && typeof item === 'object')
}
if (payload && Array.isArray(payload.data)) {
return payload.data.filter(item => item && typeof item === 'object')
}
return []
},
parsePaymentInfo(paymentInfo) {
if (!paymentInfo) {
return {}
}
if (typeof paymentInfo === 'object') {
return paymentInfo
}
const text = String(paymentInfo).trim()
if (!text) {
return {}
}
try {
return JSON.parse(text)
} catch (error) {
return {}
}
},
normalizeWechatPayOptions(paymentInfo) {
const source = this.parsePaymentInfo(paymentInfo)
return {
provider: 'wxpay',
timeStamp: String(source.timeStamp || source.timestamp || source.time_stamp || ''),
nonceStr: source.nonceStr || source.noncestr || source.nonce_str || '',
package: source.package || source.packageValue || source.package_value || '',
signType: source.signType || source.sign_type || 'MD5',
paySign: source.paySign || source.paysign || source.pay_sign || ''
}
},
requestWechatPayment(paymentInfo) {
const payOptions = this.normalizeWechatPayOptions(paymentInfo)
if (!payOptions.timeStamp || !payOptions.nonceStr || !payOptions.package || !payOptions.paySign) {
return Promise.reject(new Error(this.t('fetchPaymentParamsFailed')))
}
return new Promise((resolve, reject) => {
uni.requestPayment({
...payOptions,
success: resolve,
fail: reject
})
})
},
getWechatLoginCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => {
if (!res || !res.code) {
reject(new Error(this.t('fetchWechatLoginCodeFailed')))
return
}
resolve(res.code)
},
fail: reject
})
})
},
normalizePayingBills(response) {
const payload = response && response.data ? response.data : response
if (Array.isArray(payload)) {
return payload.filter(item => item && typeof item === 'object')
}
if (payload && Array.isArray(payload.data)) {
return payload.data.filter(item => item && typeof item === 'object')
}
return []
},
async getPendingPaymentOrder(billNo = '') {
const normalizedBillNo = String(billNo || '').trim()
if (!normalizedBillNo) {
return null
}
const response = await paymentPayingBills({
BillNo: normalizedBillNo
})
const orderList = this.normalizePayingBills(response)
return orderList.length ? orderList[0] : null
},
getApplicationPendingPaymentOrder(item = {}) {
return item && item.pendingPaymentOrder ? item.pendingPaymentOrder : null
},
getApplicationPendingPaymentOrderNo(item = {}) {
const order = this.getApplicationPendingPaymentOrder(item)
return order ? (order.payRequestNo || order.payNo || order.paymentNo || '--') : '--'
},
getApplicationPendingPaymentAmount(item = {}) {
const order = this.getApplicationPendingPaymentOrder(item)
const bill = item && item.pendingDepositBill ? item.pendingDepositBill : {}
if (order && (order.amount || order.amount === 0)) {
return order.amount
}
return bill.remainingAmount || bill.amount || 0
},
getApplicationPendingPaymentDescription(item = {}) {
const order = this.getApplicationPendingPaymentOrder(item)
return order ? (order.description || '') : ''
},
startCountdownTimer() {
if (this._countdownTimer) {
return
}
this._countdownTimer = setInterval(() => {
this.countdownNow = Date.now()
}, 1000)
},
clearCountdownTimer() {
if (this._countdownTimer) {
clearInterval(this._countdownTimer)
this._countdownTimer = null
}
},
getPendingPaymentExpireTimestamp(order = {}) {
return parseExpireTimeToTimestamp(order.expireTime || (order.cashier && order.cashier.expireTime) || '')
},
getPendingPaymentRemainingSeconds(order = {}) {
const expireTimestamp = this.getPendingPaymentExpireTimestamp(order)
if (!expireTimestamp) {
return 0
}
return Math.max(0, Math.ceil((expireTimestamp - this.countdownNow) / 1000))
},
formatPendingPaymentCountdown(seconds = 0) {
const totalSeconds = Math.max(0, Number(seconds) || 0)
const day = Math.floor(totalSeconds / 86400)
const hour = Math.floor((totalSeconds % 86400) / 3600)
const minute = Math.floor((totalSeconds % 3600) / 60)
const second = totalSeconds % 60
const timeText = `${padCountdownUnit(hour)}:${padCountdownUnit(minute)}:${padCountdownUnit(second)}`
return day > 0
? (this.language === 'en' ? `${day}d ${timeText}` : `${day}天 ${timeText}`)
: timeText
},
isApplicationPendingPaymentExpired(item = {}) {
const order = this.getApplicationPendingPaymentOrder(item)
if (!order) {
return false
}
const expireTimestamp = this.getPendingPaymentExpireTimestamp(order)
if (!expireTimestamp) {
return false
}
return expireTimestamp <= this.countdownNow
},
getApplicationPendingPaymentExpireText(item = {}) {
const order = this.getApplicationPendingPaymentOrder(item)
if (!order) {
return ''
}
const expireTimestamp = this.getPendingPaymentExpireTimestamp(order)
if (!expireTimestamp) {
return ''
}
if (expireTimestamp <= this.countdownNow) {
return this.t('pendingPaymentExpired')
}
return this.formatPendingPaymentCountdown(this.getPendingPaymentRemainingSeconds(order))
},
getPendingPaymentActionText(item = {}) {
return this.isApplicationPendingPaymentExpired(item) ? this.t('payDeposit') : this.t('continuePay')
},
async loadPendingPaymentOrdersForList(list = []) {
const baseList = Array.isArray(list) ? list : []
const requestToken = Date.now()
this._pendingPaymentRequestToken = requestToken
await Promise.all(baseList.map(async (item, index) => {
if (!this.canViewBill(item)) {
return
}
const referenceTypeNo = this.resolveReferenceTypeNo(item)
if (!referenceTypeNo) {
return
}
try {
console.log('[MyApplicationTab] query bills for pending payment order', referenceTypeNo)
const billRes = await accountQueryBills({
referenceTypeNo,
status: 10,
paymentStatus: 0,
ProviderType: 'WechatMiniProgram'
})
const billList = this.normalizeBillList(billRes)
const bill = Array.isArray(billList) && billList.length ? billList[0] : null
if (!bill || this._pendingPaymentRequestToken !== requestToken) {
return
}
const billNo = this.getBillNo(bill)
if (!billNo) {
return
}
console.log('[MyApplicationTab] query payment/paying-bills', billNo)
const pendingPaymentOrder = await this.getPendingPaymentOrder(billNo).catch(() => null)
if (this._pendingPaymentRequestToken !== requestToken) {
return
}
const currentItem = this.list[index]
if (!currentItem || this.getApplicationKey(currentItem) !== this.getApplicationKey(item)) {
return
}
const updatedItem = {
...currentItem,
pendingDepositBill: bill,
pendingPaymentOrder
}
this.$set(this.list, index, updatedItem)
} catch (error) {
console.log('[MyApplicationTab] load pending payment order failed', error)
}
}))
},
async refreshStatus() {
if (this.loading || this.loadingMore) {
return
}
await this.reloadList(true)
if (this.total === 0) {
this.$emit('refresh-shell-state')
uni.showToast({
title: this.t('refreshEmptySuccess'),
icon: 'none'
})
return
}
if (this.list.length) {
uni.showToast({
title: this.t('refreshSuccess'),
icon: 'none'
})
}
},
async reloadList(force = false) {
const now = Date.now()
if (!force && this._lastReloadListAt && now - this._lastReloadListAt < 600) {
return Promise.resolve()
}
if (!force && this._reloadListPromise) {
return this._reloadListPromise
}
this._lastReloadListAt = now
this.pageIndex = 1
this.hasMore = true
this.currentIndex = 0
this._reloadListPromise = this.fetchList(true).finally(() => {
this._reloadListPromise = null
})
return this._reloadListPromise
},
onSwiperChange(e) {
this.currentIndex = e.detail.current
if (this.currentIndex >= this.list.length - 2 && this.hasMore) {
this.loadMore()
}
},
async loadMore() {
if (!this.hasMore || this.loading || this.loadingMore) {
return
}
this.pageIndex += 1
await this.fetchList(false)
},
async fetchList(isReload = false) {
if (isReload) {
this.loading = true
} else {
this.loadingMore = true
}
const params = {
PageIndex: this.pageIndex,
PageSize: this.pageSize
}
if (this.status !== '' && this.status !== null && typeof this.status !== 'undefined') {
params.Status = this.status
}
await applicationFormMePage(params, this.language)
.then(async (response) => {
const pageData = formatPageResponse(response)
console.log('pageData', pageData)
const nextList = normalizeApplicationList(pageData.data || [])
const total = Number(pageData.total || 0)
const mergedList = normalizeApplicationList(isReload ? nextList : this.list.concat(nextList))
.map(item => ({
...item,
pendingDepositBill: item && item.pendingDepositBill ? item.pendingDepositBill : null,
pendingPaymentOrder: item && item.pendingPaymentOrder ? item.pendingPaymentOrder : null
}))
this.total = total
this.list = mergedList
this.hasMore = this.list.length < total && nextList.length > 0
saveMyApplicationList(this.list)
const activeCode = this.resolvePreferredReferenceTypeNo(this.list)
if (activeCode) {
saveActiveApplicationCode(activeCode)
}
await this.loadPendingPaymentOrdersForList(this.list)
})
.catch((error) => {
if (!isReload) {
this.pageIndex = Math.max(1, this.pageIndex - 1)
}
uni.showToast({
title: (error && (error.message || error.msg)) || this.t('loadError'),
icon: 'none'
})
})
.finally(() => {
this.loading = false
this.loadingMore = false
})
},
changeStatus(value) {
if (this.status === value) {
return
}
this.status = value
this.reloadList()
},
getApplicationCode(item = {}) {
return item.detailCode || '--'
},
getApplicationKey(item = {}) {
return item.detailCode || item.formId || `${item.createTime || 'application'}-${item.phone || item.userName || 'unknown'}`
},
getApplicationTitle(item = {}) {
if (item.isViewingApplication) {
return this.t('viewingApplicationTitle')
}
return item.intendedRoomTypeName || this.t('stayApplicationTitle')
},
resolveReferenceTypeNo(item = {}) {
return item.detailCode || ''
},
canViewBill(item = {}) {
return this.shouldPayDeposit(item)
&& this.resolveReferenceTypeNo(item)
},
canCancelOrder(item = {}) {
return this.shouldPayDeposit(item)
&& this.resolveReferenceTypeNo(item)
},
getCancelButtonText(item = {}) {
return this.cancelingReferenceTypeNo === this.resolveReferenceTypeNo(item)
? this.t('cancelOrderLoading')
: this.t('cancelOrder')
},
confirmCancelOrder() {
return new Promise((resolve) => {
uni.showModal({
title: this.t('cancelOrderTitle'),
content: this.t('cancelOrderContent'),
confirmColor: '#ef4444',
success: (res) => resolve(!!(res && res.confirm)),
fail: () => resolve(false)
})
})
},
syncCanceledItem(referenceTypeNo = '') {
const targetCode = String(referenceTypeNo || '')
if (!targetCode) {
return
}
const updateItem = (item = {}) => {
if (String(this.resolveReferenceTypeNo(item)) !== targetCode) {
return item
}
return {
...item,
status: 99,
statusDesc: this.t('statusFinished'),
pendingDepositBill: null,
pendingPaymentOrder: null
}
}
this.list = normalizeApplicationList(this.list.map(updateItem))
saveMyApplicationList(this.list)
const cachedList = normalizeApplicationList(getMyApplicationList())
if (cachedList.length) {
saveMyApplicationList(cachedList.map(updateItem))
}
},
cancelOrderAction(event) {
const dataset = event && event.currentTarget ? event.currentTarget.dataset || {} : {}
const referenceTypeNo = dataset.referenceTypeNo || dataset.referenceTypeNo === 0
? String(dataset.referenceTypeNo)
: ''
if (!referenceTypeNo || this.cancelingReferenceTypeNo) {
return
}
this.confirmCancelOrder()
.then((confirmed) => {
if (!confirmed) {
return
}
this.cancelingReferenceTypeNo = referenceTypeNo
return userDetailCancel({
detailCode: referenceTypeNo,
code: referenceTypeNo,
referenceTypeNo
}, this.language)
.then(async () => {
this.syncCanceledItem(referenceTypeNo)
await this.reloadList(true)
this.$emit('refresh-shell-state')
uni.showToast({
title: this.t('cancelOrderSuccess'),
icon: 'success'
})
})
})
.catch((error) => {
uni.showToast({
title: (error && (error.message || error.msg)) || this.t('cancelOrderFailed'),
icon: 'none'
})
})
.finally(() => {
this.cancelingReferenceTypeNo = ''
})
},
continuePendingPaymentAction(event) {
const dataset = event && event.currentTarget ? event.currentTarget.dataset || {} : {}
const referenceTypeNo = dataset.referenceTypeNo || dataset.referenceTypeNo === 0
? String(dataset.referenceTypeNo)
: ''
if (!referenceTypeNo) {
uni.showToast({
title: this.t('missingBillCode'),
icon: 'none'
})
return
}
this.payDepositByReferenceTypeNo(referenceTypeNo)
},
resolvePreferredReferenceTypeNo(list = []) {
const sortedList = [...list]
.filter(item => !item.isViewingApplication && this.resolveReferenceTypeNo(item))
.sort((a, b) => new Date(b.createTime || 0).getTime() - new Date(a.createTime || 0).getTime())
if (!sortedList.length) {
return ''
}
const preferredItem = sortedList.find(item => Number(item.status) !== 99) || sortedList[0]
return this.resolveReferenceTypeNo(preferredItem)
},
async payDepositAction(event) {
const dataset = event && event.currentTarget ? event.currentTarget.dataset || {} : {}
const referenceTypeNo = dataset.referenceTypeNo || dataset.referenceTypeNo === 0
? String(dataset.referenceTypeNo)
: ''
if (!referenceTypeNo) {
uni.showToast({
title: this.t('missingBillCode'),
icon: 'none'
})
return
}
this.pendingDepositReferenceTypeNo = referenceTypeNo
this.hasReadNotice = false
this.showDepositNotice = true
},
onNoticeScrollToBottom() {
this.hasReadNotice = true
},
closeDepositNotice() {
this.showDepositNotice = false
this.pendingDepositReferenceTypeNo = ''
},
async payDepositByReferenceTypeNo(referenceTypeNo = '') {
if (!referenceTypeNo) {
return
}
saveActiveApplicationCode(referenceTypeNo)
uni.showLoading({ title: this.t('loadingBill') })
try {
const billRes = await accountQueryBills({
referenceTypeNo,
status: 10,
paymentStatus: 0,
ProviderType: 'WechatMiniProgram'
})
const billList = billRes && billRes.data ? billRes.data : billRes
if (!Array.isArray(billList) || !billList.length) {
uni.showToast({ title: this.t('noPendingDepositBill'), icon: 'none' })
return
}
const bill = billList[0]
const billNo = this.getBillNo(bill)
const pendingPaymentOrder = await this.getPendingPaymentOrder(billNo).catch(() => null)
uni.showLoading({ title: this.t('loadingPayment') })
const reusablePendingPaymentOrder = pendingPaymentOrder && !this.isApplicationPendingPaymentExpired({
pendingPaymentOrder
})
? pendingPaymentOrder
: null
if (reusablePendingPaymentOrder && reusablePendingPaymentOrder.cashier && reusablePendingPaymentOrder.cashier.paymentInfo) {
await this.requestWechatPayment(reusablePendingPaymentOrder.cashier.paymentInfo)
} else {
const wechatCode = await this.getWechatLoginCode()
const payRes = await paymentBillPay({
referenceTypeNo,
billNo,
payMethod: 'Wechat',
amount: Number(bill.remainingAmount || bill.amount || 0),
description: bill.billName || bill.name || this.t('depositPaymentDesc'),
ExtraInfo: wechatCode
})
const payload = payRes && payRes.data ? payRes.data : payRes
const cashier = payload && payload.cashier ? payload.cashier : {}
await this.requestWechatPayment(cashier.paymentInfo)
}
uni.showToast({ title: this.t('paymentSuccess'), icon: 'success' })
} catch (error) {
const errMsg = error && (error.errMsg || error.message || error.msg) ? (error.errMsg || error.message || error.msg) : this.t('paymentFailed')
if (/cancel/i.test(errMsg)) {
uni.showToast({ title: this.t('paymentCanceled'), icon: 'none' })
} else {
uni.showToast({ title: errMsg, icon: 'none' })
}
} finally {
uni.hideLoading()
setTimeout(() => {
this.refreshStatus()
}, 1500)
}
},
async confirmAndPayDeposit() {
if (!this.hasReadNotice) {
return
}
const referenceTypeNo = this.pendingDepositReferenceTypeNo
this.showDepositNotice = false
this.pendingDepositReferenceTypeNo = ''
return this.payDepositByReferenceTypeNo(referenceTypeNo)
},
goToApplicationDetail(item = {}) {
const code = item.detailCode || ''
if (!code) {
uni.showToast({
title: this.t('missingApplicationCode'),
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pkgService/application_detail/application_detail?code=${encodeURIComponent(code)}&payload=${encodeURIComponent(JSON.stringify(item))}`
})
},
goSelectRoom() {
uni.reLaunch({
url: '/pages/index/index?index=0&pageId=select-room'
})
}
}
}
</script>
<style scoped lang="scss">
.my-application-page {
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
overflow: hidden;
}
.page-header {
flex-shrink: 0;
padding-bottom: 20rpx;
}
.page-title {
font-size: 44rpx;
font-weight: 800;
letter-spacing: 1rpx;
}
.list-scroll-view {
flex: 1;
height: 0;
}
.application-card,
.empty-panel {
background: #ffffff;
border-radius: 24rpx;
padding: 32rpx;
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
border: 1rpx solid rgba($brand-primary, 0.05);
}
.card-title-wrap {
display: flex;
align-items: center;
}
.title-icon {
width: 48rpx;
height: 48rpx;
border-radius: 12rpx;
background: rgba($brand-primary, 0.08);
color: $brand-primary;
font-size: 28rpx;
}
.card-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.card-code,
.info-label,
.remark-label {
color: #6A7A99;
}
.card-code,
.info-label,
.info-value,
.remark-text {
font-size: 24rpx;
}
.status-badge {
padding: 8rpx 20rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
}
.status-pending {
color: #b45309;
background: rgba(245, 158, 11, 0.12);
}
.status-approved {
color: $brand-primary;
background: rgba($brand-primary, 0.12);
}
.status-rejected {
color: #DC2626;
background: rgba(220, 38, 38, 0.12);
}
.status-finished {
color: #64748B;
background: #F1F5F9;
}
.card-body {
display: flex;
flex-direction: column;
gap: 0;
}
.info-row {
padding: 16rpx 0;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
gap: 24rpx;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
flex: 0 0 170rpx;
color: #6A7A99;
}
.info-value {
flex: 1;
color: #1A2233;
text-align: right;
word-break: break-all;
font-weight: 500;
}
.info-value--highlight {
color: #c2410c;
font-weight: 700;
}
.info-value--expired {
color: #dc2626;
font-weight: 700;
}
.remark-panel,
.reminder-panel {
padding: 24rpx;
border-radius: 20rpx;
background: rgba($brand-primary, 0.02);
border: 1rpx solid rgba($brand-primary, 0.1);
}
.pending-payment-panel {
padding: 24rpx;
border-radius: 20rpx;
background: linear-gradient(180deg, rgba(255, 247, 237, 0.9) 0%, rgba(255, 251, 245, 0.95) 100%);
border: 1rpx solid rgba(249, 115, 22, 0.16);
}
.pending-payment-title,
.reminder-title {
margin-bottom: 12rpx;
font-size: 26rpx;
font-weight: 700;
color: #c2410c;
}
.reminder-title {
color: $brand-primary;
}
.remark-line {
display: flex;
align-items: flex-start;
gap: 16rpx;
}
.remark-label {
flex: 0 0 96rpx;
font-size: 22rpx;
color: #6A7A99;
}
.remark-text {
flex: 1;
color: #1A2233;
line-height: 1.7;
}
.card-footer {
display: flex;
gap: 20rpx;
justify-content: flex-end;
}
.cancel-btn,
.pay-btn {
min-width: 220rpx;
height: 84rpx;
padding: 0 36rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
color: #ffffff;
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.25);
font-size: 26rpx;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.3s;
}
.cancel-btn {
background: #ffffff;
color: #ef4444;
border: 1rpx solid rgba(239, 68, 68, 0.3);
box-shadow: 0 8rpx 20rpx rgba(239, 68, 68, 0.08);
}
.pay-btn:active,
.cancel-btn:active {
transform: scale(0.98);
}
.cancel-btn--loading,
.pay-btn--loading {
opacity: 0.72;
}
.empty-action {
width: 100%;
height: 96rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
color: #ffffff;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.25);
transition: all 0.3s;
}
.empty-action:active {
transform: scale(0.98);
}
.empty-panel .empty-action {
width: 80%;
}
.empty-desc {
color: #94A3B8;
font-size: 28rpx;
line-height: 1.6;
}
.animation-fade-in {
animation: fadeIn 0.4s ease forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 隐藏 scroll-view 滚动条 */
::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
-webkit-appearance: none;
background: transparent;
}
.deposit-notice-popup {
background: #FFFFFF;
border-radius: 32rpx 32rpx 0 0;
display: flex;
flex-direction: column;
height: 80vh;
}
.popup-header {
padding: 32rpx;
text-align: center;
border-bottom: 1px solid #F1F5F9;
}
.popup-title {
font-size: 36rpx;
font-weight: bold;
color: #1A2233;
}
.popup-body {
flex: 1;
padding: 32rpx;
overflow-y: auto;
}
.notice-content {
padding-bottom: 40rpx;
}
.notice-item {
margin-bottom: 32rpx;
}
.notice-item-zh {
font-size: 28rpx;
color: #334155;
line-height: 1.6;
margin-bottom: 8rpx;
font-weight: 500;
}
.notice-item-en {
font-size: 24rpx;
color: #64748B;
line-height: 1.5;
}
.popup-footer {
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
border-top: 1px solid #F1F5F9;
background: #FFFFFF;
display: flex;
justify-content: center;
}
.action-btn {
min-width: 420rpx;
max-width: 100%;
height: 92rpx;
padding: 0 40rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: #ffffff;
box-shadow: 0 16rpx 34rpx rgba(34, 197, 94, 0.24);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.action-btn:active {
transform: scale(0.98);
}
.action-btn--disabled {
opacity: 0.6;
background: #94A3B8;
box-shadow: none;
}
</style>