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

812 lines
22 KiB
Vue
Raw Permalink 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-safe-area-inset-bottom">
<tn-nav-bar :isBack="true" :bottomShadow="false" backgroundColor="transparent">
<view class="tn-text-bold tn-text-xl tn-color-black">{{ $tr('我的申请') }}</view>
</tn-nav-bar>
<view class="page-main tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '48rpx' }">
<scroll-view class="filter-scroll" scroll-x :show-scrollbar="false">
<view class="filter-row tn-margin-bottom-lg">
<view
class="filter-chip"
v-for="item in statusOptions"
:key="item.value"
:class="{ 'filter-chip--active': status === item.value }"
@click="changeStatus(item.value)"
>
{{ item.label }}
</view>
</view>
</scroll-view>
<view class="toolbar-row tn-margin-bottom-lg">
<view class="toolbar-summary">{{ $tr('共 {total} 条申请', { total }) }}</view>
<view
class="refresh-btn"
:class="{ 'refresh-btn--loading': loading }"
@click="refreshStatus"
>
<text
class="tn-text-bold tn-flex tn-flex-row-center tn-flex-col-center"
>
<text v-if="loading" class="tn-icon-loading tn-margin-right-xs spin"></text>
{{ loading ? $tr('刷新中...') : $tr('刷新状态') }}
</text>
</view>
</view>
<view v-if="list.length">
<view
v-for="item 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">
<view class="card-title">{{ getApplicationTitle(item) }}</view>
<view class="card-code tn-margin-top-xs">{{ getApplicationCode(item) }}</view>
</view>
<view :class="['status-badge', 'status-' + resolveStatusType(item.status)]">
{{ getDisplayStatusText(item) }}
</view>
</view>
<view
v-if="shouldShowApplicationPaymentStatus(item)"
class="payment-status-banner tn-margin-top"
:class="`payment-status-banner--${getApplicationPaymentStatusType(item)}`"
>
<text class="payment-status-banner__label">{{ $tr('支付状态') }}</text>
<text class="payment-status-banner__value">{{ getApplicationPaymentStatusText(item) }}</text>
</view>
<view class="card-body tn-margin-top">
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ $tr('所属公寓') }}</text>
<text class="info-value">{{ item.intendedAreaName || '--' }}</text>
</view>
<view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ $tr('入住人') }}</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 ? $tr('预约时间') : $tr('预计租期') }}</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">{{ $tr('提交时间') }}</text>
<text class="info-value">{{ formatDateTime(item.createTime) }}</text>
</view>
<view v-if="item.remark" class="remark-panel tn-margin-top">
<view class="remark-label">{{ $tr('补充说明') }}</view>
<view class="remark-text tn-margin-top-xs">{{ item.remark }}</view>
</view>
</view>
<view v-if="canViewBill(item)" class="card-footer tn-margin-top-lg">
<view
class="card-action-btn card-action-btn--ghost"
:data-reference-type-no="resolveReferenceTypeNo(item)"
@click="goToBillPage"
>
<text class="tn-text-bold">{{ $tr('查看关联账单') }}</text>
</view>
</view>
</view>
<view class="load-state">
<text v-if="loadingMore">{{ $tr('加载中...') }}</text>
<text v-else-if="!hasMore">{{ $tr('没有更多申请了') }}</text>
<text v-else>{{ $tr('上拉加载更多') }}</text>
</view>
</view>
<view v-else-if="!loading" class="empty-panel tn-padding-lg">
<view class="empty-title">{{ $tr('暂无申请记录') }}</view>
<view class="empty-desc tn-margin-top-sm">{{ $tr('您还没有提交任何申请,去选房后可发起入住申请或预约看房。') }}</view>
<view class="action-btn tn-margin-top-lg" @click="goSelectRoom">
<text class="tn-text-bold">{{ $tr('去选房') }}</text>
</view>
</view>
<view v-if="loading && !list.length" class="empty-panel tn-padding-lg">
<view class="empty-title">{{ $tr('申请列表加载中') }}</view>
<view class="empty-desc tn-margin-top-sm">{{ $tr('正在同步您的入住申请记录,请稍候。') }}</view>
</view>
</view>
</view>
</template>
<script>
import { applicationFormMePage, orderSearchedReservationFlow } from '@/api/index.js'
import { saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state'
import {
attachReservationFlow,
buildReservationFlowMap,
getReservationOrderNo,
parseReservationFlowResponse
} from '@/utils/reservation-flow'
function formatPageResponse(response) {
const payload = response && response.data ? response.data : response
return payload && typeof payload === 'object' ? payload : {}
}
const VIEWING_APPLICATION_PATTERN = /预约看房申请|看房时间[::]|意向房型[::]|意向区域[::]|viewing request|viewing time[::]|preferred room type[::]|preferred area[::]/i
function isViewingApplicationRecord(item = {}) {
if (!item || typeof item !== 'object') {
return false
}
const typeText = [
item.businessType,
item.applicationType,
item.applicationTypeDesc,
item.formType,
item.bizType,
item.type,
item.typeDesc
]
.filter(value => value !== null && typeof value !== 'undefined' && String(value).trim())
.join(' ')
if (/viewing|看房|带看/i.test(typeText)) {
return true
}
if ([item.appointmentViewingTime, item.viewingTime, item.bookViewingTime].some(value => String(value || '').trim())) {
return true
}
const remark = item && item.remark ? String(item.remark) : ''
return VIEWING_APPLICATION_PATTERN.test(remark)
}
function resolveApplicationPaymentStatusInfo(item = {}) {
if (!item || item.isViewingApplication) {
return {
key: 'exempt',
desc: ''
}
}
const desc = String(item.payStatusDesc || item.paymentStatusDesc || '').trim()
const status = Number(
item.payStatus !== undefined && item.payStatus !== null
? item.payStatus
: item.paymentStatus
)
if (status === 10 || /已支付|paid/i.test(desc)) {
return { key: 'paid', desc }
}
if (/退款|refunded?/i.test(desc)) {
return { key: 'refunded', desc }
}
if (/失败|failed?/i.test(desc)) {
return { key: 'failed', desc }
}
if (/处理中|processing/i.test(desc)) {
return { key: 'processing', desc }
}
if (status === 0 || /待支付|未支付|unpaid|pending/i.test(desc)) {
return { key: 'pending', desc }
}
return {
key: desc ? 'processing' : 'pending',
desc
}
}
function normalizeApplicationList(list = []) {
if (!Array.isArray(list)) {
return []
}
return list
.filter(item => item && typeof item === 'object')
.map((item) => {
return {
...item,
isViewingApplication: isViewingApplicationRecord(item)
}
})
}
export default {
data() {
return {
status: '',
pageIndex: 1,
pageSize: 10,
total: 0,
list: [],
loading: false,
loadingMore: false,
hasMore: true,
statusOptions: []
}
},
onShow() {
this.reloadList()
},
onPullDownRefresh() {
this.reloadList().finally(() => {
uni.stopPullDownRefresh()
})
},
onReachBottom() {
this.loadMore()
},
created() {
this.syncStatusOptions()
},
watch: {
language() {
this.syncStatusOptions()
this.reloadList(true)
}
},
computed: {
language() {
return this.$getLanguage()
}
},
methods: {
syncStatusOptions() {
this.statusOptions = [
{ label: this.$tr('全部'), value: '' },
{ label: this.$tr('待审核'), value: 0 },
{ label: this.$tr('已通过'), value: 10 },
{ label: this.$tr('已拒绝'), value: 20 },
{ label: this.$tr('已结束'), value: 99 }
]
},
getStatusText(status) {
const map = {
0: this.$tr('待审核'),
10: this.$tr('已通过'),
20: this.$tr('已拒绝'),
99: this.$tr('已结束')
}
return map[status] || this.$tr('处理中')
},
getDisplayStatusText(item = {}) {
if (this.language !== 'en' && item.statusDesc) {
return item.statusDesc
}
return this.getStatusText(item.status)
},
resolveStatusType(status) {
if (status === 10) {
return 'approved'
}
if (status === 20) {
return 'rejected'
}
if (status === 99) {
return 'finished'
}
return 'pending'
},
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.$tr(' 至 ')}${endText}`
}
}
return `${this.formatDate(start)}${this.$tr(' 至 ')}${this.formatDate(end)}`
},
async refreshStatus() {
if (this.loading || this.loadingMore) {
return
}
await this.reloadList()
if (this.list.length || this.total === 0) {
uni.showToast({
title: this.$tr('申请状态已刷新'),
icon: 'none'
})
}
},
async reloadList() {
this.pageIndex = 1
this.hasMore = true
this.list = []
await this.fetchList(true)
},
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)
this.total = total
this.list = normalizeApplicationList(isReload ? nextList : this.list.concat(nextList))
this.hasMore = this.list.length < total && nextList.length > 0
const activeCode = this.resolvePreferredReferenceTypeNo(this.list)
if (activeCode) {
saveActiveApplicationCode(activeCode)
}
await this.loadReservationFlowsForList(this.list)
saveMyApplicationList(this.list)
})
.catch((error) => {
if (!isReload) {
this.pageIndex = Math.max(1, this.pageIndex - 1)
}
uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('申请记录加载失败'),
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.$tr('预约看房申请')
}
return item.intendedRoomTypeName || this.$tr('入住申请')
},
shouldShowApplicationPaymentStatus(item = {}) {
return !item.isViewingApplication
},
getApplicationPaymentStatusText(item = {}) {
const statusInfo = resolveApplicationPaymentStatusInfo(item)
if (this.$getLanguage() !== 'en' && statusInfo.desc) {
return statusInfo.desc
}
const textMap = {
exempt: this.$tr('无需支付'),
paid: this.$tr('已支付'),
refunded: this.$tr('已退款'),
failed: this.$tr('支付失败'),
processing: this.$tr('处理中'),
pending: this.$tr('待支付')
}
return textMap[statusInfo.key] || statusInfo.desc || this.$tr('待支付')
},
getApplicationPaymentStatusType(item = {}) {
const statusInfo = resolveApplicationPaymentStatusInfo(item)
const typeMap = {
exempt: 'neutral',
paid: 'success',
refunded: 'neutral',
failed: 'danger',
processing: 'info',
pending: 'warning'
}
return typeMap[statusInfo.key] || 'warning'
},
resolveReferenceTypeNo(item = {}) {
return item.detailCode || ''
},
canViewBill(item = {}) {
return !item.isViewingApplication && this.resolveReferenceTypeNo(item) && this.resolveStatusType(item.status) === 'pending'
},
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 loadReservationFlowsForList(list = []) {
const baseList = Array.isArray(list) ? list : []
const orderNos = [...new Set(
baseList
.filter(item => item && !item.isViewingApplication)
.map(item => getReservationOrderNo(item))
.filter(Boolean)
)]
const requestToken = Date.now()
this._reservationFlowRequestToken = requestToken
const applyFlowMap = (flowMap = {}) => {
if (this._reservationFlowRequestToken !== requestToken) {
return
}
this.list = (Array.isArray(this.list) ? this.list : []).map(item => attachReservationFlow(item, flowMap))
}
if (!orderNos.length) {
applyFlowMap({})
return
}
try {
const response = await orderSearchedReservationFlow({
orderNos
}, this.language)
const flowMap = buildReservationFlowMap(parseReservationFlowResponse(response))
applyFlowMap(flowMap)
} catch (error) {
console.log('[my_application] load reservation flow failed', error)
applyFlowMap({})
}
},
goToBillPage(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.$tr('申请单编号缺失'),
icon: 'none'
})
return
}
saveActiveApplicationCode(referenceTypeNo)
uni.navigateTo({
url: `/pkgService/utility_recharge/utility_recharge?referenceTypeNo=${encodeURIComponent(referenceTypeNo)}`
})
},
goToApplicationDetail(item = {}) {
const code = item.detailCode || ''
if (!code) {
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 {
min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
}
.application-card,
.empty-panel {
background: #FFFFFF;
border-radius: 24rpx;
padding: 32rpx;
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
border: 1px solid rgba($brand-primary, 0.05);
}
.empty-title,
.card-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.empty-desc,
.card-code,
.info-label,
.remark-label,
.load-state {
color: #6A7A99;
}
.filter-scroll {
width: 100%;
white-space: nowrap;
}
.filter-row {
display: inline-flex;
flex-wrap: nowrap;
gap: 16rpx;
padding: 4rpx;
}
.toolbar-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
}
.toolbar-summary {
flex: 1;
font-size: 24rpx;
color: #6A7A99;
}
.refresh-btn {
min-width: 188rpx;
height: 72rpx;
padding: 0 24rpx;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
background: #FFFFFF;
border: 1rpx solid rgba($brand-primary, 0.18);
color: $brand-primary;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.06);
}
.refresh-btn--loading {
opacity: 0.88;
}
.filter-chip {
flex-shrink: 0;
padding: 14rpx 28rpx;
border-radius: 999rpx;
background: #FFFFFF;
border: 1rpx solid rgba($brand-primary, 0.15);
color: #6A7A99;
font-size: 24rpx;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
transition: all 0.2s;
}
.filter-chip--active {
background: #FFFFFF;
border-color: $brand-primary;
color: $brand-primary;
font-weight: bold;
box-shadow: 0 4rpx 16rpx rgba($brand-primary, 0.1);
}
.card-code {
font-size: 22rpx;
}
.status-badge {
padding: 8rpx 20rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
}
.payment-status-banner {
padding: 20rpx 24rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
border: 1rpx solid transparent;
}
.payment-status-banner__label {
font-size: 24rpx;
color: #64748B;
font-weight: 600;
}
.payment-status-banner__value {
font-size: 28rpx;
font-weight: 700;
}
.payment-status-banner--warning {
background: linear-gradient(180deg, rgba(255, 247, 237, 0.96) 0%, rgba(255, 251, 245, 0.98) 100%);
border-color: rgba(249, 115, 22, 0.16);
}
.payment-status-banner--warning .payment-status-banner__value {
color: #C2410C;
}
.payment-status-banner--success {
background: linear-gradient(180deg, rgba(236, 253, 245, 0.96) 0%, rgba(240, 253, 250, 0.98) 100%);
border-color: rgba(16, 185, 129, 0.16);
}
.payment-status-banner--success .payment-status-banner__value {
color: #047857;
}
.payment-status-banner--info {
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96) 0%, rgba(248, 250, 252, 0.98) 100%);
border-color: rgba(59, 130, 246, 0.14);
}
.payment-status-banner--info .payment-status-banner__value {
color: #1D4ED8;
}
.payment-status-banner--danger {
background: linear-gradient(180deg, rgba(254, 242, 242, 0.96) 0%, rgba(255, 245, 245, 0.98) 100%);
border-color: rgba(239, 68, 68, 0.16);
}
.payment-status-banner--danger .payment-status-banner__value {
color: #B91C1C;
}
.payment-status-banner--neutral {
background: linear-gradient(180deg, rgba(241, 245, 249, 0.96) 0%, rgba(248, 250, 252, 0.98) 100%);
border-color: rgba(148, 163, 184, 0.16);
}
.payment-status-banner--neutral .payment-status-banner__value {
color: #475569;
}
.status-pending {
background: rgba(245, 158, 11, 0.12);
color: #B45309;
}
.status-approved {
background: rgba($brand-primary, 0.12);
color: $brand-primary;
}
.status-rejected {
background: rgba(239, 68, 68, 0.12);
color: #B91C1C;
}
.status-finished {
background: rgba(100, 116, 139, 0.12);
color: #475569;
}
.info-row {
padding: 16rpx 0;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
}
.info-row:last-child {
border-bottom: none;
}
.info-value {
max-width: 62%;
text-align: right;
color: #1A2233;
font-weight: 500;
}
.remark-panel {
padding: 24rpx;
border-radius: 20rpx;
background: rgba($brand-primary, 0.02);
border: 1rpx solid rgba($brand-primary, 0.1);
}
.remark-text {
color: #1A2233;
line-height: 1.6;
}
.card-footer {
display: flex;
justify-content: flex-end;
}
.card-action-btn {
min-width: 220rpx;
height: 84rpx;
padding: 0 28rpx;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 18rpx;
font-size: 26rpx;
}
.card-action-btn--ghost {
background: #ffffff;
color: $brand-primary;
border: 1rpx solid rgba($brand-primary, 0.3);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
}
.action-btn {
width: 100%;
height: 96rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 999rpx;
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);
}
.load-state {
text-align: center;
font-size: 24rpx;
padding: 12rpx 0 24rpx;
color: #6A7A99;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 隐藏 scroll-view 滚动条 */
::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
-webkit-appearance: none;
background: transparent;
}
</style>