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

@@ -1,10 +1,10 @@
<template>
<view class="service-page tn-safe-area-inset-bottom">
<tn-nav-bar :isBack="false" :bottomShadow="false" backgroundColor="transparent" :customBackStyle="{ transition: 'all 0.3s ease' }">
<tn-nav-bar :isBack="false" :bottomShadow="false" :backgroundColor="navBarBackgroundColor" :customBackStyle="{ transition: 'all 0.3s ease' }">
<view class="tn-text-bold tn-text-xl tn-color-black">{{ t('title') }}</view>
</tn-nav-bar>
<scroll-view class="service-scroll" scroll-y :style="{paddingTop: vuex_custom_bar_height + 'px'}">
<scroll-view class="service-scroll" scroll-y :style="{paddingTop: vuex_custom_bar_height + 'px'}" @scroll="handleScroll">
<view v-if="!hasCheckedIn" class="empty-state tn-padding animation-fade-in">
<!-- 无预订:去选房 -->
<view v-if="!currentBooking" class="no-booking-card">
@@ -112,21 +112,6 @@
<!-- 已入住状态下的服务大厅内容 -->
<view v-else class="animation-fade-in tn-padding-bottom">
<view class="tn-margin-top tn-margin-left tn-margin-right">
<view class="tn-text-bold tn-text tn-color-black tn-margin-bottom">{{ t('stayService') }}</view>
<view class="tn-flex tn-flex-row-between">
<view
v-for="item in stayActions"
:key="item.key"
class="stay-action-card"
@click="openServicePage(item)"
>
<view class="stay-action-icon"><text :class="item.icon" class="stay-action-icon-symbol"></text></view>
<view class="stay-action-title tn-margin-top-sm">{{ pickItemText(item, 'label', 'labelEn') }}</view>
</view>
</view>
</view>
<!-- 融合房卡与智能控制的新卡片 -->
<view class="integrated-room-card tn-margin-left tn-margin-right tn-margin-top">
<!-- 房卡背景 -->
@@ -181,6 +166,32 @@
</view>
<view class="tn-margin-top tn-margin-left tn-margin-right">
<view class="tn-text-bold tn-text tn-color-black tn-margin-bottom">{{ t('stayService') }}</view>
<view class="tn-flex tn-flex-wrap tn-flex-row-between">
<view class="service-item tn-margin-bottom-sm" v-for="item in stayActions" :key="item.key" @click="openServicePage(item)">
<view class="service-card-visual">
<image :src="item.image" mode="aspectFill" class="service-card-bg"></image>
<view class="service-card-mask"></view>
<view class="service-card-content tn-padding-sm tn-flex tn-flex-direction-column tn-flex-row-between" style="height: 100%;">
<view class="tn-flex tn-flex-row-between tn-flex-col-center">
<view class="service-icon-box tn-flex tn-flex-col-center tn-flex-row-center">
<text :class="item.icon" class="service-icon-symbol"></text>
</view>
<view class="status-tag tn-text-xs" :class="item.isBusy ? 'tag-busy' : 'tag-free'">
{{ pickItemText(item, 'statusZh', 'statusEn') }}
</view>
</view>
<view>
<view class="tn-text-bold tn-text tn-color-white">{{ pickItemText(item, 'label', 'labelEn') }}</view>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="tn-margin-top-sm tn-margin-left tn-margin-right">
<view class="tn-flex tn-flex-row-between tn-flex-col-bottom tn-margin-bottom">
<view class="tn-text-bold tn-text tn-color-black">{{ t('publicService') }}</view>
<view class="tn-text-sm tn-color-gray">{{ t('viewAll') }} <text class="tn-icon-right section-arrow-icon"></text></view>
@@ -238,36 +249,38 @@
</view>
</view>
<!-- 专属管家 (原活动位置) -->
<!-- 宿管与管理员列表 -->
<view class="butler-card-new tn-margin-top tn-margin-left tn-margin-right">
<view class="tn-text-bold tn-text tn-color-black tn-margin-bottom">{{ t('butler') }}</view>
<view class="tn-text-bold tn-text tn-color-black tn-margin-bottom">{{ t('managementContacts') }}</view>
<view class="butler-card-new__inner">
<view v-if="areaManagers.length" class="butler-card-new__list">
<view v-if="managerContacts.length" class="butler-card-new__list">
<view
v-for="(manager, index) in areaManagers"
:key="manager.userCode || manager.userName || manager.name || index"
v-for="(contact, index) in managerContacts"
:key="contact.contactKey || index"
class="butler-member-new"
>
<view class="butler-member-new__identity">
<view class="butler-avatar-new">
<image v-if="getManagerAvatar(manager)" :src="getManagerAvatar(manager)" mode="aspectFill"></image>
<image v-if="getManagerAvatar(contact)" :src="getManagerAvatar(contact)" mode="aspectFill"></image>
<view v-else class="butler-avatar-new-fallback tn-flex tn-flex-col-center tn-flex-row-center">
<text class="butler-avatar-new-fallback-text">{{ getManagerInitial(manager) }}</text>
<text class="butler-avatar-new-fallback-text">{{ getManagerInitial(contact) }}</text>
</view>
<view class="online-dot"></view>
</view>
<view class="butler-member-new__info">
<view class="butler-member-new__name">
{{ getManagerName(manager) }}
<text v-if="index === 0" class="butler-member-new__tag">{{ t('primaryButlerTag') }}</text>
{{ getManagerName(contact) }}
<text class="butler-member-new__tag butler-member-new__tag--type" :class="contact.contactType === 'counselor' ? 'tag-counselor' : 'tag-dormitory'">
{{ contact.contactType === 'counselor' ? t('counselorTag') : t('dormitoryManagerTag') }}
</text>
</view>
<view class="butler-member-new__phone">{{ getManagerPhone(manager) || t('noContactPhone') }}</view>
<view class="butler-member-new__phone">{{ getManagerPhone(contact) || t('noContactPhone') }}</view>
</view>
</view>
<view
class="butler-member-new__action"
:class="{ 'disabled': !getManagerPhone(manager) }"
@click.stop="handleAreaManagerCall(manager)"
:class="{ 'disabled': !getManagerPhone(contact) }"
@click.stop="handleAreaManagerCall(contact)"
>
<text class="tn-icon-phone-fill"></text>
</view>
@@ -278,12 +291,12 @@
<view class="butler-member-new__identity">
<view class="butler-avatar-new">
<view class="butler-avatar-new-fallback tn-flex tn-flex-col-center tn-flex-row-center">
<text class="tn-icon-service-fill" style="font-size: 32rpx; color: #fff;"></text>
<text class="tn-icon-my-fill" style="font-size: 32rpx; color: #fff;"></text>
</view>
</view>
<view class="butler-member-new__info">
<view class="butler-member-new__name">{{ t('butler') }}</view>
<view class="butler-member-new__phone">{{ isAreaManagerLoading ? t('loadingButlerInfo') : t('noButlerInfo') }}</view>
<view class="butler-member-new__name">{{ t('managementContacts') }}</view>
<view class="butler-member-new__phone">{{ isManagementContactsLoading ? t('loadingButlerInfo') : t('noManagerContacts') }}</view>
</view>
</view>
</view>
@@ -296,7 +309,7 @@
</template>
<script>
import { accountQueryBills, facilityManagers, gatewayRoomDeviceList, orderSearchedDetail } from '../../../api/index.js'
import { accountQueryBills, facilityManagers, myDormitoryManagers, myCounselor, gatewayRoomDeviceList, orderSearchedDetail } from '../../../api/index.js'
import { getSessionSnapshot, saveMyRoomData } from '../../../utils/app-state'
import { ACCOUNT_BILL_PAYMENT_STATUS, ACCOUNT_BILL_STATUS, fetchEnums, getAccountReferenceTypeLabel, getApplicationDetailStatusLabel } from '../../../utils/enum-store'
import { getBookingStatusText } from '../../../utils/flow'
@@ -400,14 +413,62 @@ export default {
areaManager: null,
areaManagers: [],
isAreaManagerLoading: false,
// 宿管与管理员联系人
dormitoryManagers: [],
counselor: null,
isManagementContactsLoading: false,
// 导航栏滚动背景色
navBarBackgroundColor: 'transparent',
unpaidBillList: [],
smartDevices: [],
isSmartDevicesLoading: false,
smartDeviceError: '',
stayActions: [
{ key: 'change-room', label: '换房申请', labelEn: 'Change Room', icon: 'tn-icon-home-smile-fill', title: '换房申请', titleEn: 'Change Room Request', titleEnShort: 'Room Change', desc: '提交换房原因、期望房型和管家审核说明。', descEn: 'Submit your reason, expected room type, and butler review notes.' },
{ key: 'renew-stay', label: '续住申请', labelEn: 'Renew Stay', icon: 'tn-icon-calendar-fill', title: '续住申请', titleEn: 'Renew Stay Request', titleEnShort: 'Renew Stay', desc: '提交续住时长、续住原因和新的租期需求。', descEn: 'Submit extension duration, reason, and new lease request.' },
{ key: 'checkout', label: '退房申请', labelEn: 'Check Out', icon: 'tn-icon-home-in-fill', title: '退房申请', titleEn: 'Checkout Request', titleEnShort: 'Check Out', desc: '提交退房时间、离场说明和验房申请。', descEn: 'Submit checkout time, leave notes, and inspection request.' }
{
key: 'change-room',
label: '换房申请',
labelEn: 'Change Room',
icon: 'tn-icon-home-smile-fill',
statusZh: '可提交',
statusEn: 'Available',
isBusy: false,
image: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_11.png',
title: '换房申请',
titleEn: 'Change Room Request',
titleEnShort: 'Room Change',
desc: '提交换房原因、期望房型和管家审核说明。',
descEn: 'Submit your reason, expected room type, and butler review notes.'
},
{
key: 'renew-stay',
label: '续住申请',
labelEn: 'Renew Stay',
icon: 'tn-icon-calendar-fill',
statusZh: '可提交',
statusEn: 'Available',
isBusy: false,
image: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_16.png',
title: '续住申请',
titleEn: 'Renew Stay Request',
titleEnShort: 'Renew Stay',
desc: '提交续住时长、续住原因和新的租期需求。',
descEn: 'Submit extension duration, reason, and new lease request.'
},
{
key: 'checkout',
label: '退房申请',
labelEn: 'Check Out',
icon: 'tn-icon-home-in-fill',
statusZh: '可提交',
statusEn: 'Available',
isBusy: false,
image: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_17.png',
title: '退房申请',
titleEn: 'Checkout Request',
titleEnShort: 'Check Out',
desc: '提交退房时间、离场说明和验房申请。',
descEn: 'Submit checkout time, leave notes, and inspection request.'
}
],
services: [
{
@@ -440,6 +501,21 @@ export default {
desc: '填写备注说明需要几桶水后提交申请。',
descEn: 'Submit the quantity of water needed and any notes.'
},
{
key: 'meal-delivery',
icon: 'tn-icon-cart-fill',
nameZh: '送餐服务',
nameEn: 'Food Delivery',
statusZh: '可下单',
statusEn: 'Order Now',
isBusy: false,
image: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_15.png',
title: '送餐服务',
titleEn: 'Food Delivery',
titleEnShort: 'Food',
desc: '一键跳转点餐小程序,挑选餐品并下单配送。',
descEn: 'Open the ordering mini-program to pick meals and get delivery.'
},
{
key: 'cleaning',
icon: 'tn-icon-clean',
@@ -503,7 +579,7 @@ export default {
butler: '专属社区管家',
online: '当前在线 · 响应时间 < 5分钟',
callNow: '拨打',
publicService: '社区设施预约',
publicService: '公寓精选服务',
viewAll: '全部',
leaseTerm: '租期',
activeStatus: '使用中',
@@ -525,6 +601,10 @@ export default {
noContactPhone: '暂无联系电话',
loadingButlerInfo: '正在同步管家信息...',
noButlerInfo: '暂未获取到管家信息',
managementContacts: '宿管与管理员',
dormitoryManagerTag: '宿管',
counselorTag: '管理员',
noManagerContacts: '暂无管理联系人',
managerContactSummary: '当前区域共有 {count} 位管家可联系',
contactPhonePrefix: '联系电话 ',
loadingShort: '同步中',
@@ -583,7 +663,7 @@ export default {
butler: 'Community Butler',
online: 'Online · Response < 5 mins',
callNow: 'Call',
publicService: 'Facility Booking',
publicService: 'Premium Services',
viewAll: 'View All',
leaseTerm: 'Lease',
activeStatus: 'Active',
@@ -605,6 +685,10 @@ export default {
noContactPhone: 'No contact number',
loadingButlerInfo: 'Syncing butler info...',
noButlerInfo: 'No butler information yet',
managementContacts: 'Dorm Managers & Admins',
dormitoryManagerTag: 'Dorm Mgr',
counselorTag: 'Admin',
noManagerContacts: 'No management contacts',
managerContactSummary: '{count} butlers are available in this area',
contactPhonePrefix: 'Phone ',
loadingShort: 'Syncing',
@@ -884,6 +968,25 @@ export default {
butlerPhoneNumber() {
const manager = this.primaryAreaManager || {}
return this.getManagerPhone(manager)
},
// 合并宿管列表与管理员(辅导员)为统一联系人列表
managerContacts() {
const contacts = Array.isArray(this.dormitoryManagers)
? this.dormitoryManagers.map((item, index) => ({
...this.normalizeManager(item),
contactType: 'dormitory-manager',
contactKey: String(item.code || item.userCode || item.phone || index)
}))
: []
const counselor = this.counselor
if (counselor && typeof counselor === 'object' && (counselor.name || counselor.phone || counselor.code)) {
contacts.push({
...this.normalizeManager(counselor),
contactType: 'counselor',
contactKey: String(counselor.code || counselor.userCode || counselor.phone || 'counselor')
})
}
return contacts
}
},
mounted() {
@@ -1123,6 +1226,58 @@ export default {
return this._loadAreaManagerPromise
},
// 获取宿管列表与管理员(辅导员)联系人
fetchManagementContacts() {
if (!this.isCurrentLogin()) {
this.dormitoryManagers = []
this.counselor = null
return Promise.resolve()
}
if (this._fetchManagementContactsPromise) {
return this._fetchManagementContactsPromise
}
this.isManagementContactsLoading = true
this._fetchManagementContactsPromise = Promise.allSettled([
myDormitoryManagers(this.language),
myCounselor(this.language)
])
.then(([dormitoryManagersResult, counselorResult]) => {
this.dormitoryManagers = dormitoryManagersResult.status === 'fulfilled' && Array.isArray(dormitoryManagersResult.value && dormitoryManagersResult.value.data)
? dormitoryManagersResult.value.data
: []
this.counselor = counselorResult.status === 'fulfilled' && counselorResult.value && counselorResult.value.data && typeof counselorResult.value.data === 'object'
? counselorResult.value.data
: null
if (dormitoryManagersResult.status === 'rejected') {
console.warn('获取宿管联系人失败', dormitoryManagersResult.reason)
}
if (counselorResult.status === 'rejected') {
console.warn('获取管理员信息失败', counselorResult.reason)
}
})
.catch((error) => {
this.dormitoryManagers = []
this.counselor = null
console.warn('获取管理联系人失败', error)
})
.finally(() => {
this.isManagementContactsLoading = false
this._fetchManagementContactsPromise = null
})
return this._fetchManagementContactsPromise
},
// 滚动事件处理:顶部导航栏加背景色
handleScroll(e) {
const scrollTop = (e && e.detail && e.detail.scrollTop) || 0
if (scrollTop > 20) {
this.navBarBackgroundColor = '#F8FAFF'
} else {
this.navBarBackgroundColor = 'transparent'
}
},
fetchCurrentOrderDetail() {
if (!this.hasCheckedIn) {
return Promise.resolve()
@@ -1165,10 +1320,14 @@ export default {
this.hasCheckedIn = false
this.currentBooking = null
this.myRoomData = null
this.dormitoryManagers = []
this.counselor = null
return
}
this.currentBooking = storeUser.currentBooking || session.currentBooking || null
// 已登录即获取宿管与管理员联系人
this.fetchManagementContacts()
if (this.getStoreCheckinStatus() || session.hasCheckedIn) {
this.hasCheckedIn = true
this.myRoomData = buildRoomDataFromOrder(storeCurrentOrder) || buildRoomDataFromOrder(session.myRoomData) || session.myRoomData || null
@@ -1294,17 +1453,65 @@ export default {
}
return `/pkgService/submit_request/submit_request?title=${encodeURIComponent(title || this.t('submitRequest'))}&titleZh=${encodeURIComponent(titleZh)}&titleEn=${encodeURIComponent(titleEn)}&desc=${encodeURIComponent(desc || this.t('completeFormBeforeSubmit'))}&descZh=${encodeURIComponent(descZh)}&descEn=${encodeURIComponent(descEn)}&payload=${encodeURIComponent(JSON.stringify(payload))}`
},
buildTicketWorkbenchUrl(item = {}) {
const serviceTypeMap = {
repair: 'TicketRepairApply',
complaint: 'TicketSuggestionApply',
'change-room': 'TicketChangeApply',
checkout: 'TicketCheckOutApply',
'renew-stay': 'TicketRenewalApply',
'water-delivery': 'TicketWaterDeliveryApply',
cleaning: 'TicketCleaningApply'
}
const serviceType = serviceTypeMap[item.key]
if (!serviceType) {
return ''
}
return `/pkgService/ticket_workbench/ticket_workbench?serviceType=${encodeURIComponent(serviceType)}`
},
openServicePage(item) {
if (item.key === 'meal-delivery') {
this.openMealDeliveryMiniProgram()
return
}
if (item.key === 'utility') {
uni.navigateTo({
url: '/pkgService/utility_recharge/utility_recharge'
});
return
}
if (item.key === 'lost-found') {
uni.navigateTo({
url: '/pkgService/lost_found_publish/lost_found_publish'
});
return
}
const ticketUrl = this.buildTicketWorkbenchUrl(item)
if (ticketUrl) {
uni.navigateTo({
url: ticketUrl
});
return
}
uni.navigateTo({
url: this.buildServiceSubmitUrl(item)
});
},
// 送餐服务:跳转外部点餐小程序
openMealDeliveryMiniProgram() {
uni.navigateToMiniProgram({
appId: 'wxf221ee13f2abd417',
path: 'pages/scan/index?scene=1005&toMenuList=5&entityId=00684887&multiMenuId=006848879dd2e038019e017ba6e330d5',
success: () => {},
fail: (error) => {
console.error('跳转送餐小程序失败', error)
uni.showToast({
title: this.t('mealDeliveryJumpFailed'),
icon: 'none'
})
}
})
},
getManagerName(manager = {}) {
return manager.displayName || manager.name || manager.userName || manager.nickname || manager.userCode || manager.phone || ''
},
@@ -1400,6 +1607,10 @@ export default {
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
min-height: 100vh;
}
/* 导航栏背景色过渡 */
.service-page ::v-deep .tn-custom-nav-bar__bar {
transition: background-color 0.3s ease;
}
.service-scroll {
height: calc(100vh - 100rpx);
}
@@ -1729,43 +1940,6 @@ export default {
box-shadow: 0 6rpx 14rpx rgba($brand-primary, 0.16);
}
.stay-action-card {
width: 31%;
background: #ffffff;
border-radius: 24rpx;
padding: 24rpx 12rpx;
text-align: center;
box-shadow: 0 10rpx 26rpx rgba(0, 0, 0, 0.03);
}
.stay-action-icon {
width: 72rpx;
height: 72rpx;
margin: 0 auto;
border-radius: 50%;
background: linear-gradient(135deg, $icon-surface-brand-soft 0%, rgba($brand-primary, 0.04) 100%);
color: $icon-tone-brand-deep;
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx;
box-shadow: inset 0 0 0 1rpx rgba($brand-primary, 0.08);
}
.stay-action-icon-symbol {
font-size: 32rpx;
line-height: 1;
}
.stay-action-title {
font-size: 24rpx;
font-weight: bold;
color: #222222;
line-height: 1.3;
text-align: center;
word-break: break-word;
}
.half-btn {
width: 48%;
height: 92rpx;
@@ -2044,6 +2218,18 @@ export default {
margin-left: 10rpx;
}
/* 宿管标签 - 绿色 */
.butler-member-new__tag--type.tag-dormitory {
background: rgba(16, 185, 129, 0.1);
color: #10B981;
}
/* 管理员标签 - 琥珀色 */
.butler-member-new__tag--type.tag-counselor {
background: rgba(245, 158, 11, 0.1);
color: #F59E0B;
}
.butler-member-new__phone {
font-size: 24rpx;
color: #888888;