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:
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
554
pkgService/lost_found_publish/lost_found_publish.vue
Normal file
554
pkgService/lost_found_publish/lost_found_publish.vue
Normal 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>
|
||||
@@ -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({
|
||||
|
||||
1130
pkgService/room_inspection/room_inspection.vue
Normal file
1130
pkgService/room_inspection/room_inspection.vue
Normal file
File diff suppressed because it is too large
Load Diff
314
pkgService/submit_request/components/BuildingSelector.vue
Normal file
314
pkgService/submit_request/components/BuildingSelector.vue
Normal 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>
|
||||
308
pkgService/submit_request/components/FloorSelector.vue
Normal file
308
pkgService/submit_request/components/FloorSelector.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
211
pkgService/ticket_center/ticket_center.vue
Normal file
211
pkgService/ticket_center/ticket_center.vue
Normal 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>
|
||||
2252
pkgService/ticket_workbench/ticket_workbench.vue
Normal file
2252
pkgService/ticket_workbench/ticket_workbench.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user