chore: initial import

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

View File

@@ -0,0 +1,787 @@
<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>