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

@@ -12,6 +12,30 @@
</view>
</view>
<!-- #ifdef MP-WEIXIN -->
<view v-if="showPrivacyPopup" class="privacy-mask">
<view class="privacy-dialog">
<view class="privacy-title">{{ t('privacyTitle') }}</view>
<view class="privacy-content">
{{ t('privacyContentPrefix') }}
<text class="privacy-link" @click="openPrivacyContract">{{ t('privacyContractName') }}</text>
{{ t('privacyContentSuffix') }}
</view>
<view class="privacy-action-row">
<view class="privacy-action-btn" @click="handlePrivacyDisagree">{{ t('privacyDisagree') }}</view>
<button
id="index-privacy-agree-btn"
class="privacy-action-btn is-primary"
open-type="agreePrivacyAuthorization"
@agreeprivacyauthorization="handleAgreePrivacyAuthorization"
>
{{ t('privacyAgree') }}
</button>
</view>
</view>
</view>
<!-- #endif -->
<!-- 二级页面 -->
<view v-if="hasPage('select-room')" v-show="currentPageId === 'select-room'">
<scroll-view class="custom-tabbar-page" scroll-y enable-back-to-top @scrolltolower="tabbarPageScrollLower">
@@ -87,9 +111,24 @@
import MyApplicationTab from './component/MyApplicationTab.vue'
import ContractAlert from '../../components/contract-alert/contract-alert.vue'
import { clearLegacyDemoCache, getHomePageTabId, getSessionSnapshot, saveHomePageTabId } from '../../utils/app-state'
import { checkRecordByChecked, notificationPage, applicationFormMePage } from '@/api/index.js'
import { applicationFormMePage, checkRecordByChecked, notificationPage, orderSearchedMePage, orderSearchedReservationFlow } from '@/api/index.js'
import { parseOrderListResponse, getOrderStatusCode } from '@/utils/booking-helper'
import { bindPrivacyAuthorizationListener, getPrivacyAuthorizationStatus, openWechatPrivacyContract, unbindPrivacyAuthorizationListener } from '@/utils/privacy.js'
import { countPendingInspectionRecords, extractInspectionList, resolveNotificationUnreadTotal } from '../../utils/message-helper'
import { syncLoginStayState } from '../../utils/session-sync'
import { getReservationOrderNo, getReservationCurrentNode, parseReservationFlowResponse } from '@/utils/reservation-flow'
function formatPageResponse(response) {
const payload = response && response.data ? response.data : response
return payload && typeof payload === 'object' ? payload : {}
}
function normalizeApplicationList(list = []) {
if (!Array.isArray(list)) {
return []
}
return list.filter(item => item && typeof item === 'object')
}
export default {
components: {
@@ -113,6 +152,7 @@
hasCheckedIn: false,
hasStayOrder: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
pendingContracts: []
},
tabDefinitions: [
@@ -125,6 +165,9 @@
loadedPages: ['select-room'],
isAppInitLoading: true,
isTabSwitching: false,
showPrivacyPopup: false,
resolvePrivacyAuthorization: null,
isCheckingPrivacyAuthorization: false,
i18n: {
zh: {
loadingBrand: '绿居公寓',
@@ -133,7 +176,13 @@
tabStayService: '入 住',
tabMyApplication: '申请单',
tabMessageCenter: '消 息',
tabProfileCenter: '我 的'
tabProfileCenter: '我 的',
privacyTitle: '隐私授权提示',
privacyContentPrefix: '进入小程序前,请先阅读并同意',
privacyContractName: '《用户隐私保护指引》',
privacyContentSuffix: ',同意后才能继续使用相关功能。',
privacyAgree: '同意并继续',
privacyDisagree: '暂不授权'
},
en: {
loadingBrand: 'Luju Apartments',
@@ -142,7 +191,13 @@
tabStayService: 'Stay',
tabMyApplication: 'Applications',
tabMessageCenter: 'Messages',
tabProfileCenter: 'Profile'
tabProfileCenter: 'Profile',
privacyTitle: 'Privacy Authorization',
privacyContentPrefix: 'Before entering the mini program, please read and agree to the ',
privacyContractName: 'Privacy Protection Guide',
privacyContentSuffix: ' to continue using related features.',
privacyAgree: 'Agree and Continue',
privacyDisagree: 'Not Now'
}
}
}
@@ -160,12 +215,12 @@
const tabs = []
if (this.sessionState.hasStayOrder) {
tabs.push('stay-service')
} else if (this.sessionState.hasPendingApplication) {
} else if (this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow) {
tabs.push('my-application')
} else {
tabs.push('select-room')
}
if (this.sessionState.hasPendingApplication && !tabs.includes('my-application')) {
if ((this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow) && !tabs.includes('my-application')) {
tabs.push('my-application')
}
tabs.push('message-center', 'profile-center')
@@ -186,14 +241,20 @@
},
onShow() {
this.handlePageShow()
this.checkEntryPrivacyAuthorization()
},
onLoad(options) {
clearLegacyDemoCache()
uni.$on('messageBadgeCountChange', this.handleMessageBadgeCountChange)
uni.$on('reservation-flow-force-refresh', this.handleReservationFlowForceRefresh)
this.initPrivacyAuthorization()
this.checkEntryPrivacyAuthorization()
this.handlePageLoad(options)
},
onUnload() {
uni.$off('messageBadgeCountChange', this.handleMessageBadgeCountChange)
uni.$off('reservation-flow-force-refresh', this.handleReservationFlowForceRefresh)
this.releasePrivacyAuthorization()
},
onReady() {
this.$nextTick(() => {
@@ -204,6 +265,76 @@
t(key) {
return this.$tm('pages.index.index', key)
},
initPrivacyAuthorization() {
// #ifdef MP-WEIXIN
if (typeof wx === 'undefined' || !wx.onNeedPrivacyAuthorization) {
return
}
this.handlePrivacyAuthorizationRequest = (resolve) => {
this.resolvePrivacyAuthorization = resolve
this.showPrivacyPopup = true
}
bindPrivacyAuthorizationListener(this.handlePrivacyAuthorizationRequest)
// #endif
},
releasePrivacyAuthorization() {
// #ifdef MP-WEIXIN
if (!this.handlePrivacyAuthorizationRequest) {
return
}
unbindPrivacyAuthorizationListener(this.handlePrivacyAuthorizationRequest)
this.handlePrivacyAuthorizationRequest = null
this.resolvePrivacyAuthorization = null
this.showPrivacyPopup = false
// #endif
},
checkEntryPrivacyAuthorization() {
// #ifdef MP-WEIXIN
if (this.isCheckingPrivacyAuthorization) {
return
}
this.isCheckingPrivacyAuthorization = true
getPrivacyAuthorizationStatus()
.then((res) => {
if (!this.resolvePrivacyAuthorization) {
this.showPrivacyPopup = !!(res && res.needAuthorization)
}
})
.finally(() => {
this.isCheckingPrivacyAuthorization = false
})
// #endif
},
openPrivacyContract() {
// #ifdef MP-WEIXIN
openWechatPrivacyContract().catch((error) => {
console.error('open privacy contract failed', error)
})
// #endif
},
handleAgreePrivacyAuthorization() {
this.showPrivacyPopup = false
if (!this.resolvePrivacyAuthorization) {
return
}
const resolvePrivacyAuthorization = this.resolvePrivacyAuthorization
this.resolvePrivacyAuthorization = null
resolvePrivacyAuthorization({
buttonId: 'index-privacy-agree-btn',
event: 'agree'
})
},
handlePrivacyDisagree() {
this.showPrivacyPopup = false
if (!this.resolvePrivacyAuthorization) {
return
}
const resolvePrivacyAuthorization = this.resolvePrivacyAuthorization
this.resolvePrivacyAuthorization = null
resolvePrivacyAuthorization({
event: 'disagree'
})
},
getStoreUser() {
return (this.$store && this.$store.state && this.$store.state.vuex_user) || {}
},
@@ -238,6 +369,58 @@
console.error('sync login stay state failed', error)
})
},
resolveApplicationState() {
return applicationFormMePage({
Status: 0,
PageIndex: 1,
PageSize: 1
}, this.$getLanguage())
.then((response) => {
const pageData = formatPageResponse(response)
const list = normalizeApplicationList(pageData.data || [])
const total = Number(pageData.total || 0)
const hasPendingApplication = list.some(item => Number(item && item.status) === 0)
return {
hasPendingApplication: total > 0 || hasPendingApplication
}
})
.catch((error) => {
console.error('resolve application state failed', error)
return {
hasPendingApplication: false
}
})
},
resolveApplicationFlowState(list = []) {
const orderList = normalizeApplicationList(list)
const reservationOrderList = orderList.filter(item => getOrderStatusCode(item) === 0)
const orderNos = [...new Set(reservationOrderList.map(item => getReservationOrderNo(item)).filter(Boolean))]
const result = {
hasPendingApplication: false,
hasActiveReservationFlow: false
}
if (!orderNos.length) {
return Promise.resolve(result)
}
return orderSearchedReservationFlow({
orderNos
}, this.$getLanguage())
.then((response) => {
const flowList = parseReservationFlowResponse(response)
const hasActiveReservationFlow = flowList.some(item => !!getReservationCurrentNode(item))
return {
hasPendingApplication: result.hasPendingApplication || hasActiveReservationFlow,
hasActiveReservationFlow
}
})
.catch((error) => {
console.error('resolve application flow state failed', error)
return result
})
},
refreshShellState(force = false, options = {}) {
const { skipMessageBadge = false } = options
const tasks = [
@@ -249,18 +432,30 @@
}
if (this.isCurrentLogin()) {
tasks.push(
applicationFormMePage({ Status: 0, PageIndex: 1, PageSize: 1 })
.then(res => {
const payload = res && res.data ? res.data : res
const total = payload && payload.total ? Number(payload.total) : (payload && payload.data && payload.data.length ? payload.data.length : 0)
this.sessionState.hasPendingApplication = total > 0
this.resolveApplicationState()
.then((applicationState = {}) => {
return orderSearchedMePage({ PageIndex: 1, PageSize: 100, IsExport: false })
.then(res => {
const orderList = parseOrderListResponse(res)
return this.resolveApplicationFlowState(orderList)
.then((flowState = {}) => ({
hasPendingApplication: !!applicationState.hasPendingApplication,
hasActiveReservationFlow: !!flowState.hasActiveReservationFlow
}))
})
})
.then((flowState = {}) => {
this.sessionState.hasPendingApplication = !!flowState.hasPendingApplication
this.sessionState.hasActiveReservationFlow = !!flowState.hasActiveReservationFlow
})
.catch(() => {
this.sessionState.hasPendingApplication = false
this.sessionState.hasActiveReservationFlow = false
})
)
} else {
this.sessionState.hasPendingApplication = false
this.sessionState.hasActiveReservationFlow = false
}
return Promise.all(tasks).finally(() => {
this.refreshSessionState()
@@ -314,6 +509,7 @@
hasCheckedIn: storeHasCheckedIn || session.hasCheckedIn,
hasStayOrder: hasStayOrder || pendingContracts.length > 0,
hasPendingApplication: !!this.sessionState.hasPendingApplication,
hasActiveReservationFlow: !!this.sessionState.hasActiveReservationFlow,
pendingContracts
}
},
@@ -396,6 +592,13 @@
const stayServiceTab = list.find(item => item.id === 'stay-service')
if (stayServiceTab) return stayServiceTab
}
const shouldPreferApplicationFlow = this.sessionState.isLogin
&& !this.sessionState.hasStayOrder
&& (this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow)
if (shouldPreferApplicationFlow && (!preferredPageId || preferredPageId === 'select-room')) {
const applicationTab = list.find(item => item.id === 'my-application')
if (applicationTab) return applicationTab
}
if (preferredPageId) {
const matchedTab = list.find(item => item.id === preferredPageId)
if (matchedTab) return matchedTab
@@ -438,6 +641,22 @@
this.getTabbarItemInfo()
})
},
async handleReservationFlowForceRefresh() {
await this.refreshShellState(true)
this.ensurePageLoaded('my-application')
this.$nextTick(() => {
const myApplicationTab = this.getPageRef('myApplicationTab')
if (myApplicationTab && typeof myApplicationTab.load === 'function') {
myApplicationTab.load(true)
} else {
this.refreshTabPageState('my-application')
}
if (this.currentPageId) {
this.refreshTabPageState(this.currentPageId)
}
this.getTabbarItemInfo()
})
},
tabbarPageScrollLower(e) {
if (this.currentPageId === 'select-room') {
const selectRoomTab = this.getPageRef('selectRoomTab')
@@ -587,6 +806,86 @@
background-color: #111111;
z-index: 99999;
}
.privacy-mask {
position: fixed;
inset: 0;
z-index: 100000;
background: rgba(17, 24, 39, 0.56);
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
box-sizing: border-box;
}
.privacy-dialog {
width: 100%;
max-width: 640rpx;
background: #FFFFFF;
border-radius: 28rpx;
padding: 40rpx 32rpx 32rpx;
box-sizing: border-box;
box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, 0.16);
}
.privacy-title {
font-size: 34rpx;
font-weight: 700;
color: #1A2233;
text-align: center;
}
.privacy-content {
margin-top: 24rpx;
font-size: 28rpx;
line-height: 1.7;
color: #5C6B8A;
}
.privacy-link {
color: $brand-primary;
font-weight: 700;
}
.privacy-action-row {
display: flex;
margin-top: 32rpx;
}
.privacy-action-btn {
flex: 1;
height: 88rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 700;
color: $brand-primary;
background: rgba($brand-primary, 0.08);
border: 1rpx solid rgba($brand-primary, 0.12);
box-sizing: border-box;
}
.privacy-action-btn + .privacy-action-btn {
margin-left: 20rpx;
}
button.privacy-action-btn {
padding: 0;
line-height: 88rpx;
}
button.privacy-action-btn::after {
border: none;
}
.privacy-action-btn.is-primary {
color: #FFFFFF;
background: linear-gradient(135deg, rgba($brand-primary, 0.92) 0%, $brand-primary 100%);
border-color: transparent;
}
.loading-logo {
animation: pulse 2s infinite ease-in-out;