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

@@ -41,17 +41,19 @@
</view>
</scroll-view>
<view class="time-grid">
<view
v-for="(item, index) in timeSlots"
:key="index"
class="time-slot"
:class="{ 'time-slot--active': currentTimeIndex === index }"
@click="selectTimeSlot(index)"
>
{{ item }}
<scroll-view class="time-scroll" scroll-x :show-scrollbar="false">
<view class="time-flex">
<view
v-for="(item, index) in timeSlots"
:key="index"
class="time-slot"
:class="{ 'time-slot--active': currentTimeIndex === index }"
@click="selectTimeSlot(index)"
>
{{ item }}
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 租住需求卡片 -->
@@ -63,6 +65,15 @@
</view>
</view>
<view class="field-item">
<view class="field-label">{{ t('roomTypeLabel') }}</view>
<room-type-selector
v-model="formData.intendedRoomTypeCode"
:initial-area-code="formData.intendedAreaCode"
@change="handleRoomTypeChange"
/>
</view>
<view class="field-item">
<view class="field-label">{{ t('durationLabel') }}</view>
<view class="tag-grid">
@@ -119,8 +130,12 @@
</template>
<script>
import { submitApplicationForm } from '@/api/index.js'
import { submitViewingApplicationForm } from '@/api/index.js'
import { resolveExistingBookingNavigationTarget } from '../../utils/booking-entry'
import { getSessionSnapshot } from '../../utils/app-state'
import { syncLoginStayState } from '../../utils/session-sync'
import { compactEnglishText, localizePayMethodText } from '../../utils/select-room-normalizers'
import RoomTypeSelector from './components/RoomTypeSelector.vue'
function buildViewingDateTime(date = '', time = '', isEnd = false) {
if (!date || !time) {
@@ -134,7 +149,44 @@ function buildViewingDateTime(date = '', time = '', isEnd = false) {
return `${date}T${selected}:00`
}
function formatDatePart(value) {
return String(value).padStart(2, '0')
}
function shiftDate(date = '', days = 0) {
if (!date) {
return ''
}
const next = new Date(`${date}T00:00:00`)
if (!Number.isFinite(next.getTime())) {
return ''
}
next.setDate(next.getDate() + days)
return `${next.getFullYear()}-${formatDatePart(next.getMonth() + 1)}-${formatDatePart(next.getDate())}`
}
function buildExpectedStayRange(date = '', durationIndex = 0) {
if (!date) {
return {
start: null,
end: null
}
}
const durationDays = [90, 180, 270, 365, 365]
const normalizedIndex = durationIndex >= 0 ? durationIndex : 0
const endDate = shiftDate(date, durationDays[normalizedIndex] || durationDays[0])
return {
start: `${date}T00:00:00`,
end: endDate ? `${endDate}T23:59:59` : null
}
}
export default {
components: {
RoomTypeSelector
},
computed: {
language() {
return this.$getLanguage()
@@ -177,6 +229,8 @@ export default {
currentDateIndex: 0,
currentTimeIndex: -1,
formData: {
intendedAreaCode: '',
intendedRoomTypeCode: '',
budget: '',
duration: '',
workplace: ''
@@ -191,6 +245,7 @@ export default {
selectViewingTimeDesc: '请选择您方便的时间段',
rentalNeeds: '您的租住需求',
rentalNeedsDesc: '帮助管家更好地为您推荐房源',
roomTypeLabel: '意向房型',
durationLabel: '预计租住时长',
budgetLabel: '租金预算 (元/月)',
budgetPlaceholder: '请输入您的心理价位',
@@ -226,7 +281,7 @@ export default {
selectDurationRequired: '请选择预计租住时长',
submitting: '提交中...',
submitSuccessTitle: '预约成功',
submitSuccessContent: '您的预约看房申请已提交。\n系统会自动转发给对应区域有房间权限的宿管,宿管确认带看后可直接完结订单,或选定房型房号后转为预定单发送给租客支付。',
submitSuccessContent: '您的预约看房申请已提交,请等待管家联系',
submitFailed: '预约提交失败'
},
en: {
@@ -236,6 +291,7 @@ export default {
selectViewingTimeDesc: 'Choose a time slot that works for you',
rentalNeeds: 'Rental Needs',
rentalNeedsDesc: 'Help the manager recommend a suitable room',
roomTypeLabel: 'Preferred Room Type',
durationLabel: 'Expected Stay Duration',
budgetLabel: 'Budget (CNY/month)',
budgetPlaceholder: 'Enter your expected budget',
@@ -283,15 +339,47 @@ export default {
if (options.payload) {
try {
this.sourceData = JSON.parse(decodeURIComponent(options.payload))
this.formData.intendedAreaCode = this.sourceData && this.sourceData.areaCode ? this.sourceData.areaCode : ''
this.formData.intendedRoomTypeCode = this.sourceData && this.sourceData.roomTypeCode ? this.sourceData.roomTypeCode : ''
} catch (e) {
console.error('Failed to parse payload', e)
}
}
this.redirectIfBookingUnavailable()
},
methods: {
t(key, params) {
return this.$tm('pkgBooking.book_viewing.book_viewing', key, params)
},
redirectIfBookingUnavailable() {
const session = getSessionSnapshot()
if (!session.isLogin) {
return Promise.resolve(false)
}
return syncLoginStayState({
language: this.$getLanguage(),
force: true
})
.then((stayState) => {
const targetUrl = resolveExistingBookingNavigationTarget(stayState)
if (!targetUrl) {
return false
}
if (/^\/pages\/index\/index(?:\?|$)/.test(targetUrl)) {
uni.reLaunch({ url: targetUrl })
return true
}
uni.redirectTo({ url: targetUrl })
return true
})
.catch((error) => {
console.error('check viewing booking entry failed', error)
return false
})
},
syncDurationOptions() {
this.durationOptions = [
this.t('duration1'),
@@ -339,50 +427,26 @@ export default {
selectTimeSlot(index) {
this.currentTimeIndex = index
},
handleRoomTypeChange({ area, roomType }) {
this.formData.intendedAreaCode = area && area.code ? area.code : ''
this.formData.intendedRoomTypeCode = roomType && roomType.code ? roomType.code : ''
if (!this.sourceData) {
this.sourceData = {}
}
if (area) {
this.sourceData.areaCode = area.code || ''
this.sourceData.areaName = area.name || ''
this.sourceData.areaNameEn = area.nameEn || ''
}
if (roomType) {
this.sourceData.roomTypeCode = roomType.code || ''
this.sourceData.roomTypeName = roomType.name || ''
this.sourceData.roomTypeNameEn = roomType.nameEn || ''
}
},
selectDuration(item) {
this.formData.duration = item
},
buildRemark(selectedDate, selectedTime) {
const lines = [
this.t('remarkTitle'),
this.t('remarkTime', { date: selectedDate, time: selectedTime }),
this.t('remarkDuration', { duration: this.formData.duration })
]
if (this.formData.budget) {
lines.push(this.t('remarkBudget', { budget: this.formData.budget }))
}
if (this.formData.workplace) {
lines.push(this.t('remarkWorkplace', { workplace: this.formData.workplace }))
}
if (this.sourceData && (this.sourceData.areaName || this.sourceData.areaNameEn)) {
lines.push(this.t('remarkArea', {
area: this.language === 'en'
? (this.sourceData.areaNameEnDisplay || this.sourceData.areaLabelEnDisplay || this.sourceData.areaNameEn || this.sourceData.areaLabelEn || this.sourceData.areaName || this.sourceData.areaLabel || '')
: (this.sourceData.areaName || this.sourceData.areaLabel || this.sourceData.areaNameEn || this.sourceData.areaLabelEn || '')
}))
}
if (this.sourceData && (this.sourceData.roomTypeName || this.sourceData.roomTypeNameEn)) {
lines.push(this.t('remarkRoomType', {
roomType: this.language === 'en'
? (this.sourceData.roomTypeNameEnDisplay || this.sourceData.roomTypeNameEn || this.sourceData.roomTypeName || '')
: (this.sourceData.roomTypeName || this.sourceData.roomTypeNameEn || '')
}))
}
if (this.sourceData && (this.sourceData.roomName || this.sourceData.roomNameEn)) {
lines.push(this.t('remarkSource', {
room: this.language === 'en'
? (this.sourceData.roomNameEnDisplay || this.sourceData.roomNameEn || this.sourceData.roomName || '')
: (this.sourceData.roomName || this.sourceData.roomNameEn || '')
}))
}
if (this.sourceData && this.sourceData.payMethod) {
lines.push(this.t('remarkPayMethod', { payMethod: localizePayMethodText(this.sourceData.payMethod, this.language) }))
}
lines.push(this.t('remarkTail'))
return lines.join('\n')
},
handleSubmit() {
if (this.isSubmitting) {
return
@@ -398,20 +462,24 @@ export default {
const selectedDate = this.dateList[this.currentDateIndex].date
const selectedTime = this.timeSlots[this.currentTimeIndex]
const appointmentViewingTime = buildViewingDateTime(selectedDate, selectedTime, false)
const durationIndex = this.durationOptions.findIndex(item => item === this.formData.duration)
const expectedStayRange = buildExpectedStayRange(selectedDate, durationIndex)
const payload = {
intendedAreaCode: this.sourceData && this.sourceData.areaCode ? this.sourceData.areaCode : null,
intendedRoomTypeCode: this.sourceData && this.sourceData.roomTypeCode ? this.sourceData.roomTypeCode : null,
intendedAreaCode: this.formData.intendedAreaCode || (this.sourceData && this.sourceData.areaCode ? this.sourceData.areaCode : null),
intendedRoomTypeCode: this.formData.intendedRoomTypeCode || (this.sourceData && this.sourceData.roomTypeCode ? this.sourceData.roomTypeCode : null),
roomSlotHeight: 1,
hobbiesAndInterestsTagKeys: null,
lifestyleHabitsTagKeys: null,
remark: this.buildRemark(selectedDate, selectedTime),
start: buildViewingDateTime(selectedDate, selectedTime, false),
end: buildViewingDateTime(selectedDate, selectedTime, true)
};
remark: null,
start: expectedStayRange.start,
end: expectedStayRange.end,
appointmentViewingTime
}
this.isSubmitting = true
uni.showLoading({ title: this.t('submitting'), mask: true })
return submitApplicationForm(payload)
return submitViewingApplicationForm(payload)
.then(() => {
uni.hideLoading()
uni.showModal({
@@ -582,14 +650,18 @@ export default {
}
/* 时间段选择 */
.time-grid {
display: flex;
flex-wrap: wrap;
.time-scroll {
width: 100%;
}
.time-flex {
display: inline-flex;
gap: 20rpx;
padding-bottom: 8rpx;
}
.time-slot {
width: calc(33.33% - 14rpx);
width: 220rpx;
height: 72rpx;
line-height: 72rpx;
text-align: center;
@@ -599,6 +671,7 @@ export default {
color: #4E5969;
border: 2rpx solid transparent;
transition: all 0.3s ease;
flex-shrink: 0;
}
.time-slot--active {