Files
QSY-lvjuxcx/pkgService/my_application/my_application.vue
liangjian 445bd850f4 feat: 新增工单中心、房间巡查、失物招领等业务模块
- 新增工单中心(ticket_center)与工单工作台(ticket_workbench)页面
- 新增房间巡查(room_inspection)与失物招领发布(lost_found_publish)页面
- 预约看样流程新增房型/楼栋/楼层选择器组件
- 新增工具模块: booking-entry、privacy、reservation-flow
- api/index.js 补充楼层列表、看样提交等接口
- locales/messages.js 补充多语言文案
- manifest.json 开启微信小程序隐私检查
- 完善登录、注册、申请详情、账单等相关页面交互
2026-07-24 09:35:46 +08:00

743 lines
21 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-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' }">
<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">{{ t('toolbarSummary', { 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 ? t('refreshing') : t('refresh') }}
</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 class="card-body tn-margin-top">
<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 v-if="item.remark" class="remark-panel tn-margin-top">
<view class="remark-label">{{ t('remarkLabel') }}</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">{{ t('viewBill') }}</text>
</view>
</view>
</view>
<view class="load-state">
<text v-if="loadingMore">{{ t('loadingMore') }}</text>
<text v-else-if="!hasMore">{{ t('noMore') }}</text>
<text v-else>{{ t('pullUpMore') }}</text>
</view>
</view>
<view v-else-if="!loading" class="empty-panel tn-padding-lg">
<view class="empty-title">{{ t('emptyTitle') }}</view>
<view class="empty-desc tn-margin-top-sm">{{ t('emptyDesc') }}</view>
<view class="action-btn tn-margin-top-lg" @click="goSelectRoom">
<text class="tn-text-bold">{{ t('goSelectRoom') }}</text>
</view>
</view>
<view v-if="loading && !list.length" class="empty-panel tn-padding-lg">
<view class="empty-title">{{ t('loadingTitle') }}</view>
<view class="empty-desc tn-margin-top-sm">{{ t('loadingDesc') }}</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 normalizeApplicationList(list = []) {
if (!Array.isArray(list)) {
return []
}
return list
.filter(item => item && typeof item === 'object')
.map((item) => {
return {
...item,
isViewingApplication: isViewingApplicationRecord(item)
}
})
}
export default {
i18n: {
zh: {
navTitle: '我的申请',
toolbarSummary: '共 {total} 条申请',
refreshing: '刷新中...',
refresh: '刷新状态',
apartmentLabel: '所属公寓',
occupantLabel: '入住人',
viewingTimeLabel: '预约时间',
leaseTermLabel: '预计租期',
submittedAtLabel: '提交时间',
remarkLabel: '补充说明',
viewBill: '查看关联账单',
loadingMore: '加载中...',
noMore: '没有更多申请了',
pullUpMore: '上拉加载更多',
emptyTitle: '暂无申请记录',
emptyDesc: '您还没有提交任何申请,去选房后可发起入住申请或预约看房。',
goSelectRoom: '去选房',
loadingTitle: '申请列表加载中',
loadingDesc: '正在同步您的入住申请记录,请稍候。',
statusAll: '全部',
statusPending: '待审核',
statusApproved: '已通过',
statusRejected: '已拒绝',
statusFinished: '已结束',
statusProcessing: '处理中',
refreshSuccess: '申请状态已刷新',
loadError: '申请记录加载失败',
viewingApplicationTitle: '预约看房申请',
stayApplicationTitle: '入住申请',
missingBillCode: '申请单编号缺失',
missingApplicationCode: '申请编号缺失',
dateRangeJoiner: ' 至 '
},
en: {
navTitle: 'My Applications',
toolbarSummary: '{total} applications',
refreshing: 'Refreshing...',
refresh: 'Refresh Status',
apartmentLabel: 'Apartment',
occupantLabel: 'Resident',
viewingTimeLabel: 'Viewing Time',
leaseTermLabel: 'Lease Term',
submittedAtLabel: 'Submitted At',
remarkLabel: 'Remarks',
viewBill: 'View Related Bill',
loadingMore: 'Loading...',
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',
loadingTitle: 'Loading applications',
loadingDesc: 'Syncing your application records. Please wait.',
statusAll: 'All',
statusPending: 'Pending',
statusApproved: 'Approved',
statusRejected: 'Rejected',
statusFinished: 'Closed',
statusProcessing: 'Processing',
refreshSuccess: 'Application status refreshed',
loadError: 'Failed to load applications',
viewingApplicationTitle: 'Viewing Request',
stayApplicationTitle: 'Stay Application',
missingBillCode: 'Missing application bill number',
missingApplicationCode: 'Missing application number',
dateRangeJoiner: ' to '
}
},
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: {
t(key, params) {
return this.$tm('pkgService.my_application.my_application', key, params)
},
syncStatusOptions() {
this.statusOptions = [
{ label: this.t('statusAll'), value: '' },
{ label: this.t('statusPending'), value: 0 },
{ label: this.t('statusApproved'), value: 10 },
{ label: this.t('statusRejected'), value: 20 },
{ label: this.t('statusFinished'), value: 99 }
]
},
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.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.t('dateRangeJoiner')}${endText}`
}
}
return `${this.formatDate(start)}${this.t('dateRangeJoiner')}${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.t('refreshSuccess'),
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.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 !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.t('missingBillCode'),
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;
}
.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>