feat: 新增工单中心、房间巡查、失物招领等业务模块

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

View File

@@ -114,14 +114,28 @@
v-if="canCancelOrder"
class="danger-btn"
:class="{ 'danger-btn--loading': canceling }"
@tap.stop="handleCancelOrder"
@click.stop="handleCancelOrder"
>
<text class="tn-text-bold">{{ canceling ? t('cancelOrderLoading') : t('cancelOrder') }}</text>
</view>
<view
v-if="canViewBill"
v-if="currentStepActionText"
class="action-btn"
@tap.stop="goToBillPage"
@click.stop="handleCurrentStepAction"
>
<text class="tn-text-bold">{{ currentStepActionText }}</text>
</view>
<view
v-else-if="isReservationCheckinProcessing()"
class="action-btn action-btn--ghost"
@click.stop="refreshCurrentPage"
>
<text class="tn-text-bold">{{ t('refreshAction') }}</text>
</view>
<view
v-else-if="canViewBill"
class="action-btn"
@click.stop="goToBillPage"
>
<text class="tn-text-bold">{{ t('viewBill') }}</text>
</view>
@@ -131,8 +145,48 @@
</template>
<script>
import { userDetailCancel } from '@/api/index.js'
import { contractDetail, orderSearchedReservationFlow, roomInspectionDetail, userDetailCancel } from '@/api/index.js'
import { getMyApplicationList, saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state'
import {
RESERVATION_FLOW_STEP,
attachReservationFlow,
buildReservationFlowMap,
getReservationCurrentActionText,
getReservationCurrentStepText,
getReservationOrderNo,
parseReservationFlowResponse
} from '@/utils/reservation-flow'
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)) {
@@ -141,15 +195,31 @@ function normalizeApplicationList(list = []) {
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
isViewingApplication: isViewingApplicationRecord(item)
}
})
}
function parseJsonStringMaybe(value) {
if (typeof value !== 'string') {
return value
}
const text = value.trim()
if (!text) {
return value
}
if (!((text.startsWith('{') && text.endsWith('}')) || (text.startsWith('[') && text.endsWith(']')))) {
return value
}
try {
return JSON.parse(text)
} catch (error) {
return value
}
}
export default {
i18n: {
zh: {
@@ -186,6 +256,9 @@ export default {
emptyRemark: '暂无补充说明',
codeCopied: '申请编号已复制',
missingBillCode: '申请单编号缺失',
missingContractNo: '合同编号缺失',
checkinPendingHint: '宿管正在办理入住,请耐心等待',
refreshAction: '刷新',
cancelOrder: '取消订单',
cancelOrderLoading: '取消中...',
cancelOrderTitle: '取消订单',
@@ -235,6 +308,9 @@ export default {
emptyRemark: 'No additional remarks',
codeCopied: 'Application number copied',
missingBillCode: 'Missing application bill number',
missingContractNo: 'Missing contract number',
checkinPendingHint: 'Move-in is being processed, please wait',
refreshAction: 'Refresh',
cancelOrder: 'Cancel Order',
cancelOrderLoading: 'Canceling...',
cancelOrderTitle: 'Cancel Order',
@@ -255,7 +331,14 @@ export default {
return {
detailCode: '',
detail: {},
canceling: false
canceling: false,
reservationContractDetail: null,
reservationInspectionDetail: null
}
},
watch: {
language() {
this.loadReservationFlow()
}
},
computed: {
@@ -323,7 +406,35 @@ export default {
}
return this.parseViewingRemark()
},
currentStepActionText() {
if (this.isViewingApplication) {
return ''
}
if (this.isReservationInspectionPendingReview()) {
return ''
}
if (this.isReservationContractAwaitingReview()) {
return ''
}
if (this.isReservationCheckinProcessing()) {
return ''
}
return getReservationCurrentActionText(this.detail, this.language)
},
statusReminderText() {
if (this.isReservationInspectionPendingReview()) {
return this.getReservationInspectionStatusText()
}
if (this.isReservationContractAwaitingReview()) {
return this.getReservationContractStatusText()
}
if (this.isReservationCheckinProcessing()) {
return this.t('checkinPendingHint')
}
const reservationStepText = getReservationCurrentStepText(this.detail, this.language)
if (reservationStepText) {
return reservationStepText
}
if (this.shouldPayDeposit(this.detail)) {
return this.t('statusPendingPayment')
}
@@ -351,6 +462,9 @@ export default {
this.detailCode = options.code || ''
this.restoreDetail(options.payload || '')
},
onShow() {
this.loadReservationFlow()
},
methods: {
t(key, params) {
return this.$tm('pkgService.application_detail.application_detail', key, params)
@@ -368,6 +482,106 @@ export default {
&& Number(item.status) === 0
&& Number(item.payStatus) === 10
},
getReservationCurrentStepValue() {
return Number(this.detail && this.detail.reservationCurrentNode ? this.detail.reservationCurrentNode.step : 0)
},
isReservationCheckinProcessing() {
return this.getReservationCurrentStepValue() === RESERVATION_FLOW_STEP.CHECKIN
},
getReservationInspectionNo() {
const currentNode = this.detail && this.detail.reservationCurrentNode ? this.detail.reservationCurrentNode : {}
if (Number(currentNode.step || 0) !== RESERVATION_FLOW_STEP.INSPECTION) {
return ''
}
return String(currentNode.refNo || '').trim()
},
getReservationContractNo() {
const currentNode = this.detail && this.detail.reservationCurrentNode ? this.detail.reservationCurrentNode : {}
if (Number(currentNode.step || 0) !== RESERVATION_FLOW_STEP.CONTRACT) {
return ''
}
return String(currentNode.refNo || '').trim()
},
normalizeInspectionDetail(response) {
const payload = parseJsonStringMaybe(response && response.data ? response.data : response)
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload
}
if (payload && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
return payload.data
}
return {}
},
normalizeContractDetail(response) {
const payload = parseJsonStringMaybe(response && response.data ? response.data : response)
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload
}
if (payload && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
return payload.data
}
return {}
},
isReservationInspectionPendingReview() {
if (this.getReservationCurrentStepValue() !== RESERVATION_FLOW_STEP.INSPECTION) {
return false
}
const detail = this.reservationInspectionDetail || {}
return Number(detail.status) === 2
},
getReservationInspectionStatusText() {
return this.t('inspectionPendingReviewHint')
},
isReservationContractAwaitingReview() {
if (this.getReservationCurrentStepValue() !== RESERVATION_FLOW_STEP.CONTRACT) {
return false
}
const detail = this.reservationContractDetail || {}
const queryStatus = Number(detail.queryStatus)
const approvalStatus = Number(detail.approvalStatus)
const hasSigned = !!String(detail.signTime || detail.signImageUrl || '').trim()
return queryStatus === 1 || (hasSigned && approvalStatus === 0)
},
getReservationContractStatusText() {
const detail = this.reservationContractDetail || {}
return detail.queryStatusDesc || detail.approvalStatusDesc || detail.statusDesc || ''
},
async ensureReservationInspectionDetail() {
const inspectionNo = this.getReservationInspectionNo()
if (!inspectionNo) {
this.reservationInspectionDetail = null
return null
}
try {
const detail = this.normalizeInspectionDetail(await roomInspectionDetail({
inspectionNo
}, this.language))
this.reservationInspectionDetail = detail && Object.keys(detail).length ? detail : null
return this.reservationInspectionDetail
} catch (error) {
console.error('load inspection detail failed', error)
this.reservationInspectionDetail = null
return null
}
},
async ensureReservationContractDetail() {
const contractNo = this.getReservationContractNo()
if (!contractNo) {
this.reservationContractDetail = null
return null
}
try {
const detail = this.normalizeContractDetail(await contractDetail({
contractNo
}, this.language))
this.reservationContractDetail = detail && Object.keys(detail).length ? detail : null
return this.reservationContractDetail
} catch (error) {
console.error('load contract detail failed', error)
this.reservationContractDetail = null
return null
}
},
restoreDetail(payload = '') {
if (payload) {
try {
@@ -386,6 +600,46 @@ export default {
isViewingApplication: false
}
},
async loadReservationFlow() {
if (!this.detail || this.detail.isViewingApplication) {
return
}
const orderNo = getReservationOrderNo(this.detail)
if (!orderNo) {
this.detail = attachReservationFlow(this.detail, {})
this.reservationInspectionDetail = null
this.reservationContractDetail = null
return
}
try {
const response = await orderSearchedReservationFlow({
orderNos: [orderNo]
}, this.language)
const flowMap = buildReservationFlowMap(parseReservationFlowResponse(response))
this.detail = attachReservationFlow(this.detail, flowMap)
await Promise.all([
this.ensureReservationInspectionDetail(),
this.ensureReservationContractDetail()
])
const list = normalizeApplicationList(getMyApplicationList())
if (list.length) {
saveMyApplicationList(list.map((item) => {
if (String(item.detailCode || '') !== String(this.detail.detailCode || '')) {
return item
}
return this.detail
}))
}
} catch (error) {
console.error('load reservation flow failed', error)
this.detail = attachReservationFlow(this.detail, {})
this.reservationInspectionDetail = null
this.reservationContractDetail = null
}
},
parseViewingRemark() {
const lines = this.remarkLines
const targets = [
@@ -537,6 +791,54 @@ export default {
uni.navigateTo({
url: `/pkgService/utility_recharge/utility_recharge?referenceTypeNo=${encodeURIComponent(this.detail.detailCode)}`
})
},
refreshCurrentPage() {
this.loadReservationFlow()
},
handleCurrentStepAction() {
const step = this.getReservationCurrentStepValue()
if (step === RESERVATION_FLOW_STEP.BILL) {
this.goToBillPage()
return
}
if (step === RESERVATION_FLOW_STEP.CONTRACT) {
if (this.isReservationContractAwaitingReview()) {
return
}
const contractNo = String(this.detail && this.detail.reservationCurrentNode && this.detail.reservationCurrentNode.refNo || '').trim()
if (!contractNo) {
uni.showToast({
title: this.t('missingContractNo'),
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pkgService/contract_sign/contract_sign?no=${encodeURIComponent(contractNo)}`
})
return
}
if (step === RESERVATION_FLOW_STEP.CHECKIN) {
uni.reLaunch({
url: '/pages/index/index?pageId=stay-service'
})
return
}
if (step === RESERVATION_FLOW_STEP.INSPECTION) {
if (this.isReservationInspectionPendingReview()) {
return
}
const orderNo = getReservationOrderNo(this.detail)
uni.navigateTo({
url: orderNo
? `/pkgService/room_inspection/room_inspection?orderNo=${encodeURIComponent(orderNo)}`
: '/pkgService/room_inspection/room_inspection'
})
}
}
}
}
@@ -784,4 +1086,11 @@ export default {
color: #FFFFFF;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.25);
}
.action-btn--ghost {
background: rgba($brand-primary, 0.08);
color: $brand-primary;
border: 1rpx solid rgba($brand-primary, 0.18);
box-shadow: none;
}
</style>

View File

@@ -245,6 +245,35 @@ import { getActiveApplicationCode, getMyApplicationList, saveActiveApplicationCo
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 normalizeBillList(response) {
const payload = response && response.data ? response.data : response
if (Array.isArray(payload)) {
@@ -263,11 +292,9 @@ function normalizeApplicationList(list = []) {
return list
.filter(item => item && typeof item === 'object')
.map((item) => {
const remark = item && item.remark ? String(item.remark) : ''
const isViewingApplication = VIEWING_APPLICATION_PATTERN.test(remark)
return {
...item,
isViewingApplication
isViewingApplication: isViewingApplicationRecord(item)
}
})
}

View File

@@ -344,6 +344,7 @@ export default {
uni.$emit('contractSignSuccess', {
contractNo: this.contractNo
})
uni.$emit('refresh-shell-state')
})
.then(() => {
uni.hideLoading()
@@ -352,8 +353,8 @@ export default {
icon: 'success'
})
setTimeout(() => {
uni.redirectTo({
url: '/pkgService/my_contract/my_contract'
uni.navigateBack({
delta: 1
})
}, 600)
})

View File

@@ -0,0 +1,554 @@
<template>
<view class="lost-found-page tn-safe-area-inset-bottom">
<tn-nav-bar
:isBack="true"
:bottomShadow="false"
:backgroundColor="navBarBackgroundColor"
:customBackStyle="navBarBackStyle"
:fixed="true"
>
<view class="tn-text-bold tn-text-xl tn-color-black" :style="{ opacity: navTitleOpacity }">{{ t('navTitle') }}</view>
</tn-nav-bar>
<scroll-view class="page-scroll" scroll-y :show-scrollbar="false" @scroll="handleScroll">
<view class="page-content tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '220rpx' }">
<view class="hero-header tn-margin-bottom-xl">
<view class="hero-badge">
<text class="badge-dot"></text>
<text>{{ t('heroBadge') }}</text>
</view>
<view class="hero-title">{{ t('heroTitle') }}</view>
<view class="hero-desc">{{ t('heroDesc') }}</view>
</view>
<view class="form-card">
<view class="block-title">{{ t('formTitle') }}</view>
<view class="block-subtitle">{{ t('formDesc') }}</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('typeLabel') }}</view>
<picker mode="selector" :range="typeLabels" @change="handleTypeChange">
<view class="form-picker">
{{ selectedTypeLabel || t('typePlaceholder') }}
</view>
</picker>
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('itemTypeLabel') }}</view>
<input v-model="form.itemType" class="form-input" :placeholder="t('itemTypePlaceholder')" maxlength="40" />
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('itemNameLabel') }}</view>
<input v-model="form.itemName" class="form-input" :placeholder="t('itemNamePlaceholder')" maxlength="60" />
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('placeLabel') }}</view>
<input v-model="form.place" class="form-input" :placeholder="t('placePlaceholder')" maxlength="100" />
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('whenLabel') }}</view>
<view class="picker-row">
<picker mode="date" :value="form.whenDate" @change="handleDateChange">
<view class="form-picker">{{ form.whenDate || t('selectDate') }}</view>
</picker>
<picker mode="time" :value="form.whenTime" @change="handleTimeChange">
<view class="form-picker form-picker--time">{{ form.whenTime || t('selectTime') }}</view>
</picker>
</view>
<view class="field-tip">{{ t('whenTip') }}</view>
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('contractLabel') }}</view>
<input v-model="form.contract" class="form-input" :placeholder="t('contractPlaceholder')" maxlength="40" />
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('contractPhoneLabel') }}</view>
<input v-model="form.contractPhone" class="form-input" :placeholder="t('contractPhonePlaceholder')" maxlength="30" />
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('remarkLabel') }}</view>
<textarea
v-model="form.remark"
class="form-textarea"
:placeholder="t('remarkPlaceholder')"
maxlength="300"
></textarea>
<view class="field-counter">{{ (form.remark || '').length }}/300</view>
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('imagesLabel') }}</view>
<tn-image-upload
:key="uploadInstanceKey"
:fileList="form.photos"
:action="uploadAction"
:header="uploadHeaders"
:formData="uploadFormData"
:max-count="6"
:max-size="5 * 1024 * 1024"
uploadText=""
@on-success="onUploadSuccess"
@on-list-change="onPhotoListChange"
@on-remove="onRemoveImage"
@on-error="onUploadError"
@on-oversize="onUploadOversize"
@on-exceed="onUploadExceed"
>
<template #addBtn>
<view class="upload-trigger">
<text class="tn-icon-camera-fill upload-trigger__icon"></text>
<view class="upload-trigger__text">{{ t('uploadImage') }}</view>
</view>
</template>
</tn-image-upload>
<view class="field-tip">{{ t('imagesTip') }}</view>
</view>
</view>
</view>
</scroll-view>
<view class="floating-submit-bar">
<view class="floating-submit-bar__inner">
<view class="floating-submit-tip">{{ t('submitTip') }}</view>
<view class="submit-btn btn-primary" :class="{ 'submit-btn--disabled': isSubmitting }" @click="submitForm">
<text class="tn-text-bold">{{ isSubmitting ? t('submitting') : t('submitButton') }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import { API_BASE_URL } from '@/utils/request'
import { getAuthToken } from '@/utils/app-state'
import { addFilePrefix } from '@/utils/common'
import { lostAndFoundPublish } from '@/api/index.js'
import { getStoredUser } from '@/utils/flow'
function pickReadableValue(...values) {
for (let index = 0; index < values.length; index += 1) {
const text = String(values[index] || '').trim()
if (text) {
return text
}
}
return ''
}
export default {
name: 'LostFoundPublishPage',
data() {
return {
scrollTop: 0,
isSubmitting: false,
uploadInstanceKey: 0,
form: this.createDefaultForm()
}
},
computed: {
language() {
return this.$getLanguage ? this.$getLanguage() : 'zh'
},
navBarBackgroundColor() {
if (this.scrollTop < 40) return 'rgba(255, 255, 255, 0)'
return 'rgba(255, 255, 255, 0.96)'
},
navBarBackStyle() {
const isDarkBg = this.scrollTop < 40
return {
transition: 'all 0.2s ease',
background: isDarkBg ? 'rgba(0, 0, 0, 0.2)' : 'transparent',
borderRadius: '50%',
width: '64rpx',
height: '64rpx',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: '12rpx',
color: isDarkBg ? '#FFFFFF' : '#000000'
}
},
navTitleOpacity() {
return Math.min(Math.max((this.scrollTop - 40) / 50, 0), 1)
},
typeOptions() {
return [
{ value: 1, label: this.t('typeLost'), labelEn: this.t('typeLost') },
{ value: 2, label: this.t('typeFound'), labelEn: this.t('typeFound') }
]
},
typeLabels() {
return this.typeOptions.map(item => item.label)
},
selectedTypeLabel() {
const current = this.typeOptions.find(item => item.value === this.form.type)
return current ? current.label : ''
},
uploadAction() {
return `${String(API_BASE_URL || '').replace(/\/$/, '')}/file/common/upload`
},
uploadHeaders() {
const token = getAuthToken()
return {
...(token ? { Authorization: `Bearer ${token}` } : {}),
'X-Requested-Language': this.language === 'en' ? 'en' : 'zh'
}
},
uploadFormData() {
return {
ProviderType: 'WechatMiniProgram'
}
}
},
onLoad() {
this.prefillContact()
},
methods: {
t(key, params) {
return this.$tm('pkgService.lost_found_publish.lost_found_publish', key, params)
},
createDefaultForm() {
return {
type: 1,
itemType: '',
itemName: '',
place: '',
whenDate: '',
whenTime: '',
contract: '',
contractPhone: '',
remark: '',
photos: [],
imageUrls: []
}
},
prefillContact() {
const user = getStoredUser() || {}
this.form.contract = this.form.contract || pickReadableValue(user.name, user.realName, user.username)
this.form.contractPhone = this.form.contractPhone || pickReadableValue(user.phone, user.mobile, user.tel)
},
handleScroll(e) {
this.scrollTop = e.detail.scrollTop
},
handleTypeChange(event) {
const index = Number(event && event.detail ? event.detail.value : 0)
const target = this.typeOptions[index]
this.form.type = target ? target.value : 1
},
handleDateChange(event) {
this.form.whenDate = event && event.detail ? event.detail.value : ''
},
handleTimeChange(event) {
this.form.whenTime = event && event.detail ? event.detail.value : ''
},
resolveWhenValue() {
if (!this.form.whenDate) {
return null
}
const timeText = this.form.whenTime || '00:00'
return `${this.form.whenDate}T${timeText}:00`
},
resolveUploadedFileUrl(uploadResult) {
return pickReadableValue(
uploadResult && uploadResult.data && uploadResult.data.url,
uploadResult && uploadResult.data && uploadResult.data.fileUrl,
uploadResult && uploadResult.data && uploadResult.data.path,
uploadResult && uploadResult.data,
uploadResult && uploadResult.url,
uploadResult && uploadResult.fileUrl,
uploadResult && uploadResult.path,
typeof uploadResult === 'string' ? uploadResult : ''
)
},
onUploadSuccess(data, index, list) {
let parsedData = data
if (typeof data === 'string') {
try {
parsedData = JSON.parse(data)
} catch (error) {}
}
const resourceUrl = this.resolveUploadedFileUrl(parsedData)
if (!resourceUrl) {
return
}
this.form.photos = Array.isArray(list) ? list : this.form.photos
this.form.imageUrls.splice(index, 1, resourceUrl)
},
onPhotoListChange(list) {
this.form.photos = Array.isArray(list) ? list : []
},
onRemoveImage(index) {
this.form.imageUrls.splice(index, 1)
},
onUploadError() {
uni.showToast({ title: this.t('uploadFailed'), icon: 'none' })
},
onUploadOversize() {
uni.showToast({ title: this.t('uploadOversize'), icon: 'none' })
},
onUploadExceed() {
uni.showToast({ title: this.t('uploadExceed'), icon: 'none' })
},
getValidationMessage() {
if (!String(this.form.itemType || '').trim()) return this.t('validateItemType')
if (!String(this.form.itemName || '').trim()) return this.t('validateItemName')
if (!String(this.form.place || '').trim()) return this.t('validatePlace')
if (!String(this.form.contract || '').trim()) return this.t('validateContract')
if (!String(this.form.contractPhone || '').trim()) return this.t('validateContractPhone')
return ''
},
buildSubmitPayload() {
return {
type: Number(this.form.type) || 1,
itemType: String(this.form.itemType || '').trim(),
itemName: String(this.form.itemName || '').trim(),
place: String(this.form.place || '').trim(),
when: this.resolveWhenValue(),
contract: String(this.form.contract || '').trim(),
contractPhone: String(this.form.contractPhone || '').trim(),
remark: String(this.form.remark || '').trim() || null,
imageUrls: this.form.imageUrls.length ? this.form.imageUrls.slice() : null
}
},
resetForm() {
this.uploadInstanceKey += 1
this.form = this.createDefaultForm()
this.prefillContact()
},
submitForm() {
if (this.isSubmitting) {
return
}
const validationMessage = this.getValidationMessage()
if (validationMessage) {
uni.showToast({ title: validationMessage, icon: 'none' })
return
}
this.isSubmitting = true
lostAndFoundPublish(this.buildSubmitPayload(), this.language)
.then(() => {
uni.showToast({
title: this.t('submitSuccess'),
icon: 'success'
})
this.resetForm()
setTimeout(() => {
uni.navigateBack({
delta: 1
})
}, 600)
})
.catch((error) => {
uni.showToast({
title: (error && (error.message || error.msg)) || this.t('submitFailed'),
icon: 'none'
})
})
.finally(() => {
this.isSubmitting = false
})
}
}
}
</script>
<style scoped lang="scss">
.lost-found-page {
min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
}
.page-scroll {
height: 100vh;
}
.hero-header {
padding: 24rpx 8rpx 0;
}
.hero-badge {
display: inline-flex;
align-items: center;
padding: 10rpx 18rpx;
background: rgba($brand-primary, 0.08);
border-radius: 999rpx;
color: $brand-primary;
font-size: 22rpx;
font-weight: 700;
}
.badge-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: $brand-primary;
margin-right: 10rpx;
}
.hero-title {
margin-top: 22rpx;
font-size: 52rpx;
line-height: 1.2;
color: #1A2233;
font-weight: 800;
}
.hero-desc {
margin-top: 16rpx;
color: #6A7A99;
font-size: 26rpx;
line-height: 1.6;
}
.form-card {
background: #FFFFFF;
border-radius: 28rpx;
box-shadow: 0 12rpx 36rpx rgba(43, 142, 255, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.8);
padding: 30rpx;
}
.block-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.block-subtitle {
margin-top: 10rpx;
font-size: 24rpx;
color: #7C8AA5;
line-height: 1.6;
}
.form-label {
margin-bottom: 16rpx;
font-size: 28rpx;
color: #1A2233;
font-weight: 700;
}
.form-input,
.form-picker,
.form-textarea {
width: 100%;
background: #F8FAFF;
border-radius: 22rpx;
border: 1rpx solid rgba($brand-primary, 0.08);
color: #1A2233;
}
.form-input,
.form-picker {
min-height: 96rpx;
padding: 0 26rpx;
display: flex;
align-items: center;
font-size: 28rpx;
}
.form-textarea {
min-height: 180rpx;
padding: 26rpx;
font-size: 28rpx;
line-height: 1.6;
}
.picker-row {
display: flex;
gap: 16rpx;
}
.picker-row picker {
flex: 1;
}
.form-picker--time {
justify-content: center;
}
.field-tip {
margin-top: 12rpx;
font-size: 22rpx;
color: #8A97AE;
line-height: 1.6;
}
.field-counter {
margin-top: 12rpx;
text-align: right;
font-size: 22rpx;
color: #9AA6BC;
}
.upload-trigger {
width: 200rpx;
height: 200rpx;
border-radius: 22rpx;
background: linear-gradient(135deg, rgba($brand-primary, 0.08) 0%, rgba($brand-primary, 0.03) 100%);
border: 1rpx dashed rgba($brand-primary, 0.26);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-trigger__icon {
font-size: 40rpx;
color: $brand-primary;
}
.upload-trigger__text {
margin-top: 12rpx;
font-size: 24rpx;
color: $brand-primary;
font-weight: 600;
}
.floating-submit-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 24rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(248, 250, 255, 0) 0%, rgba(248, 250, 255, 0.98) 36%, rgba(248, 250, 255, 1) 100%);
}
.floating-submit-bar__inner {
background: rgba(255, 255, 255, 0.96);
border-radius: 28rpx;
padding: 22rpx;
box-shadow: 0 12rpx 36rpx rgba(43, 142, 255, 0.08);
}
.floating-submit-tip {
margin-bottom: 16rpx;
font-size: 22rpx;
color: #7C8AA5;
}
.submit-btn {
height: 96rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
.btn-primary {
background: linear-gradient(135deg, rgba($brand-primary, 0.92) 0%, $brand-primary 100%);
color: #FFFFFF;
box-shadow: 0 12rpx 28rpx rgba($brand-primary, 0.24);
}
.submit-btn--disabled {
opacity: 0.7;
}
</style>

View File

@@ -109,14 +109,51 @@
</template>
<script>
import { applicationFormMePage } from '@/api/index.js'
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 []
@@ -124,11 +161,9 @@ function normalizeApplicationList(list = []) {
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
isViewingApplication: isViewingApplicationRecord(item)
}
})
}
@@ -349,7 +384,7 @@ export default {
}
await applicationFormMePage(params, this.language)
.then((response) => {
.then(async (response) => {
const pageData = formatPageResponse(response)
console.log('pageData', pageData)
const nextList = normalizeApplicationList(pageData.data || [])
@@ -358,12 +393,14 @@ export default {
this.total = total
this.list = normalizeApplicationList(isReload ? nextList : this.list.concat(nextList))
this.hasMore = this.list.length < total && nextList.length > 0
saveMyApplicationList(this.list)
const activeCode = this.resolvePreferredReferenceTypeNo(this.list)
if (activeCode) {
saveActiveApplicationCode(activeCode)
}
await this.loadReservationFlowsForList(this.list)
saveMyApplicationList(this.list)
})
.catch((error) => {
if (!isReload) {
@@ -416,6 +453,41 @@ export default {
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
@@ -437,10 +509,6 @@ export default {
goToApplicationDetail(item = {}) {
const code = item.detailCode || ''
if (!code) {
uni.showToast({
title: this.t('missingApplicationCode'),
icon: 'none'
})
return
}
uni.navigateTo({

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
<template>
<view class="building-selector">
<view
class="selector-header tn-flex tn-flex-row-between tn-flex-col-center"
:class="{ 'selector-header--disabled': !areaCode }"
@click="handleOpen"
>
<view class="selected-info">
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedBuilding }">
{{ selectedBuilding ? getBuildingDisplayName(selectedBuilding) : placeholderText }}
</view>
</view>
<text class="tn-icon-right" :class="areaCode ? 'tn-color-gray' : 'tn-color-disabled'"></text>
</view>
<tn-popup v-model="showPopup" mode="bottom" length="72%" :borderRadius="32" :closeBtn="true">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">{{ t('popupTitle') }}</text>
</view>
<scroll-view scroll-y class="building-list-scroll">
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
<tn-loading mode="circle"></tn-loading>
<view class="tn-margin-top-sm tn-color-gray">{{ t('loadingText') }}</view>
</view>
<view v-else-if="!buildingList.length" class="empty-state tn-padding-xl tn-text-center">
<text class="tn-color-gray">{{ t('emptyText') }}</text>
</view>
<view v-else class="building-list">
<view
v-for="item in buildingList"
:key="item.code"
class="building-item"
:class="{ 'is-active': selectedBuilding && selectedBuilding.code === item.code }"
@click="handleSelect(item)"
>
<text class="building-name">{{ getBuildingDisplayName(item) }}</text>
<text v-if="selectedBuilding && selectedBuilding.code === item.code" class="tn-icon-success-circle-fill building-check"></text>
</view>
</view>
</scroll-view>
</view>
</tn-popup>
</view>
</template>
<script>
import { buildingPagination } from '@/api/index.js'
function normalizeText(value) {
if (value === undefined || value === null) {
return ''
}
return String(value).trim()
}
function normalizeBuildingItem(item = {}) {
return {
areaCode: normalizeText(item.areaCode),
code: normalizeText(item.code),
name: normalizeText(item.name),
nameEn: normalizeText(item.nameEn),
sort: Number(item.sort || 0),
buildingSections: Array.isArray(item.buildingSections) ? item.buildingSections : []
}
}
export default {
name: 'BuildingSelector',
props: {
value: {
type: String,
default: ''
},
areaCode: {
type: String,
default: ''
},
roomTypeCode: {
type: String,
default: ''
}
},
data() {
return {
showPopup: false,
isLoading: false,
buildingList: [],
selectedBuilding: null
}
},
computed: {
language() {
return this.$getLanguage ? this.$getLanguage() : 'zh'
},
placeholderText() {
return this.areaCode ? this.t('selectBuilding') : this.t('selectAreaFirst')
}
},
watch: {
value() {
this.syncSelection()
},
language() {
this.loadData(true)
},
areaCode() {
this.handleAreaChange()
},
roomTypeCode(newVal, oldVal) {
if (newVal !== oldVal) {
this.handleRoomTypeChange()
}
}
},
mounted() {
this.loadData()
},
methods: {
t(key, params) {
return this.$tm('pkgService.submit_request.components.BuildingSelector', key, params)
},
handleOpen() {
if (!this.areaCode) {
uni.showToast({
title: this.t('selectAreaFirst'),
icon: 'none'
})
return
}
this.showPopup = true
this.loadData()
},
handleAreaChange() {
this.buildingList = []
this.selectedBuilding = null
if (this.value) {
this.$emit('input', '')
this.$emit('change', { building: null })
}
if (!this.areaCode) {
return
}
this.loadData(true)
},
handleRoomTypeChange() {
this.buildingList = []
this.selectedBuilding = null
if (this.value) {
this.$emit('input', '')
this.$emit('change', { building: null })
}
if (!this.areaCode) {
return
}
this.loadData(true)
},
loadData(force = false) {
if (!this.areaCode) {
return Promise.resolve()
}
if (!force && this.buildingList.length) {
this.syncSelection()
return Promise.resolve()
}
this.isLoading = true
return buildingPagination({
AreaCode: this.areaCode,
PageIndex: 1,
PageSize: 100
}, this.language)
.then((res) => {
const payload = res && res.data ? res.data : res
const rawList = Array.isArray(payload && payload.data)
? payload.data
: (Array.isArray(payload) ? payload : [])
this.buildingList = rawList
.map(item => normalizeBuildingItem(item))
.filter(item => item.code)
.sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code))
this.syncSelection()
})
.catch((error) => {
console.error('load building list error', error)
this.buildingList = []
this.selectedBuilding = null
uni.showToast({
title: this.t('loadFailed'),
icon: 'none'
})
})
.finally(() => {
this.isLoading = false
})
},
syncSelection() {
if (!this.value || !this.buildingList.length) {
this.selectedBuilding = null
return
}
this.selectedBuilding = this.buildingList.find(item => item.code === this.value) || null
},
handleSelect(item) {
this.selectedBuilding = item
this.$emit('input', item.code)
this.$emit('change', { building: item })
this.showPopup = false
},
getBuildingDisplayName(item = {}) {
if (this.language === 'en') {
return item.nameEn || item.name || item.code || ''
}
return item.name || item.nameEn || item.code || ''
}
}
}
</script>
<style scoped lang="scss">
.building-selector {
width: 100%;
}
.selector-header {
background: #FFFFFF;
border-radius: 20rpx;
padding: 26rpx 32rpx;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
min-height: 100rpx;
}
.selector-header--disabled {
opacity: 0.72;
}
.selected-info {
flex: 1;
overflow: hidden;
}
.selected-name {
font-size: 28rpx;
color: #1A2233;
font-weight: 600;
&.is-empty {
color: #A0B0C0;
font-weight: normal;
}
}
.popup-content {
display: flex;
flex-direction: column;
height: 100%;
background: #F8FAFF;
}
.popup-header {
padding: 40rpx 30rpx 20rpx;
text-align: center;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
background: #FFFFFF;
}
.popup-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.building-list-scroll {
flex: 1;
height: 0;
padding: 20rpx 30rpx 60rpx;
}
.building-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.building-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #FFFFFF;
border-radius: 20rpx;
padding: 28rpx 30rpx;
border: 1rpx solid rgba($brand-primary, 0.12);
transition: all 0.2s ease;
&.is-active {
border-color: rgba($brand-primary, 0.5);
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.08);
}
}
.building-name {
font-size: 28rpx;
color: #1A2233;
}
.building-check {
color: $brand-primary;
font-size: 30rpx;
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<view class="floor-selector">
<view
class="selector-header tn-flex tn-flex-row-between tn-flex-col-center"
@click="handleOpen"
>
<view class="selected-info">
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedFloor }">
{{ selectedFloor ? getFloorDisplayName(selectedFloor) : placeholderText }}
</view>
</view>
<text class="tn-icon-right tn-color-gray"></text>
</view>
<tn-popup v-model="showPopup" mode="bottom" length="72%" :borderRadius="32" :closeBtn="true">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">{{ t('popupTitle') }}</text>
</view>
<scroll-view scroll-y class="floor-list-scroll">
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
<tn-loading mode="circle"></tn-loading>
<view class="tn-margin-top-sm tn-color-gray">{{ t('loadingText') }}</view>
</view>
<view v-else-if="!floorList.length" class="empty-state tn-padding-xl tn-text-center">
<text class="tn-color-gray">{{ t('emptyText') }}</text>
</view>
<view v-else class="floor-list">
<view
class="floor-item floor-item--clear"
:class="{ 'is-active': !selectedFloor }"
@click="handleClear"
>
<text class="floor-name">{{ t('clearSelection') }}</text>
<text v-if="!selectedFloor" class="tn-icon-success-circle-fill floor-check"></text>
</view>
<view
v-for="item in floorList"
:key="item.value"
class="floor-item"
:class="{ 'is-active': selectedFloor && selectedFloor.value === item.value }"
@click="handleSelect(item)"
>
<text class="floor-name">{{ getFloorDisplayName(item) }}</text>
<text v-if="selectedFloor && selectedFloor.value === item.value" class="tn-icon-success-circle-fill floor-check"></text>
</view>
</view>
</scroll-view>
</view>
</tn-popup>
</view>
</template>
<script>
import { getEnums } from '@/api/index.js'
function pickFirstDefined(...values) {
const matched = values.find(value => value !== undefined && value !== null && value !== '')
return matched === undefined ? '' : matched
}
function normalizeFloorValue(value) {
if (value === undefined || value === null) {
return ''
}
return String(value).trim()
}
function resolveFloorList(response) {
const payload = response && response.data !== undefined ? response.data : response
if (Array.isArray(payload)) {
const floorEnum = payload.find(item => item && item.code === 'FloorLevelEnum')
if (floorEnum && Array.isArray(floorEnum.data)) {
return floorEnum.data
}
return payload
}
if (payload && Array.isArray(payload.data)) {
return payload.data
}
if (payload && payload.data && Array.isArray(payload.data.data)) {
return payload.data.data
}
if (payload && Array.isArray(payload.items)) {
return payload.items
}
if (payload && Array.isArray(payload.records)) {
return payload.records
}
return []
}
function normalizeFloorItem(item = {}) {
const code = normalizeFloorValue(pickFirstDefined(item.key, item.code, item.value))
const name = normalizeFloorValue(pickFirstDefined(item.cname, item.nameZh, item.labelZh, item.name, item.value))
const nameEn = normalizeFloorValue(pickFirstDefined(item.ename, item.nameEn, item.labelEn, item.descriptionEn, ''))
return {
value: code || name,
code,
name,
nameEn,
sort: Number(pickFirstDefined(item.sort, item.key, 0))
}
}
export default {
name: 'FloorSelector',
props: {
value: {
type: [String, Number],
default: ''
}
},
data() {
return {
showPopup: false,
isLoading: false,
floorList: [],
selectedFloor: null
}
},
computed: {
language() {
return this.$getLanguage ? this.$getLanguage() : 'zh'
},
placeholderText() {
return this.t('selectFloor')
}
},
watch: {
value() {
this.syncSelection()
},
language() {
this.loadData(true)
}
},
mounted() {
this.loadData()
},
methods: {
t(key, params) {
return this.$tm('pkgService.submit_request.components.FloorSelector', key, params)
},
handleOpen() {
this.showPopup = true
this.loadData()
},
loadData(force = false) {
if (!force && this.floorList.length) {
this.syncSelection()
return Promise.resolve()
}
this.isLoading = true
return getEnums({}, this.language)
.then((res) => {
const rawList = resolveFloorList(res)
this.floorList = rawList
.map(item => normalizeFloorItem(item))
.filter(item => item.value)
.sort((a, b) => a.sort - b.sort || a.value.localeCompare(b.value))
this.syncSelection()
})
.catch((error) => {
console.error('load floor list error', error)
this.floorList = []
this.selectedFloor = null
uni.showToast({
title: this.t('loadFailed'),
icon: 'none'
})
})
.finally(() => {
this.isLoading = false
})
},
syncSelection() {
const currentValue = normalizeFloorValue(this.value)
if (!currentValue || !this.floorList.length) {
this.selectedFloor = null
return
}
this.selectedFloor = this.floorList.find(item => item.value === currentValue) || null
},
handleSelect(item) {
this.selectedFloor = item
this.$emit('input', item.value)
this.$emit('change', { floor: item })
this.showPopup = false
},
handleClear() {
this.selectedFloor = null
this.$emit('input', '')
this.$emit('change', { floor: null })
this.showPopup = false
},
getFloorDisplayName(item = {}) {
if (this.language === 'en') {
return item.nameEn || item.name || item.code || ''
}
return item.name || item.nameEn || item.code || ''
}
}
}
</script>
<style scoped lang="scss">
.floor-selector {
width: 100%;
}
.selector-header {
background: #FFFFFF;
border-radius: 20rpx;
padding: 26rpx 32rpx;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
min-height: 100rpx;
}
.selected-info {
flex: 1;
overflow: hidden;
}
.selected-name {
font-size: 28rpx;
color: #1A2233;
font-weight: 600;
&.is-empty {
color: #A0B0C0;
font-weight: normal;
}
}
.popup-content {
display: flex;
flex-direction: column;
height: 100%;
background: #F8FAFF;
}
.popup-header {
padding: 40rpx 30rpx 20rpx;
text-align: center;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
background: #FFFFFF;
}
.popup-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.floor-list-scroll {
flex: 1;
height: 0;
padding: 20rpx 30rpx 60rpx;
}
.floor-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.floor-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #FFFFFF;
border-radius: 20rpx;
padding: 28rpx 30rpx;
border: 1rpx solid rgba($brand-primary, 0.12);
transition: all 0.2s ease;
&.is-active {
border-color: rgba($brand-primary, 0.5);
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.08);
}
}
.floor-item--clear {
border-style: dashed;
}
.floor-name {
font-size: 28rpx;
color: #1A2233;
}
.floor-check {
color: $brand-primary;
font-size: 30rpx;
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<view class="room-type-selector">
<view class="selector-header tn-flex tn-flex-row-between tn-flex-col-center" @click="showPopup = true">
<view class="selector-header tn-flex tn-flex-row-between tn-flex-col-center" @click="openSelector">
<view class="selected-info">
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }">
{{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : t('selectRoomType') }}
@@ -24,11 +24,11 @@
<view class="tn-margin-top-sm tn-color-gray">{{ t('loadingRoomTypes') }}</view>
</view>
<view v-else-if="!areaList.length" class="empty-state tn-padding-xl tn-text-center">
<view v-else-if="!displayAreaList.length" class="empty-state tn-padding-xl tn-text-center">
<text class="tn-color-gray">{{ t('emptyRoomTypes') }}</text>
</view>
<view v-else class="area-group" v-for="(area, index) in areaList" :key="area.code">
<view v-else class="area-group" v-for="(area, index) in displayAreaList" :key="area.code">
<view class="area-title tn-flex tn-flex-row-between tn-flex-col-center" @click="toggleArea(index)">
<view class="tn-flex tn-flex-col-center">
<text class="area-dot"></text>
@@ -69,6 +69,10 @@ export default {
initialAreaCode: {
type: String,
default: ''
},
buildingCode: {
type: String,
default: ''
}
},
data() {
@@ -84,6 +88,19 @@ export default {
computed: {
language() {
return this.$getLanguage ? this.$getLanguage() : 'zh'
},
displayAreaList() {
if (!this.buildingCode) {
return this.areaList
}
return this.areaList
.map(area => ({
...area,
roomTypes: Array.isArray(area.roomTypes)
? area.roomTypes.filter(roomType => !roomType.buildingCode || roomType.buildingCode === this.buildingCode)
: []
}))
.filter(area => area.roomTypes && area.roomTypes.length > 0)
}
},
watch: {
@@ -92,6 +109,9 @@ export default {
},
language() {
this.loadData(true)
},
buildingCode() {
this.syncSelection()
}
},
mounted() {
@@ -101,6 +121,10 @@ export default {
t(key) {
return this.$translate(this.i18n, key)
},
openSelector() {
this.showPopup = true
this.loadData()
},
loadData(force = false) {
if (!force && this.areaList.length > 0) return
@@ -131,14 +155,16 @@ export default {
})
},
syncSelection() {
if (!this.areaList.length) return
if (!this.displayAreaList.length) return
let foundArea = null
let foundRoom = null
const previousAreaCode = this.selectedArea && this.selectedArea.code ? this.selectedArea.code : ''
const previousRoomCode = this.selectedRoomType && this.selectedRoomType.code ? this.selectedRoomType.code : ''
// Try to find by room type code
if (this.value) {
for (const area of this.areaList) {
for (const area of this.displayAreaList) {
const room = area.roomTypes.find(r => r.code === this.value)
if (room) {
foundArea = area
@@ -150,7 +176,7 @@ export default {
// If not found by room, try to find by initial area code
if (!foundRoom && this.initialAreaCode) {
foundArea = this.areaList.find(a => a.code === this.initialAreaCode)
foundArea = this.displayAreaList.find(a => a.code === this.initialAreaCode)
}
if (foundArea) {
@@ -159,11 +185,17 @@ export default {
this.selectedRoomType = foundRoom
}
// If we found the room, or we just want to initialize the area but no room yet
if (foundRoom && this.value !== foundRoom.code) {
this.$emit('input', foundRoom.code)
this.$emit('change', { area: foundArea, roomType: foundRoom })
if (foundRoom) {
if (this.value !== foundRoom.code) {
this.$emit('input', foundRoom.code)
}
if (previousAreaCode !== foundArea.code || previousRoomCode !== foundRoom.code) {
this.$emit('change', { area: foundArea, roomType: foundRoom })
}
}
} else {
this.selectedArea = null
this.selectedRoomType = null
}
},
handleSelect(area, roomType) {

View File

@@ -12,7 +12,7 @@
</tn-nav-bar>
<scroll-view class="page-scroll" scroll-y @scroll="handleScroll" :show-scrollbar="false">
<view class="page-content tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '60rpx' }">
<view class="page-content tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: pageBottomPadding }">
<view class="hero-header tn-margin-bottom-xl">
<view class="hero-badge">
<text class="badge-dot"></text>
@@ -20,13 +20,6 @@
</view>
<view class="hero-title">{{ pageTitle }}</view>
<view class="hero-desc">{{ pageDesc }}</view>
<view v-if="sourceSummaryList.length" class="tn-margin-top-lg">
<view v-for="item in sourceSummaryList" :key="item.label" class="source-row tn-flex tn-flex-row-between">
<text class="source-row__label">{{ item.label }}</text>
<text class="source-row__value">{{ item.value }}</text>
</view>
</view>
</view>
<view class="form-card tn-margin-top-xl">
@@ -41,7 +34,15 @@
@change="handleRoomTypeChange"
/>
</view>
<view v-if="showFloorSelector" class="form-item tn-margin-top">
<view class="form-label">{{ floorLabel }}</view>
<floor-selector
v-model="form.intendedFloorLevel"
@change="handleFloorChange"
/>
</view>
<view v-if="showPreferenceSelectors" class="form-item tn-margin-top">
<view class="form-label">{{ t('hobbyLabel') }}</view>
<tag-selector
@@ -79,13 +80,22 @@
<textarea class="form-textarea" :placeholder="remarkPlaceholder" v-model="form.remark"></textarea>
</view>
<view class="submit-btn btn-primary tn-margin-top-xl" :class="{ 'submit-btn--disabled': isSubmitting }" @click="submitRequest">
<view v-if="!showFloatingSubmitBtn" class="submit-btn btn-primary tn-margin-top-xl" :class="{ 'submit-btn--disabled': isSubmitting }" @click="submitRequest">
<text class="tn-text-bold">{{ isSubmitting ? t('submitting') : primaryActionText }}</text>
</view>
</view>
</view>
</scroll-view>
<view v-if="showFloatingSubmitBtn" class="floating-submit-bar">
<view class="floating-submit-bar__inner">
<view class="submit-btn btn-primary" :class="{ 'submit-btn--disabled': isSubmitting }" @click="submitRequest">
<text class="tn-text-bold">{{ isSubmitting ? t('submitting') : primaryActionText }}</text>
</view>
</view>
</view>
</view>
</template>
@@ -98,6 +108,7 @@ import { getStoredUser } from '../../utils/flow'
import TagSelector from './components/TagSelector.vue'
import DateRangeSelector from './components/DateRangeSelector.vue'
import RoomTypeSelector from './components/RoomTypeSelector.vue'
import FloorSelector from './components/FloorSelector.vue'
function createDefaultForm() {
const now = new Date()
@@ -107,6 +118,7 @@ function createDefaultForm() {
return {
intendedAreaCode: '',
intendedRoomTypeCode: '',
intendedFloorLevel: '',
roomSlotHeight: 1,
hobbiesAndInterestsTagKeys: [],
lifestyleHabitsTagKeys: [],
@@ -564,7 +576,8 @@ export default {
components: {
TagSelector,
DateRangeSelector,
RoomTypeSelector
RoomTypeSelector,
FloorSelector
},
data() {
return {
@@ -743,12 +756,27 @@ export default {
roomTypeLabel() {
return this.getBusinessText('roomTypeLabel') || this.t('applyRoomType')
},
pageBottomPadding() {
return this.showFloatingSubmitBtn ? '220rpx' : '60rpx'
},
showFloatingSubmitBtn() {
return this.isRoomTypeApply
},
floorLabel() {
return this.getBusinessText('floorLabel') || this.t('floorLabel')
},
showRoomTypeSelector() {
if (this.isRoomTypeApply) {
return true
}
return this.businessConfig ? this.businessConfig.showRoomTypeSelector !== false : true
},
showFloorSelector() {
if (this.isRoomTypeApply) {
return true
}
return this.businessConfig ? this.businessConfig.showFloorSelector !== false : true
},
showPreferenceSelectors() {
if (this.isRoomTypeApply) {
return true
@@ -775,41 +803,6 @@ export default {
},
dateRangeEndPlaceholder() {
return this.getBusinessText('dateRangeEndPlaceholder') || this.t('selectCheckoutDate')
},
sourceSummaryList() {
const payload = this.sourcePayload || {}
if (!Object.keys(payload).length) {
return []
}
const roomTypeName = this.language === 'en'
? (payload.roomTypeNameEn || payload.roomTypeName || '')
: (payload.roomTypeName || '')
const roomLabel = this.language === 'en'
? (payload.roomLabelEn || payload.currentRoomLabelEn || payload.roomLabel || payload.currentRoomLabel || '')
: (payload.roomLabel || payload.currentRoomLabel || '')
const areaName = this.language === 'en'
? (payload.areaLabelEn || payload.areaNameEn || payload.areaLabel || payload.areaName || '')
: (payload.areaLabel || payload.areaName || '')
const list = []
if (roomTypeName) {
list.push({ label: this.t('applyRoomType'), value: roomTypeName })
}
if (roomLabel) {
list.push({ label: this.t('currentRoom'), value: roomLabel })
}
if (areaName) {
list.push({ label: this.t('areaLabel'), value: areaName })
}
if (payload.price) {
list.push({ label: this.t('referencePrice'), value: payload.price })
}
if (payload.payMethod) {
list.push({ label: this.t('paymentMethod'), value: localizePayMethodText(payload.payMethod, this.language) })
}
if (this.pageTitle) {
list.push({ label: this.t('applicationType'), value: this.pageTitle })
}
return list
}
},
watch: {
@@ -943,6 +936,7 @@ export default {
...defaultForm,
intendedAreaCode: this.sourcePayload.areaCode || '',
intendedRoomTypeCode: this.sourcePayload.roomTypeCode || '',
intendedFloorLevel: this.sourcePayload.intendedFloorLevel || this.sourcePayload.IntendedFloorLevel || '',
remark: '',
start: this.sourcePayload.start || defaultForm.start,
end: this.sourcePayload.end || ''
@@ -967,18 +961,31 @@ export default {
this.sourcePayload.roomTypeCode = roomType.code
this.sourcePayload.roomTypeName = roomType.name
this.sourcePayload.roomTypeNameEn = roomType.nameEn || ''
this.sourcePayload.selectedRoomTypeBuildingCode = roomType.buildingCode || ''
this.sourcePayload.selectedRoomTypeBuildingSectionCode = roomType.buildingSectionCode || ''
this.sourcePayload.selectedRoomTypeBuildingName = roomType.buildingName || ''
this.sourcePayload.selectedRoomTypeBuildingSectionName = roomType.buildingSectionName || ''
if (area) {
this.sourcePayload.areaCode = area.code
this.sourcePayload.areaName = area.name
this.sourcePayload.areaNameEn = area.nameEn || ''
}
if (roomType.price) {
this.sourcePayload.price = roomType.price
}
}
},
handleFloorChange({ floor }) {
if (!this.sourcePayload) {
this.sourcePayload = {}
}
this.sourcePayload.intendedFloorLevel = floor && floor.value ? floor.value : ''
this.sourcePayload.intendedFloorName = floor
? (this.language === 'en' ? (floor.nameEn || floor.name || '') : (floor.name || floor.nameEn || ''))
: ''
},
loadDictOptions() {
this.dictLoading = true
return Promise.all([
@@ -1052,6 +1059,7 @@ export default {
return {
intendedAreaCode: this.form.intendedAreaCode || null,
intendedRoomTypeCode: this.form.intendedRoomTypeCode || null,
IntendedFloorLevel: this.form.intendedFloorLevel || null,
roomSlotHeight: Number(this.form.roomSlotHeight),
hobbiesAndInterestsTagKeys: this.showPreferenceSelectors && this.form.hobbiesAndInterestsTagKeys.length ? this.form.hobbiesAndInterestsTagKeys : null,
lifestyleHabitsTagKeys: this.showPreferenceSelectors && this.form.lifestyleHabitsTagKeys.length ? this.form.lifestyleHabitsTagKeys : null,
@@ -1276,6 +1284,22 @@ export default {
pointer-events: none;
}
.floating-submit-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(248, 250, 255, 0) 0%, rgba(248, 250, 255, 0.92) 24%, #F8FAFF 100%);
backdrop-filter: blur(16rpx);
-webkit-backdrop-filter: blur(16rpx);
}
.floating-submit-bar__inner {
max-width: 100%;
}
.source-row {
padding: 24rpx 0;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
@@ -1286,6 +1310,10 @@ export default {
padding-bottom: 8rpx;
}
.source-row--interactive:active {
opacity: 0.8;
}
.source-row__label {
color: #6A7A99;
font-size: 26rpx;
@@ -1301,4 +1329,5 @@ export default {
margin-left: 24rpx;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,211 @@
<template>
<view class="ticket-center-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-content tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px' }">
<view class="hero-card tn-margin-bottom-lg">
<view class="hero-badge">
<text class="badge-dot"></text>
<text>{{ t('heroBadge') }}</text>
</view>
<view class="hero-title tn-margin-top">{{ t('heroTitle') }}</view>
<view class="hero-desc tn-margin-top-sm">{{ t('heroDesc') }}</view>
</view>
<view class="service-grid">
<view
v-for="item in ticketServices"
:key="item.serviceType"
class="service-card"
@click="openTicketWorkbench(item)"
>
<view class="service-card__icon"><text :class="item.icon"></text></view>
<view class="service-card__title tn-margin-top">{{ getItemText(item, 'title', 'titleEn') }}</view>
<view class="service-card__desc tn-margin-top-xs">{{ getItemText(item, 'desc', 'descEn') }}</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'TicketCenterPage',
i18n: {
zh: {
navTitle: '工单中心',
heroBadge: 'SERVICE',
heroTitle: '住中工单服务',
heroDesc: '报修、续住、退宿、调宿、送水、清洁和意见建议都统一从这里进入,提交后可继续查看流转记录。'
},
en: {
navTitle: 'Work Orders',
heroBadge: 'SERVICE',
heroTitle: 'In-stay Ticket Services',
heroDesc: 'Repair, renewal, checkout, room change, water delivery, cleaning, and suggestions are all handled here.'
}
},
data() {
return {
ticketServices: [
{
serviceType: 'TicketRepairApply',
icon: 'tn-icon-set-fill',
title: '报修申请',
titleEn: 'Repair Request',
desc: '设备故障、设施损坏等问题报修',
descEn: 'Report device or facility issues'
},
{
serviceType: 'TicketRenewalApply',
icon: 'tn-icon-calendar-fill',
title: '续住申请',
titleEn: 'Renewal',
desc: '提交续住时间与诉求',
descEn: 'Submit renewal details'
},
{
serviceType: 'TicketCheckOutApply',
icon: 'tn-icon-home-in-fill',
title: '退宿申请',
titleEn: 'Checkout',
desc: '提交退宿时间与原因',
descEn: 'Submit checkout request'
},
{
serviceType: 'TicketChangeApply',
icon: 'tn-icon-home-smile-fill',
title: '调宿申请',
titleEn: 'Room Change',
desc: '选择意向房型并提交调宿需求',
descEn: 'Submit room change request'
},
{
serviceType: 'TicketWaterDeliveryApply',
icon: 'tn-icon-shop-fill',
title: '送水申请',
titleEn: 'Water Delivery',
desc: '填写送水桶数和送达说明',
descEn: 'Request water delivery'
},
{
serviceType: 'TicketCleaningApply',
icon: 'tn-icon-clean',
title: '清洁服务',
titleEn: 'Cleaning Service',
desc: '提交清洁需求或预约时间',
descEn: 'Submit cleaning request'
},
{
serviceType: 'TicketSuggestionApply',
icon: 'tn-icon-message-fill',
title: '意见建议',
titleEn: 'Suggestion',
desc: '反馈建议、投诉或其他诉求',
descEn: 'Feedback, suggestions, or complaints'
}
]
}
},
methods: {
t(key, params) {
return this.$translate(this.i18n, key, params)
},
getItemText(item, fieldZh, fieldEn) {
return this.$getLanguage && this.$getLanguage() === 'en'
? (item[fieldEn] || item[fieldZh] || '')
: (item[fieldZh] || item[fieldEn] || '')
},
openTicketWorkbench(item) {
uni.navigateTo({
url: `/pkgService/ticket_workbench/ticket_workbench?serviceType=${encodeURIComponent(item.serviceType)}`
})
}
}
}
</script>
<style scoped lang="scss">
.ticket-center-page {
min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
}
.hero-card {
padding: 32rpx;
background: #FFFFFF;
border-radius: 30rpx;
box-shadow: 0 16rpx 40rpx rgba(43, 142, 255, 0.08);
}
.hero-badge {
display: inline-flex;
align-items: center;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba($brand-primary, 0.08);
color: $brand-primary;
font-size: 22rpx;
font-weight: 700;
}
.badge-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: $brand-primary;
margin-right: 10rpx;
}
.hero-title {
font-size: 44rpx;
color: #1A2233;
font-weight: 800;
}
.hero-desc {
font-size: 26rpx;
color: #6A7A99;
line-height: 1.7;
}
.service-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
padding-bottom: 40rpx;
}
.service-card {
padding: 28rpx;
background: #FFFFFF;
border-radius: 28rpx;
box-shadow: 0 12rpx 32rpx rgba(43, 142, 255, 0.06);
}
.service-card__icon {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, rgba($brand-primary, 0.14) 0%, rgba($brand-primary, 0.05) 100%);
color: $brand-primary;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
}
.service-card__title {
font-size: 30rpx;
color: #1A2233;
font-weight: 700;
}
.service-card__desc {
font-size: 22rpx;
color: #7C8AA5;
line-height: 1.6;
}
</style>

File diff suppressed because it is too large Load Diff