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

@@ -0,0 +1,363 @@
<template>
<view class="room-type-selector">
<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') }}
</view>
<view class="selected-area tn-text-ellipsis" v-if="selectedArea">
{{ getAreaDisplayName(selectedArea) }}
</view>
</view>
<text class="tn-icon-right tn-color-gray"></text>
</view>
<tn-popup v-model="showPopup" mode="bottom" length="80%" :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="area-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('loadingRoomTypes') }}</view>
</view>
<view v-else-if="!areaList.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 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>
<text>{{ getAreaDisplayName(area) }}</text>
</view>
<text :class="collapsedAreas[index] ? 'tn-icon-down' : 'tn-icon-up'" class="tn-color-gray"></text>
</view>
<view class="room-type-grid" v-show="!collapsedAreas[index]">
<view
class="room-type-item"
v-for="room in area.roomTypes"
:key="room.code"
:class="{ 'is-active': selectedRoomType && selectedRoomType.code === room.code }"
@click="handleSelect(area, room)"
>
<view class="room-name tn-text-ellipsis">{{ getRoomTypeDisplayName(room) }}</view>
<view class="room-price" v-if="room.price">¥{{ getPriceValue(room.price) }}{{ t('priceUnit') }}</view>
</view>
</view>
</view>
</scroll-view>
</view>
</tn-popup>
</view>
</template>
<script>
import { areaPagination } from '@/api/index.js'
import { normalizeAreaItem } from '../../../utils/select-room-normalizers'
const I18N = {
zh: {
emptyRoomTypes: '暂无可选房型',
loadFailed: '房型加载失败',
loadingRoomTypes: '房型加载中...',
popupTitle: '选择意向房型',
priceUnit: '/月',
selectRoomType: '请选择房型'
},
en: {
emptyRoomTypes: 'No room types available',
loadFailed: 'Failed to load room types',
loadingRoomTypes: 'Loading room types...',
popupTitle: 'Select Room Type',
priceUnit: '/month',
selectRoomType: 'Select room type'
}
}
export default {
name: 'BookViewingRoomTypeSelector',
props: {
value: {
type: String,
default: ''
},
initialAreaCode: {
type: String,
default: ''
}
},
data() {
return {
showPopup: false,
isLoading: false,
areaList: [],
selectedArea: null,
selectedRoomType: null,
collapsedAreas: {}
}
},
computed: {
language() {
return this.$getLanguage ? this.$getLanguage() : 'zh'
}
},
watch: {
value() {
this.syncSelection()
},
language() {
this.loadData(true)
}
},
mounted() {
this.loadData()
},
methods: {
t(key) {
const lang = I18N[this.language] ? this.language : 'zh'
return I18N[lang][key] || I18N.zh[key] || key
},
openSelector() {
this.showPopup = true
this.loadData()
},
loadData(force = false) {
if (!force && this.areaList.length > 0) {
this.syncSelection()
return Promise.resolve()
}
this.isLoading = true
return areaPagination({ 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.areaList = rawList
.map(item => normalizeAreaItem(item))
.filter(area => Array.isArray(area.roomTypes) && area.roomTypes.length > 0)
const collapsed = {}
this.areaList.forEach((_, index) => {
collapsed[index] = false
})
this.collapsedAreas = collapsed
this.syncSelection()
})
.catch((error) => {
console.error('load room types error', error)
uni.showToast({ title: this.t('loadFailed'), icon: 'none' })
})
.finally(() => {
this.isLoading = false
})
},
syncSelection() {
if (!this.areaList.length) {
this.selectedArea = null
this.selectedRoomType = null
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 : ''
if (this.value) {
for (const area of this.areaList) {
const room = Array.isArray(area.roomTypes) ? area.roomTypes.find(item => item.code === this.value) : null
if (room) {
foundArea = area
foundRoom = room
break
}
}
}
if (!foundRoom && this.initialAreaCode) {
foundArea = this.areaList.find(area => area.code === this.initialAreaCode) || null
}
if (!foundArea) {
this.selectedArea = null
this.selectedRoomType = null
return
}
this.selectedArea = foundArea
this.selectedRoomType = foundRoom || null
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 })
}
}
},
handleSelect(area, roomType) {
this.selectedArea = area
this.selectedRoomType = roomType
this.$emit('input', roomType.code)
this.$emit('change', { area, roomType })
this.showPopup = false
},
getAreaDisplayName(area = {}) {
if (this.language === 'en') {
return area.nameEnDisplay || area.nameEn || area.name || ''
}
return area.name || area.nameEn || ''
},
getRoomTypeDisplayName(roomType = {}) {
if (this.language === 'en') {
return roomType.nameEnDisplay || roomType.nameEn || roomType.name || ''
}
return roomType.name || roomType.nameEn || ''
},
toggleArea(index) {
this.$set(this.collapsedAreas, index, !this.collapsedAreas[index])
},
getPriceValue(priceText) {
if (!priceText) {
return '--'
}
const matched = String(priceText).match(/(\d+(\.\d+)?)/)
return matched ? matched[1] : '--'
}
}
}
</script>
<style scoped lang="scss">
.room-type-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;
}
}
.selected-area {
font-size: 22rpx;
color: #6A7A99;
margin-top: 6rpx;
}
.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;
}
.area-list-scroll {
flex: 1;
height: 0;
padding: 20rpx 30rpx 60rpx;
}
.area-group {
margin-bottom: 30rpx;
background: #FFFFFF;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.04);
border: 1rpx solid rgba($brand-primary, 0.05);
}
.area-title {
font-size: 28rpx;
font-weight: 600;
color: #1A2233;
margin-bottom: 20rpx;
}
.area-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: $brand-primary;
margin-right: 12rpx;
box-shadow: 0 0 8rpx rgba($brand-primary, 0.4);
}
.room-type-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.room-type-item {
padding: 20rpx;
border-radius: 16rpx;
background: #FFFFFF;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
transition: all 0.3s ease;
&.is-active {
background: #FFFFFF;
border-color: $brand-primary;
box-shadow: 0 4rpx 16rpx rgba($brand-primary, 0.1);
.room-name {
color: $brand-primary;
}
.room-price {
color: $brand-primary;
}
}
}
.room-name {
font-size: 26rpx;
font-weight: 600;
color: #1A2233;
margin-bottom: 8rpx;
}
.room-price {
font-size: 22rpx;
color: #6A7A99;
}
</style>