Files
QSY-lvjuxcx/pkgService/application_detail/application_detail.vue
2026-06-29 09:42:53 +08:00

788 lines
23 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="application-detail-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">{{ t('navTitle') }}</view>
</tn-nav-bar>
<view class="page-main tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '48rpx' }">
<view class="hero-panel tn-margin-bottom-lg" :class="heroThemeClass">
<view class="hero-top-row">
<view class="hero-badge" :class="heroBadgeClass">{{ applicationTypeText }}</view>
<view class="hero-status" :class="['hero-status--' + statusType]">{{ statusText }}</view>
</view>
<view class="hero-title tn-margin-top">{{ displayTitle }}</view>
<view class="hero-meta-row tn-margin-top">
<view class="hero-meta-chip">
<text class="tn-icon-time tn-margin-right-xs"></text>
<text>{{ formatDateTime(detail.createTime) }}</text>
</view>
<view class="hero-meta-chip">
<text class="tn-icon-location tn-margin-right-xs"></text>
<text>{{ detail.intendedAreaName || '--' }}</text>
</view>
</view>
</view>
<view
v-if="statusReminderText"
class="status-reminder-panel tn-margin-bottom-lg"
:class="{
'status-reminder-panel--warning': shouldPayDeposit(detail),
'status-reminder-panel--info': isWaitingRoomAssignment(detail)
}"
>
<view class="status-reminder-panel__icon">
<text :class="shouldPayDeposit(detail) ? 'tn-icon-warning-fill' : 'tn-icon-time-fill'"></text>
</view>
<view class="status-reminder-panel__content">
<view class="status-reminder-panel__title">{{ t('statusReminderTitle') }}</view>
<view class="status-reminder-panel__text">{{ statusReminderText }}</view>
</view>
</view>
<view class="content-panel tn-margin-bottom-lg">
<view class="panel-title">{{ t('basicInfoTitle') }}</view>
<view class="meta-row tn-margin-top">
<text class="meta-label">{{ t('applicationCodeLabel') }}</text>
<view class="meta-action-wrap">
<text class="meta-value">{{ detail.detailCode || '--' }}</text>
<view v-if="detail.detailCode" class="copy-btn" @click="copyCode">
<text class="tn-text-xs tn-text-bold">{{ t('copy') }}</text>
</view>
</view>
</view>
<view class="meta-row">
<text class="meta-label">{{ t('applicationTypeLabel') }}</text>
<text class="meta-value">{{ applicationTypeText }}</text>
</view>
<view class="meta-row">
<text class="meta-label">{{ t('statusLabel') }}</text>
<text class="meta-value">{{ statusText }}</text>
</view>
<view class="meta-row">
<text class="meta-label">{{ t('applicantLabel') }}</text>
<text class="meta-value">{{ detail.userName || detail.phone || '--' }}</text>
</view>
<view class="meta-row">
<text class="meta-label">{{ t('apartmentLabel') }}</text>
<text class="meta-value">{{ detail.intendedAreaName || '--' }}</text>
</view>
<view class="meta-row" v-if="detail.intendedRoomTypeName">
<text class="meta-label">{{ isViewingApplication ? t('intendedRoomTypeLabel') : t('appliedRoomTypeLabel') }}</text>
<text class="meta-value">{{ detail.intendedRoomTypeName }}</text>
</view>
<view class="meta-row">
<text class="meta-label">{{ isViewingApplication ? t('viewingTimeLabel') : t('leaseTermLabel') }}</text>
<text class="meta-value">{{ displayDateRange }}</text>
</view>
<view class="meta-row">
<text class="meta-label">{{ t('submittedAtLabel') }}</text>
<text class="meta-value">{{ formatDateTime(detail.createTime) }}</text>
</view>
</view>
<view class="content-panel tn-margin-bottom-lg" v-if="viewingMeta.length">
<view class="panel-title">{{ t('viewingInfoTitle') }}</view>
<view
v-for="item in viewingMeta"
:key="item.label"
class="meta-row tn-margin-top"
>
<text class="meta-label">{{ item.label }}</text>
<text class="meta-value">{{ item.value }}</text>
</view>
</view>
<view class="content-panel tn-margin-bottom-lg">
<view class="panel-title">{{ isViewingApplication ? t('applicationRemarkTitle') : t('remarkTitle') }}</view>
<view class="remark-panel tn-margin-top">
<view
v-for="(line, index) in remarkLines"
:key="`${line}-${index}`"
class="remark-line"
>
{{ line }}
</view>
</view>
</view>
<view class="action-row">
<view class="ghost-btn" @click="goBackList">
<text class="tn-text-bold">{{ t('backList') }}</text>
</view>
<view
v-if="canCancelOrder"
class="danger-btn"
:class="{ 'danger-btn--loading': canceling }"
@tap.stop="handleCancelOrder"
>
<text class="tn-text-bold">{{ canceling ? t('cancelOrderLoading') : t('cancelOrder') }}</text>
</view>
<view
v-if="canViewBill"
class="action-btn"
@tap.stop="goToBillPage"
>
<text class="tn-text-bold">{{ t('viewBill') }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import { userDetailCancel } from '@/api/index.js'
import { getMyApplicationList, saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state'
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
}
})
}
export default {
i18n: {
zh: {
navTitle: '申请详情',
basicInfoTitle: '基础信息',
applicationCodeLabel: '申请编号',
copy: '复制',
applicationTypeLabel: '申请类型',
statusLabel: '当前状态',
applicantLabel: '申请人',
apartmentLabel: '所属公寓',
intendedRoomTypeLabel: '意向房型',
appliedRoomTypeLabel: '申请房型',
viewingTimeLabel: '预约时间',
leaseTermLabel: '预计租期',
submittedAtLabel: '提交时间',
viewingInfoTitle: '带看信息',
applicationRemarkTitle: '申请说明',
remarkTitle: '补充说明',
backList: '返回列表',
viewBill: '查看关联账单',
viewingApplicationTitle: '预约看房申请',
stayApplicationTitle: '入住申请',
viewingApplicationType: '预约看房',
stayApplicationType: '入住申请',
statusPending: '待审核',
statusApproved: '已通过',
statusRejected: '已拒绝',
statusFinished: '已结束',
statusProcessing: '处理中',
statusPendingPayment: '请支付定金',
statusWaitingRoomAssign: '等待预订中心分配房间',
statusReminderTitle: '当前进度提醒',
emptyRemark: '暂无补充说明',
codeCopied: '申请编号已复制',
missingBillCode: '申请单编号缺失',
cancelOrder: '取消订单',
cancelOrderLoading: '取消中...',
cancelOrderTitle: '取消订单',
cancelOrderContent: '取消后将关闭当前未支付的定金订单,是否继续?',
cancelOrderSuccess: '订单已取消',
cancelOrderFailed: '取消订单失败',
viewingTimeKey: '看房时间',
leaseDurationKey: '预计租住时长',
budgetKey: '租金预算',
commuteKey: '工作/通勤地点',
preferredAreaKey: '意向区域',
preferredRoomTypeKey: '意向房型',
paymentMethodKey: '支付方式',
dateRangeJoiner: ' 至 '
},
en: {
navTitle: 'Application Detail',
basicInfoTitle: 'Basic Information',
applicationCodeLabel: 'Application No.',
copy: 'Copy',
applicationTypeLabel: 'Application Type',
statusLabel: 'Status',
applicantLabel: 'Applicant',
apartmentLabel: 'Apartment',
intendedRoomTypeLabel: 'Preferred Room Type',
appliedRoomTypeLabel: 'Requested Room Type',
viewingTimeLabel: 'Viewing Time',
leaseTermLabel: 'Lease Term',
submittedAtLabel: 'Submitted At',
viewingInfoTitle: 'Viewing Details',
applicationRemarkTitle: 'Application Notes',
remarkTitle: 'Remarks',
backList: 'Back to List',
viewBill: 'View Related Bill',
viewingApplicationTitle: 'Viewing Request',
stayApplicationTitle: 'Stay Application',
viewingApplicationType: 'Viewing Request',
stayApplicationType: 'Stay Application',
statusPending: 'Pending',
statusApproved: 'Approved',
statusRejected: 'Rejected',
statusFinished: 'Closed',
statusProcessing: 'Processing',
statusPendingPayment: 'Please Pay Deposit',
statusWaitingRoomAssign: 'Waiting for Room Assignment',
statusReminderTitle: 'Current Progress',
emptyRemark: 'No additional remarks',
codeCopied: 'Application number copied',
missingBillCode: 'Missing application bill number',
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',
viewingTimeKey: 'Viewing Time',
leaseDurationKey: 'Expected Stay Duration',
budgetKey: 'Budget',
commuteKey: 'Work / Commute Location',
preferredAreaKey: 'Preferred Area',
preferredRoomTypeKey: 'Preferred Room Type',
paymentMethodKey: 'Payment Method',
dateRangeJoiner: ' to '
}
},
data() {
return {
detailCode: '',
detail: {},
canceling: false
}
},
computed: {
isViewingApplication() {
return !!(this.detail && this.detail.isViewingApplication)
},
displayTitle() {
if (this.isViewingApplication) {
return this.detail.intendedRoomTypeName || this.t('viewingApplicationTitle')
}
return this.detail.intendedRoomTypeName || this.t('stayApplicationTitle')
},
applicationTypeText() {
return this.isViewingApplication ? this.t('viewingApplicationType') : this.t('stayApplicationType')
},
statusText() {
if (this.language !== 'en' && this.detail && this.detail.statusDesc) {
return this.detail.statusDesc
}
const map = {
0: this.t('statusPending'),
10: this.t('statusApproved'),
20: this.t('statusRejected'),
99: this.t('statusFinished')
}
return map[this.detail.status] || this.t('statusProcessing')
},
statusType() {
if (this.detail.status === 10) {
return 'approved'
}
if (this.detail.status === 20) {
return 'rejected'
}
if (this.detail.status === 99) {
return 'finished'
}
return 'pending'
},
heroThemeClass() {
return this.isViewingApplication ? 'hero-viewing' : 'hero-stay'
},
heroBadgeClass() {
return this.isViewingApplication ? 'badge-viewing' : 'badge-stay'
},
displayDateRange() {
if (!this.detail.start && !this.detail.end) {
return '--'
}
if (this.isViewingApplication) {
return `${this.formatDateTime(this.detail.start)}${this.t('dateRangeJoiner')}${this.formatDateTime(this.detail.end)}`
}
return `${this.formatDate(this.detail.start)}${this.t('dateRangeJoiner')}${this.formatDate(this.detail.end)}`
},
remarkLines() {
const text = this.detail && this.detail.remark ? String(this.detail.remark).trim() : ''
if (!text) {
return [this.t('emptyRemark')]
}
return text.split(/\r?\n/).filter(Boolean)
},
viewingMeta() {
if (!this.isViewingApplication) {
return []
}
return this.parseViewingRemark()
},
statusReminderText() {
if (this.shouldPayDeposit(this.detail)) {
return this.t('statusPendingPayment')
}
if (this.isWaitingRoomAssignment(this.detail)) {
return this.t('statusWaitingRoomAssign')
}
return ''
},
canViewBill() {
return this.shouldPayDeposit(this.detail)
&& !this.isViewingApplication
&& this.detail
&& this.detail.detailCode
&& this.statusType === 'pending'
},
canCancelOrder() {
return this.shouldPayDeposit(this.detail)
&& !this.isViewingApplication
&& this.detail
&& this.detail.detailCode
&& !this.canceling
}
},
onLoad(options = {}) {
this.detailCode = options.code || ''
this.restoreDetail(options.payload || '')
},
methods: {
t(key, params) {
return this.$tm('pkgService.application_detail.application_detail', key, params)
},
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
},
restoreDetail(payload = '') {
if (payload) {
try {
const parsed = JSON.parse(decodeURIComponent(payload))
this.detail = parsed && typeof parsed === 'object' ? parsed : {}
return
} catch (error) {
console.error('parse application detail payload error', error)
}
}
const list = normalizeApplicationList(getMyApplicationList())
const matched = list.find(item => String(item.detailCode || '') === String(this.detailCode || ''))
this.detail = matched || {
detailCode: this.detailCode,
isViewingApplication: false
}
},
parseViewingRemark() {
const lines = this.remarkLines
const targets = [
{ zh: '看房时间', en: 'Viewing Time' },
{ zh: '预计租住时长', en: 'Expected Stay Duration' },
{ zh: '租金预算', en: 'Budget' },
{ zh: '工作/通勤地点', en: 'Work / Commute Location' },
{ zh: '意向区域', en: 'Preferred Area' },
{ zh: '意向房型', en: 'Preferred Room Type' },
{ zh: '支付方式', en: 'Payment Method' }
]
return lines
.map((line) => {
const matched = targets.find(item => {
return line.startsWith(`${item.zh}:`)
|| line.startsWith(`${item.zh}:`)
|| line.startsWith(`${item.en}:`)
|| line.startsWith(`${item.en}:`)
})
if (!matched) {
return null
}
const localizedLabelMap = {
'看房时间': this.t('viewingTimeKey'),
'预计租住时长': this.t('leaseDurationKey'),
'租金预算': this.t('budgetKey'),
'工作/通勤地点': this.t('commuteKey'),
'意向区域': this.t('preferredAreaKey'),
'意向房型': this.t('preferredRoomTypeKey'),
'支付方式': this.t('paymentMethodKey')
}
const rawValue = line
.replace(`${matched.zh}:`, '')
.replace(`${matched.zh}:`, '')
.replace(`${matched.en}:`, '')
.replace(`${matched.en}:`, '')
return {
label: localizedLabelMap[matched.zh] || matched.en,
value: rawValue || '--'
}
})
.filter(Boolean)
},
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)
},
copyCode() {
if (!this.detail.detailCode) {
return
}
uni.setClipboardData({
data: String(this.detail.detailCode),
success: () => {
uni.showToast({
title: this.t('codeCopied'),
icon: 'none'
})
}
})
},
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)
})
})
},
syncCanceledDetailToCache() {
const list = normalizeApplicationList(getMyApplicationList())
if (!list.length) {
return
}
const nextList = list.map((item) => {
if (String(item.detailCode || '') !== String(this.detail.detailCode || '')) {
return item
}
return {
...item,
status: 99,
statusDesc: this.t('statusFinished')
}
})
saveMyApplicationList(nextList)
},
handleCancelOrder() {
if (!this.canCancelOrder || !this.detail.detailCode) {
return
}
this.confirmCancelOrder()
.then((confirmed) => {
if (!confirmed) {
return
}
this.canceling = true
return userDetailCancel({
detailCode: this.detail.detailCode,
code: this.detail.detailCode,
referenceTypeNo: this.detail.detailCode
}, this.language)
.then(() => {
this.detail = {
...this.detail,
status: 99,
statusDesc: this.t('statusFinished')
}
this.syncCanceledDetailToCache()
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.canceling = false
})
},
goBackList() {
uni.navigateBack()
},
goToBillPage() {
if (!this.detail.detailCode) {
uni.showToast({
title: this.t('missingBillCode'),
icon: 'none'
})
return
}
saveActiveApplicationCode(this.detail.detailCode)
uni.navigateTo({
url: `/pkgService/utility_recharge/utility_recharge?referenceTypeNo=${encodeURIComponent(this.detail.detailCode)}`
})
}
}
}
</script>
<style scoped lang="scss">
.application-detail-page {
min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
}
.hero-panel,
.content-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);
}
.hero-viewing {
background: linear-gradient(135deg, rgba($brand-primary, 0.12) 0%, rgba($brand-primary, 0.04) 100%);
}
.hero-stay {
background: linear-gradient(135deg, rgba(64, 203, 242, 0.12) 0%, rgba($brand-primary, 0.04) 100%);
}
.status-reminder-panel {
display: flex;
align-items: flex-start;
gap: 20rpx;
padding: 28rpx 32rpx;
border-radius: 24rpx;
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
}
.status-reminder-panel--warning {
background: linear-gradient(180deg, rgba(255, 247, 237, 0.96) 0%, rgba(255, 251, 245, 0.98) 100%);
border: 1px solid rgba(249, 115, 22, 0.16);
}
.status-reminder-panel--info {
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96) 0%, rgba(248, 250, 252, 0.98) 100%);
border: 1px solid rgba(59, 130, 246, 0.14);
}
.status-reminder-panel__icon {
width: 68rpx;
height: 68rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 34rpx;
}
.status-reminder-panel--warning .status-reminder-panel__icon {
color: #C2410C;
background: rgba(249, 115, 22, 0.12);
}
.status-reminder-panel--info .status-reminder-panel__icon {
color: #1D4ED8;
background: rgba(59, 130, 246, 0.12);
}
.status-reminder-panel__content {
flex: 1;
min-width: 0;
}
.status-reminder-panel__title {
font-size: 26rpx;
font-weight: 700;
color: #0F172A;
}
.status-reminder-panel__text {
margin-top: 8rpx;
font-size: 30rpx;
line-height: 1.6;
font-weight: 600;
color: #334155;
}
.hero-top-row,
.hero-meta-row,
.meta-row,
.action-row {
display: flex;
align-items: center;
}
.hero-top-row,
.meta-row {
justify-content: space-between;
}
.hero-meta-row,
.action-row {
gap: 16rpx;
flex-wrap: wrap;
}
.hero-badge,
.hero-status,
.hero-meta-chip,
.copy-btn,
.ghost-btn,
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
.hero-badge,
.hero-status {
padding: 10rpx 22rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
}
.badge-viewing,
.badge-stay {
background: rgba($brand-primary, 0.14);
color: $brand-primary;
}
.hero-status--pending {
background: rgba(245, 158, 11, 0.12);
color: #B45309;
}
.hero-status--approved {
background: rgba($brand-primary, 0.12);
color: $brand-primary;
}
.hero-status--rejected {
background: rgba(239, 68, 68, 0.12);
color: #B91C1C;
}
.hero-status--finished {
background: rgba(100, 116, 139, 0.12);
color: #475569;
}
.hero-title,
.panel-title {
font-size: 34rpx;
font-weight: 700;
color: #1A2233;
}
.hero-meta-chip {
height: 56rpx;
padding: 0 20rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.78);
color: #51607A;
font-size: 22rpx;
}
.meta-row {
padding: 18rpx 0;
border-bottom: 1px dashed rgba($brand-primary, 0.14);
}
.meta-row:last-child {
border-bottom: none;
}
.meta-label {
color: #6A7A99;
font-size: 26rpx;
}
.meta-value {
max-width: 62%;
text-align: right;
color: #1A2233;
font-size: 26rpx;
font-weight: 500;
}
.meta-action-wrap {
display: flex;
align-items: center;
gap: 12rpx;
}
.copy-btn {
height: 48rpx;
padding: 0 18rpx;
border-radius: 999rpx;
background: rgba($brand-primary, 0.1);
color: $brand-primary;
}
.remark-panel {
padding: 24rpx;
border-radius: 20rpx;
background: rgba($brand-primary, 0.03);
border: 1rpx solid rgba($brand-primary, 0.12);
}
.remark-line {
color: #1A2233;
line-height: 1.8;
font-size: 27rpx;
}
.ghost-btn,
.danger-btn,
.action-btn {
flex: 1;
min-height: 92rpx;
border-radius: 999rpx;
}
.ghost-btn {
background: #FFFFFF;
border: 1rpx solid rgba($brand-primary, 0.2);
color: $brand-primary;
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.06);
}
.danger-btn {
background: #FFFFFF;
border: 1rpx solid rgba(239, 68, 68, 0.22);
color: #DC2626;
box-shadow: 0 6rpx 16rpx rgba(239, 68, 68, 0.08);
}
.danger-btn--loading {
opacity: 0.72;
}
.action-btn {
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);
}
</style>