Files
QSY-lvjuxcx/pages/index/component/StayServiceTab.vue
liangjian 1111ac148b feat: 重构核心租住流程页面与交互体验
统一注册、看房、申请与入住等核心页面的视觉和流程衔接,补齐支付、签约与状态流转闭环,提升移动端操作连贯性。
2026-07-29 09:05:38 +08:00

4165 lines
134 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="service-page tn-safe-area-inset-bottom">
<tn-nav-bar :isBack="false" :bottomShadow="false" :backgroundColor="navBarBackgroundColor" :customBackStyle="{ transition: 'all 0.3s ease' }">
<view class="tn-text-bold tn-text-xl" :class="navBarTextColor" style="transition: color 0.3s ease">{{ $tr('我的入住') }}</view>
</tn-nav-bar>
<scroll-view class="service-scroll" scroll-y @scroll="handleScroll">
<view v-if="!hasCheckedIn" class="empty-state animation-fade-in" :class="{'has-booking': currentBooking}">
<!-- 无预订:去选房 -->
<view v-if="!currentBooking" class="no-booking-card animation-fade-in" :style="{marginTop: vuex_custom_bar_height + 20 + 'px', marginInline: '30rpx'}">
<view class="no-booking-glow no-booking-glow--one"></view>
<view class="no-booking-glow no-booking-glow--two"></view>
<view class="no-booking-content tn-flex tn-flex-direction-column tn-flex-col-center">
<view class="icon-wrap">
<text class="tn-icon-home-smile"></text>
</view>
<view class="no-booking-kicker">{{ $tr('入住服务') }}</view>
<view class="no-booking-title">{{ $tr('您还没有在租房间') }}</view>
<view class="no-booking-desc">
{{ $tr('长租公寓为海外青年提供高端社区服务,请先前往选房大厅挑选心仪套间') }}
</view>
<view class="no-booking-action" @click="goSelectRoom">
<text class="tn-text-bold">{{ $tr('去选房') }}</text>
</view>
</view>
</view>
<!-- 有预订:流程作业页面 -->
<view v-else class="flow-page animation-fade-in" style="animation-delay: 0.1s;">
<!-- 缩小的顶部Banner -->
<view class="flow-banner">
<swiper
v-if="applicationHeroImages.length"
class="flow-banner-swiper"
:autoplay="applicationHeroImages.length > 1"
:circular="applicationHeroImages.length > 1"
interval="4200"
duration="480"
@change="handleApplicationHeroChange"
>
<swiper-item v-for="(image, index) in applicationHeroImages" :key="`${image}-${index}`">
<image :src="image" mode="aspectFill" class="flow-banner-image"></image>
</swiper-item>
</swiper>
<view v-else class="flow-banner-placeholder">
<view class="flow-banner-placeholder-glow"></view>
</view>
<view class="flow-banner-overlay"></view>
<view class="flow-banner-content" :style="{paddingTop: vuex_custom_bar_height + 'px'}">
<view class="flow-banner-topline">
<view class="flow-banner-chip">{{ applicationTypeText }}</view>
</view>
<view class="flow-banner-title clamp-text-1">{{ applicationHeroTitle }}</view>
<view class="flow-banner-subtitle clamp-text-1">{{ applicationHeroArea }}</view>
</view>
<view v-if="applicationHeroImages.length > 1" class="flow-banner-pagination">
<view
v-for="(image, index) in applicationHeroImages"
:key="`dot-${index}`"
class="flow-banner-pagination-dot"
:class="{ active: index === applicationHeroIndex }"
></view>
</view>
</view>
<view class="flow-body">
<view class="flow-unified-panel">
<view class="flow-unified-panel-glow flow-unified-panel-glow--one"></view>
<view class="flow-unified-panel-glow flow-unified-panel-glow--two"></view>
<!-- 房型信息卡片 -->
<view v-if="applicationHeroMeta.length" class="flow-meta-card">
<view class="flow-meta-header tn-text-bold tn-text-lg tn-color-black">{{ $tr('房型信息') }}</view>
<view class="flow-meta-grid">
<view v-for="item in applicationHeroMeta" :key="item.label" class="flow-meta-grid-item">
<view class="flow-meta-grid-label">{{ item.label }}</view>
<view class="flow-meta-grid-value clamp-text-2">{{ item.value }}</view>
</view>
</view>
</view>
<view v-if="applicationHeroMeta.length" class="flow-panel-divider"></view>
<!-- 竖向流程轨道 + 当前阶段卡片 -->
<view class="flow-timeline-card">
<view class="flow-timeline-head">
<view class="flow-timeline-title">{{ $tr('流程进度') }}</view>
<view class="flow-progress-pill">{{ flowProgressText }}</view>
</view>
<view class="flow-step-rail">
<view
v-for="node in flowNodes"
:key="node.step"
class="flow-step-item"
:class="{ completed: node.completed, current: node.current }"
>
<view class="flow-step-axis">
<view class="flow-step-orb">
<text v-if="node.completed" class="tn-icon-success"></text>
<text v-else class="flow-step-index">{{ node.step }}</text>
</view>
<view v-if="node.step !== flowNodes.length" class="flow-step-line"></view>
</view>
<view class="flow-step-main">
<view class="flow-step-label-row">
<view class="flow-step-label">{{ node.label }}</view>
<view v-if="node.current" class="flow-stage-status">{{ bookingStatusText }}</view>
</view>
<view v-if="node.current" class="flow-stage-content">
<view class="flow-stage-copy">
<view class="flow-stage-kicker">{{ $tr('当前阶段') }}</view>
<view class="flow-stage-title">{{ pendingStageTitle }}</view>
</view>
<view v-if="currentStageMetaText" class="flow-stage-meta-line">
<text class="flow-stage-meta-label">{{ currentStageMetaLabel }}</text>
<text class="flow-stage-meta-value">{{ currentStageMetaText }}</text>
</view>
<view class="flow-stage-desc">{{ pendingStageDesc }}</view>
<view v-if="nextFlowLabel" class="flow-stage-next">{{ $tr('下一步') }} · {{ nextFlowLabel }}</view>
<view v-if="workflowActionText || canCancelCurrentApplication" class="flow-stage-actions">
<view v-if="workflowActionText" class="action-btn primary" @click="handlePendingPrimaryAction">
<text class="tn-text-bold">{{ workflowActionText }}</text>
</view>
<view v-if="canCancelCurrentApplication" class="action-btn danger" @click="handleCancelApplication">
<text class="tn-text-bold">{{ cancelApplicationActionText }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 已入住状态下的服务大厅内容 -->
<view v-else class="animation-fade-in tn-padding-bottom" :style="{paddingTop: vuex_custom_bar_height + 'px'}">
<!-- 融合房卡与智能控制的新卡片 -->
<view class="integrated-room-card tn-margin-left tn-margin-right tn-margin-top">
<!-- 房卡背景 -->
<image v-if="roomCardCover" :src="roomCardCover" mode="aspectFill" class="irc-bg"></image>
<view v-else class="irc-placeholder">
<view class="irc-placeholder-glow"></view>
</view>
<view class="irc-mask"></view>
<view class="irc-content">
<!-- 顶部信息 -->
<view class="irc-header">
<view class="tn-flex tn-flex-row-between tn-flex-col-center">
<view class="irc-room-no">{{ roomDisplayText }}</view>
<view class="irc-status-badge">
<text class="tn-icon-vip-fill tn-margin-right-xs"></text>{{ $tr('使用中') }}
</view>
</view>
<view class="irc-room-title clamp-text-1 tn-margin-top-xs">{{ roomTitleText }}</view>
<view class="irc-lease tn-margin-top-sm">{{ $tr('租期') }}:{{ leaseTermText }}</view>
</view>
<!-- 环境与客控区 -->
<view class="irc-smart-area">
<view class="irc-env-row tn-flex tn-flex-row-between tn-flex-col-center">
<view class="irc-env-text">{{ smartSummaryText }}</view>
<view class="irc-smart-btn" @click="goSmartControl">
{{ $tr('全屋智能') }} <text class="tn-icon-right tn-margin-left-xs"></text>
</view>
</view>
<scroll-view scroll-x show-scrollbar="false" class="irc-quick-controls tn-margin-top">
<view v-if="smartQuickDeviceCards.length" class="irc-qc-list">
<view
v-for="item in smartQuickDeviceCards"
:key="item.key"
class="irc-qc-item"
:class="{'is-active': item.active}"
@click.stop="openSmartDevice(item)"
>
<view class="irc-qc-icon"><text :class="item.iconClass || 'tn-icon-my-computer'"></text></view>
<view class="irc-qc-name">{{ item.name }}</view>
</view>
</view>
<view v-else class="irc-qc-empty" @click.stop="goSmartControl">
<text class="tn-icon-add tn-margin-right-xs"></text>
{{ $tr('添加智能设备') }}
</view>
</scroll-view>
</view>
</view>
</view>
<view v-if="stayApplicationsEnabled && stayActions.length" class="tn-margin-top tn-margin-left tn-margin-right">
<view class="tn-text-bold tn-text tn-color-black tn-margin-bottom">{{ $tr('住中申请') }}</view>
<view class="tn-flex tn-flex-row-between">
<view
v-for="item in stayActions"
:key="item.key"
class="stay-action-btn tn-flex tn-flex-col-center tn-flex-row-center"
@click="openServicePage(item)"
>
<view class="stay-action-icon-box tn-flex tn-flex-col-center tn-flex-row-center">
<text :class="item.icon"></text>
</view>
<text>{{ pickItemText(item, 'label', 'labelEn') }}</text>
</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">{{ $tr('公寓精选服务') }}</view>
<view class="tn-text-sm tn-color-gray">{{ $tr('全部') }} <text class="tn-icon-right section-arrow-icon"></text></view>
</view>
<view class="tn-flex tn-flex-wrap tn-flex-row-between">
<view class="service-item tn-margin-bottom-sm" v-for="item in services" :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, 'nameZh', 'nameEn') }}</view>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="bill-section tn-margin-top tn-margin-left tn-margin-right tn-padding-lg">
<view class="tn-flex tn-flex-row-between tn-flex-col-center tn-margin-bottom">
<view class="tn-text-bold tn-text tn-color-black">{{ $tr('本月账单') }}</view>
<view class="status-tag tn-text-xs" :class="unpaidBillCount ? 'tag-busy' : 'tag-free'">{{ unpaidBillCount ? $tr('待支付') : $tr('已结清') }}</view>
</view>
<view class="tn-flex tn-flex-row-between tn-flex-col-bottom tn-margin-bottom-sm">
<view class="tn-text-bold tn-color-black" style="font-size: 60rpx;">
<text class="tn-text-lg tn-margin-right-xs">¥</text>{{ formatMoney(unpaidBillTotal) }}
</view>
<view class="custom-btn btn-primary" style="padding: 10rpx 30rpx;" @click.stop="goUtilityRecharge">
<text class="tn-text-sm tn-text-bold">{{ unpaidBillCount ? $tr('去支付') : $tr('查看账单') }}</text>
</view>
</view>
<view class="tn-flex tn-flex-row-between tn-margin-top">
<view class="tn-flex tn-flex-direction-column">
<text class="tn-color-gray tn-text-xs">{{ $tr('账单类型') }}</text>
<text class="tn-text-bold tn-text-sm tn-margin-top-xs tn-color-black clamp-text-1">{{ unpaidBillPrimaryType }}</text>
</view>
<view class="tn-flex tn-flex-direction-column">
<text class="tn-color-gray tn-text-xs">{{ $tr('账单数量') }}</text>
<text class="tn-text-bold tn-text-sm tn-margin-top-xs tn-color-black">{{ unpaidBillCount }} {{ $tr('笔') }}</text>
</view>
<view class="tn-flex tn-flex-direction-column">
<text class="tn-color-gray tn-text-xs">{{ $tr('最近生成') }}</text>
<text class="tn-text-bold tn-text-sm tn-margin-top-xs tn-color-black">{{ latestBillTimeText }}</text>
</view>
</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">{{ $tr('宿管与管理员') }}</view>
<view class="butler-card-new__inner">
<view v-if="managerContacts.length" class="butler-card-new__list">
<view
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(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(contact) }}</text>
</view>
<view class="online-dot"></view>
</view>
<view class="butler-member-new__info">
<view class="butler-member-new__name">
{{ getManagerName(contact) }}
<text class="butler-member-new__tag butler-member-new__tag--type" :class="contact.contactType === 'counselor' ? 'tag-counselor' : 'tag-dormitory'">
{{ contact.contactType === 'counselor' ? $tr('管理员') : $tr('宿管') }}
</text>
</view>
<view class="butler-member-new__phone">{{ getManagerPhone(contact) || $tr('暂无联系电话') }}</view>
</view>
</view>
<view
class="butler-member-new__action"
:class="{ 'disabled': !getManagerPhone(contact) }"
@click.stop="handleAreaManagerCall(contact)"
>
<text class="tn-icon-phone-fill"></text>
</view>
</view>
</view>
<view v-else class="butler-card-new__list">
<view class="butler-member-new">
<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-my-fill" style="font-size: 32rpx; color: #fff;"></text>
</view>
</view>
<view class="butler-member-new__info">
<view class="butler-member-new__name">{{ $tr('宿管与管理员') }}</view>
<view class="butler-member-new__phone">{{ isManagementContactsLoading ? $tr('正在同步管家信息...') : $tr('暂无管理联系人') }}</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 支付确认弹窗 -->
<tn-popup v-model="showDepositNotice" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true" @close="closeDepositNotice">
<view class="deposit-notice-popup">
<view class="popup-header">
<view class="popup-handle"></view>
<view class="popup-title">{{ getPaymentNoticeTitle() }}</view>
</view>
<view class="popup-body">
<view class="notice-content">
<view class="payment-sheet">
<view class="payment-hero">
<view class="payment-hero-label">{{ $tr('本次支付') }}</view>
<view class="payment-hero-amount">{{ getPaymentNoticeBillAmountDisplay() }}</view>
<view class="payment-hero-hint">{{ $tr('确认后将发起微信支付') }}</view>
</view>
<view v-if="paymentNoticeSourceLoading || paymentNoticeBillLoading" class="payment-sync-group">
<view v-if="paymentNoticeSourceLoading" class="payment-sync-chip">
{{ $tr('正在同步申请详情,请稍候...') }}
</view>
<view v-if="paymentNoticeBillLoading" class="payment-sync-chip">
{{ $tr('正在同步支付预览,请稍候...') }}
</view>
</view>
<view v-if="getPaymentNoticeDetailRows().length" class="payment-detail-panel">
<view class="payment-detail-header">
<view class="payment-detail-title">{{ $tr('入住申请信息') }}</view>
</view>
<view class="payment-detail-list">
<view
v-for="item in getPaymentNoticeDetailRows()"
:key="item.label"
class="payment-detail-row"
>
<text class="payment-detail-label">{{ item.label }}</text>
<text class="payment-detail-value">{{ item.value }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="popup-footer">
<view class="notice-action-btn" @click="confirmAndPayDeposit">
<text class="tn-text-bold">{{ getPaymentNoticeConfirmText() }}</text>
</view>
</view>
</view>
</tn-popup>
</view>
</template>
<script>
import { accountQueryBill, accountQueryBillPreview, accountQueryBills, applicationFormDetail, contractDetail, facilityManagers, gatewayRoomDeviceList, orderSearchedDetail, paymentBillPay, paymentPayingBills, roomInspectionDetail, userDetailCancel } from '../../../api/index.js'
import { getSessionSnapshot, saveActiveApplicationCode, 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'
import { normalizeBookingDetail, parseOrderDetailResponse } from '../../../utils/booking-helper'
import { normalizeSmartDeviceList } from '../../../utils/smart-device'
import { compactEnglishText, localizePayMethodText } from '../../../utils/select-room-normalizers'
import { addFilePrefix } from '../../../utils/common'
import { getReservationOrderNo } from '../../../utils/reservation-flow'
const DEFAULT_SMART_BRAND_ID = 'Tuya'
const VIEWING_APPLICATION_PATTERN = /预约看房申请|看房时间[::]|意向房型[::]|意向区域[::]|viewing request|viewing time[::]|preferred room type[::]|preferred area[::]/i
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
}
}
function parseExpireTimeToTimestamp(value) {
if (value === null || typeof value === 'undefined' || value === '') {
return 0
}
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0
}
const text = String(value).trim()
if (!text) {
return 0
}
if (/^\d{13,}$/.test(text)) {
return Number(text)
}
if (/^\d{10}$/.test(text)) {
return Number(text) * 1000
}
const timestamp = new Date(text).getTime()
return Number.isFinite(timestamp) ? timestamp : 0
}
function padCountdownUnit(value) {
return String(Math.max(0, Number(value) || 0)).padStart(2, '0')
}
function normalizeBillList(response) {
const payload = response && response.data ? response.data : response
if (Array.isArray(payload)) {
return payload
}
if (payload && Array.isArray(payload.data)) {
return payload.data
}
return []
}
function looksLikeInternalCode(value) {
if (!value) {
return false
}
const text = String(value).trim()
if (!text) {
return false
}
return /^[A-Z0-9]{20,}$/i.test(text)
}
function pickReadableValue(...values) {
for (let i = 0; i < values.length; i += 1) {
const value = values[i]
if (value === undefined || value === null) {
continue
}
const text = String(value).trim()
if (!text || looksLikeInternalCode(text)) {
continue
}
return text
}
return ''
}
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 pickFirstFilledValue(...values) {
for (let i = 0; i < values.length; i += 1) {
const value = values[i]
if (value === undefined || value === null) {
continue
}
const text = String(value).trim()
if (text) {
return text
}
}
return ''
}
function formatStayDate(value) {
if (!value) {
return ''
}
return String(value).replace('T', ' ').slice(0, 10)
}
function appendImageCandidates(result = [], value) {
if (value === undefined || value === null || value === '') {
return result
}
const normalizedValue = parseJsonStringMaybe(value)
if (Array.isArray(normalizedValue)) {
normalizedValue.forEach(item => appendImageCandidates(result, item))
return result
}
if (normalizedValue && typeof normalizedValue === 'object') {
const keys = [
'image',
'imageUrl',
'url',
'src',
'cover',
'coverUrl',
'poster',
'posterUrl',
'photo',
'photoUrl',
'thumb',
'thumbUrl',
'fileUrl',
'path'
]
keys.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(normalizedValue, key)) {
appendImageCandidates(result, normalizedValue[key])
}
})
return result
}
const text = String(normalizedValue).trim()
if (!text) {
return result
}
const values = text.includes(',') ? text.split(',') : [text]
values
.map(item => addFilePrefix(String(item || '').trim()))
.filter(Boolean)
.forEach((item) => {
if (!result.includes(item)) {
result.push(item)
}
})
return result
}
function extractGalleryImages(...sources) {
const result = []
const candidateKeys = [
'image',
'imageUrl',
'cover',
'coverUrl',
'poster',
'posterUrl',
'banner',
'bannerUrl',
'bannerImages',
'carouselImages',
'images',
'imageUrls',
'imageList',
'photos',
'photoList',
'pictures',
'album',
'albums',
'roomImages',
'roomTypeImages'
]
sources.forEach((source) => {
if (!source || typeof source !== 'object') {
return
}
candidateKeys.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(source, key)) {
appendImageCandidates(result, source[key])
}
})
})
return result
}
function buildRoomDataFromOrder(order = {}) {
if (!order || typeof order !== 'object' || !Object.keys(order).length) {
return null
}
const stayStart = order.startDate || order.start || (order.checkInPeriod && order.checkInPeriod.start)
const stayEnd = order.endDate || order.end || (order.checkInPeriod && order.checkInPeriod.end)
const imageUrls = Array.isArray(order.imageUrls)
? order.imageUrls
: typeof order.imageUrls === 'string'
? order.imageUrls.split(',').map(item => item.trim()).filter(Boolean)
: []
return {
...order,
roomLabel: pickReadableValue(order.roomLabel, order.roomNo),
titleZh: pickReadableValue(order.titleZh, order.roomTypeName, order.roomName) || '当前入住房间',
titleEn: pickReadableValue(order.titleEn, order.roomTypeNameEn, order.roomTypeName) || 'Current Stay',
leaseText: order.leaseText || [stayStart, stayEnd].filter(Boolean).map(item => formatStayDate(item)).join(' - '),
image: order.image || order.imageUrl || imageUrls[0] || ''
}
}
export default {
name: 'StayServiceTab',
data() {
return {
hasCheckedIn: false, // 模拟未入住/已入住状态
myRoomData: null, // 从缓存获取的房间信息
currentBooking: null,
currentFlow: null,
currentReservationStep: null,
currentReservationRefNo: '',
currentApplicationDetail: null,
reservationContractDetail: null,
reservationInspectionDetail: null,
applicationHeroIndex: 0,
isPendingStatusRefreshing: false,
isCancelingApplication: false,
areaManager: null,
areaManagers: [],
isAreaManagerLoading: false,
// 宿管与管理员联系人
dormitoryManagers: [],
counselor: null,
isManagementContactsLoading: false,
stayApplicationsEnabled: false,
// 导航栏滚动背景色
navBarBackgroundColor: 'transparent',
// 支付定金弹窗
showDepositNotice: false,
pendingDepositReferenceTypeNo: '',
pendingDepositPaymentContext: null,
paymentNoticeSourceItem: null,
paymentNoticeSourceLoading: false,
paymentNoticeBillLoading: false,
paymentNoticeBillDetail: null,
reservationBillDetailMap: {},
unpaidBillList: [],
smartDevices: [],
isSmartDevicesLoading: false,
smartDeviceError: '',
stayActions: [
{
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: [
{
key: 'repair',
icon: 'tn-icon-set-fill',
nameZh: '报修',
nameEn: 'Repair',
statusZh: '24小时',
statusEn: '24 Hours',
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_19.png',
title: '报修申请',
titleEn: 'Repair Request',
titleEnShort: 'Repair',
desc: '提交报修位置、问题说明和期望上门时间。',
descEn: 'Submit the repair location, issue details, and preferred visit time.'
},
{
key: 'water-delivery',
icon: 'tn-icon-shop-fill',
nameZh: '送水服务',
nameEn: 'Water Delivery',
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_14.png',
title: '送水服务申请',
titleEn: 'Water Delivery Request',
titleEnShort: 'Water Delivery',
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',
nameZh: '清洁服务',
nameEn: 'Cleaning Service',
statusZh: '可预约',
statusEn: 'Book 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_20.png',
title: '清洁服务申请',
titleEn: 'Cleaning Service Request',
titleEnShort: 'Cleaning',
desc: '选择上门清洁日期并提交服务申请。',
descEn: 'Choose a cleaning date and submit the service request.'
},
{
key: 'complaint',
icon: 'tn-icon-message-fill',
nameZh: '投诉建议',
nameEn: 'Feedback',
statusZh: '随时提交',
statusEn: 'Anytime',
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_8.png',
title: '投诉建议',
titleEn: 'Feedback',
titleEnShort: 'Feedback',
desc: '填写投诉内容或建议意见后提交反馈。',
descEn: 'Submit your complaint details or suggestions.'
},
{
key: 'lost-found',
icon: 'tn-icon-search',
nameZh: '失物拾物',
nameEn: 'Lost and Found',
statusZh: '登记中',
statusEn: 'Open',
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_13.png',
title: '失物拾物登记',
titleEn: 'Lost and Found',
titleEnShort: 'Lost & Found',
desc: '提交失物或拾获物品信息,方便社区协助查找。',
descEn: 'Submit lost or found item details so the community can help.'
}
],
}
},
computed: {
navBarTextColor() {
// 在流程作业页面(有预订且未入住)未滚动时,标题使用白色,否则黑色
if (!this.hasCheckedIn && this.currentBooking && this.navBarBackgroundColor === 'transparent') {
return 'tn-color-white'
}
return 'tn-color-black'
},
language() {
return this.$getLanguage()
},
bookingStatusText() {
if (this.isCurrentStepPendingDispatch) {
return this.$tr('待下发')
}
if (this.isCurrentContractAwaitingReview) {
return this.$tr('待审核')
}
if (this.isApplicationStep) {
return this.$tr('申请中')
}
if (this.isInspectionStep) {
return this.$tr('验房中')
}
if (this.isBillStep) {
return this.$tr('待支付')
}
if (this.isContractSigningStep) {
return this.$tr('待签约')
}
if (Number(this.currentReservationStep || 0) === 5) {
return this.$tr('待入住')
}
if (!this.currentBooking) {
return ''
}
const statusCodeCandidates = [
this.currentBooking.statusCode,
this.currentBooking.orderStatus,
this.currentBooking.approvalStatus,
this.currentBooking.auditStatus,
this.currentBooking.status
]
const numericStatusCode = statusCodeCandidates
.map(value => Number(value))
.find(value => Number.isFinite(value))
return this.currentBooking.statusText
|| this.currentBooking.statusDesc
|| (numericStatusCode !== undefined ? getApplicationDetailStatusLabel(numericStatusCode) : '')
|| getBookingStatusText(this.currentBooking.status || this.currentBooking.statusKey, this.language)
},
pendingContracts() {
const storeContracts = this.$store && this.$store.state && this.$store.state.vuex_pending_contracts
if (Array.isArray(storeContracts) && storeContracts.length) {
return storeContracts
}
const session = getSessionSnapshot()
return Array.isArray(session.pendingContracts) ? session.pendingContracts : []
},
currentReservationRawStep() {
return Number(this.currentReservationStep || 0)
},
currentReservationDisplayStep() {
return this.currentReservationRawStep
},
isCurrentStepPendingDispatch() {
return this.currentReservationRawStep >= 2 && this.currentReservationRawStep <= 5 && !this.currentReservationRefNo
},
pendingDispatchTargetLabel() {
const targetMap = {
2: this.$tr('验房单'),
3: this.$tr('账单'),
4: this.$tr('合同'),
5: this.$tr('入住安排')
}
return targetMap[this.currentReservationRawStep] || this.$tr('下一步流程')
},
isApplicationStep() {
return this.currentReservationDisplayStep === 1
},
currentApplicationPayStatus() {
const detail = this.currentApplicationDetail || {}
const numericStatus = Number(
detail.payStatus !== undefined && detail.payStatus !== null
? detail.payStatus
: detail.paymentStatus
)
return Number.isFinite(numericStatus) ? numericStatus : null
},
isCurrentApplicationPaid() {
if (!this.isApplicationStep || this.isViewingApplicationStep) {
return false
}
const payStatus = this.currentApplicationPayStatus
if (payStatus === 10) {
return true
}
const detail = this.currentApplicationDetail || {}
const payStatusText = String(detail.payStatusDesc || detail.paymentStatusDesc || '').trim()
return /已支付|paid/i.test(payStatusText)
},
isApplicationPendingPayment() {
if (!this.isApplicationStep) {
return false
}
if (this.isViewingApplicationStep) {
return false
}
if (this.isCurrentApplicationPaid) {
return false
}
// step=1 且存在 refNo 时,申请单阶段需要支付定金
if (this.currentReservationRefNo) {
return true
}
const flow = this.currentFlow || {}
return !!(flow.pendingPaymentOrder || flow.pendingDepositBill)
},
isViewingApplicationStep() {
return this.isApplicationStep && !!(this.currentApplicationDetail && this.currentApplicationDetail.isViewingApplication)
},
canCancelCurrentApplication() {
if (!this.currentReservationRefNo || !this.isApplicationStep) {
return false
}
return this.isViewingApplicationStep || this.isApplicationPendingPayment
},
isInspectionStep() {
return this.currentReservationDisplayStep === 2
},
isReservationInspectionPendingReview() {
if (!this.isInspectionStep) {
return false
}
const detail = this.reservationInspectionDetail || {}
return Number(detail.status) === 2
},
isBillStep() {
return this.currentReservationDisplayStep === 3
},
isContractSigningStep() {
if (!this.hasCheckedIn && this.pendingContracts.length > 0) {
return true
}
return this.currentReservationDisplayStep === 4
},
isCurrentContractAwaitingReview() {
if (!this.isContractSigningStep) {
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)
},
hasSignedContract() {
if (!this.hasCheckedIn && this.currentBooking && this.currentBooking.contractStatus === 1) {
return true
}
return false
},
currentStepIcon() {
const step = Number(this.currentReservationDisplayStep || 0)
const iconMap = {
1: 'tn-icon-safe',
2: 'tn-icon-search',
3: 'tn-icon-order-fill',
4: 'tn-icon-order-fill',
5: 'tn-icon-home-smile'
}
return iconMap[step] || 'tn-icon-home-smile'
},
currentStepLabel() {
const currentNode = this.flowNodes.find(item => item.current)
return currentNode ? currentNode.label : this.$tr('流程进度')
},
applicationTypeText() {
const detail = this.currentApplicationDetail || {}
return pickReadableValue(
detail.typeDesc,
detail.applicationTypeDesc,
detail.businessTypeDesc,
this.isViewingApplicationStep ? this.$tr('预约看房') : this.$tr('入住申请')
) || this.$tr('入住申请')
},
applicationHeroImages() {
return extractGalleryImages(
this.currentApplicationDetail,
this.currentBooking,
this.currentFlow,
this.myRoomData
)
},
applicationHeroTitle() {
const detail = this.currentApplicationDetail || {}
const booking = this.currentBooking || {}
return pickReadableValue(
detail.intendedRoomTypeName,
detail.roomTypeName,
booking.roomTypeName,
booking.roomName,
booking.titleZh,
booking.titleEn
) || this.$tr('申请房型待确认')
},
applicationHeroArea() {
const detail = this.currentApplicationDetail || {}
const booking = this.currentBooking || {}
return pickReadableValue(
detail.intendedAreaName,
detail.areaName,
booking.areaName,
booking.campusName,
this.myRoomData && this.myRoomData.areaName
) || this.$tr('公寓待确认')
},
applicationHeroTimelineLabel() {
return this.isViewingApplicationStep ? this.$tr('预约时间') : this.$tr('预计入住')
},
applicationHeroMeta() {
const detail = this.currentApplicationDetail || {}
const timelineValue = this.isViewingApplicationStep
? (detail.appointmentViewingTime ? this.formatDateTime(detail.appointmentViewingTime) : '')
: ([formatStayDate(detail.start), formatStayDate(detail.end)].filter(Boolean).join(' - ') || '')
const floorValue = pickReadableValue(detail.intendedFloorLevelDesc, detail.floorLevelDesc, '')
return [
{
label: this.applicationHeroTimelineLabel,
value: this.isViewingApplicationStep ? '' : timelineValue
},
{
label: this.$tr('意向楼层'),
value: floorValue
}
].filter(item => item.value && item.value !== '--' && item.value !== this.$tr('未知'))
},
currentStageMetaLabel() {
return this.isViewingApplicationStep ? this.$tr('预约时间') : this.$tr('预计入住')
},
currentStageMetaText() {
const detail = this.currentApplicationDetail || {}
if (this.isViewingApplicationStep) {
return detail.appointmentViewingTime ? this.formatDateTime(detail.appointmentViewingTime) : ''
}
return ''
},
canViewBookingDetail() {
return !!(this.currentBooking && this.currentBooking.orderNo)
},
workflowActionText() {
if (this.isPendingStatusRefreshing) {
return this.$tr('刷新中...')
}
if (this.isReservationInspectionPendingReview) {
return ''
}
if (this.isCurrentStepPendingDispatch) {
return this.$tr('刷新状态')
}
if (this.isContractSigningStep && !this.hasSignedContract && !this.isCurrentContractAwaitingReview) {
return this.$tr('去签约')
}
if (this.isBillStep) {
return this.$tr('去支付')
}
if (this.isInspectionStep) {
return this.$tr('去验房')
}
if (this.isApplicationPendingPayment) {
return this.$tr('支付定金')
}
if (this.isViewingApplicationStep) {
return this.$tr('刷新状态')
}
return this.$tr('刷新状态')
},
cancelApplicationActionText() {
return this.isCancelingApplication
? this.$tr('取消中...')
: this.$tr('取消申请')
},
flowNodes() {
const currentStep = Number(this.currentReservationDisplayStep || 0)
const labels = [
{ step: 1, label: this.$tr('申请单') },
{ step: 2, label: this.$tr('验房') },
{ step: 3, label: this.$tr('账单') },
{ step: 4, label: this.$tr('合同') },
{ step: 5, label: this.$tr('入住') }
]
return labels.map(item => ({
...item,
completed: currentStep > item.step,
current: currentStep === item.step
}))
},
currentFlowNode() {
return this.flowNodes.find(item => item.current)
|| this.flowNodes.find(item => !item.completed)
|| this.flowNodes[this.flowNodes.length - 1]
|| {
step: 0,
label: this.$tr('流程进度')
}
},
flowProgressText() {
const total = this.flowNodes.length
const currentStep = Number(this.currentFlowNode && this.currentFlowNode.step ? this.currentFlowNode.step : 0)
if (!total || !currentStep) {
return '00/00'
}
return `${String(currentStep).padStart(2, '0')}/${String(total).padStart(2, '0')}`
},
nextFlowLabel() {
const currentStep = Number(this.currentFlowNode && this.currentFlowNode.step ? this.currentFlowNode.step : 0)
if (!currentStep) {
return ''
}
const nextNode = this.flowNodes.find(item => item.step === currentStep + 1)
return nextNode ? nextNode.label : ''
},
pendingStageTitle() {
if (this.hasSignedContract || (this.currentReservationRawStep === 5 && !this.isCurrentStepPendingDispatch)) {
return this.$tr('合同已签署,等待办理入住')
}
if (this.isCurrentStepPendingDispatch) {
return `${this.$tr('等待管家发出')}${this.pendingDispatchTargetLabel}`
}
if (this.isReservationInspectionPendingReview) {
return this.$tr('验房已提交,等待管家审核')
}
if (this.isCurrentContractAwaitingReview) {
return this.$tr('合同已签署,等待管家审核')
}
if (this.isContractSigningStep) {
return this.$tr('请尽快签订合同')
}
if (this.isBillStep) {
return this.$tr('账单已生成,请尽快支付')
}
if (this.isInspectionStep) {
return this.$tr('请预约并完成验房')
}
if (this.isApplicationStep) {
if (this.isViewingApplicationStep) {
return this.$tr('预约看房已提交,等待确认')
}
if (this.isApplicationPendingPayment) {
return this.$tr('请支付定金')
}
if (this.isCurrentApplicationPaid) {
return this.$tr('定金已支付,等待管家处理')
}
return this.$tr('申请单已提交,等待审核')
}
return this.$tr('您的预订正在审核中')
},
pendingStageDesc() {
if (this.hasSignedContract || (this.currentReservationRawStep === 5 && !this.isCurrentStepPendingDispatch)) {
return this.$tr('您的住宿合同已经完成签署,管家会尽快为您安排办理入住流程。')
}
if (this.isCurrentStepPendingDispatch) {
return `${this.$tr('当前流程正在等待管家发出')}${this.pendingDispatchTargetLabel}${this.$tr(',下发后即可继续下一步操作。')}`
}
if (this.isReservationInspectionPendingReview) {
const detail = this.reservationInspectionDetail || {}
return String(detail.statusDesc || detail.statusDes || '').trim()
|| this.$tr('您的验房单已提交成功,当前正在等待管家审核,审核通过后将进入账单支付环节。')
}
if (this.isCurrentContractAwaitingReview) {
const detail = this.reservationContractDetail || {}
return String(detail.queryStatusDesc || detail.approvalStatusDesc || detail.statusDesc || '').trim()
|| this.$tr('您的合同已经签署完成,当前正在等待管家审核,审核通过后即可继续后续入住流程。')
}
if (this.isContractSigningStep) {
return this.$tr('系统已生成待签合同,当前流程已进入签订合同阶段,完成签约后即可继续后续入住流程。')
}
if (this.isBillStep) {
return this.$tr('当前流程已进入账单支付阶段,完成支付后即可进入合同签署环节。')
}
if (this.isInspectionStep) {
return this.$tr('当前流程已进入验房阶段,请按预约时间完成房间验收。')
}
if (this.isApplicationStep) {
if (this.isViewingApplicationStep) {
return this.$tr('您的预约看房申请已提交,请等待管家与您确认看房安排。')
}
if (this.isApplicationPendingPayment) {
return this.$tr('您的入住申请已提交,请先支付定金以确认预订。')
}
if (this.isCurrentApplicationPaid) {
return this.$tr('您的定金已支付成功,当前申请正在等待管家审核和后续处理。')
}
return this.$tr('您的申请单已提交,管家审核通过后将进入验房环节。')
}
return this.$tr('定金已支付,预定单正在审批流中。审批通过后将分配管家并等待分房号。')
},
roomCardCover() {
return this.myRoomData && this.myRoomData.image ? this.myRoomData.image : ''
},
roomTitleText() {
if (!this.myRoomData) {
return this.$tr('当前入住房间')
}
return this.language === 'zh'
? (pickReadableValue(this.myRoomData.titleZh, this.myRoomData.roomTypeName, this.myRoomData.roomName, this.myRoomData.titleEn) || this.$tr('当前入住房间'))
: (pickReadableValue(this.myRoomData.titleEn, this.myRoomData.roomTypeNameEn, this.myRoomData.roomTypeName, this.myRoomData.titleZh) || this.$tr('当前入住房间'))
},
roomDisplayText() {
if (!this.myRoomData) {
return '--'
}
return pickReadableValue(
this.myRoomData.roomNo,
this.myRoomData.roomLabel,
this.myRoomData.roomName
) || '--'
},
leaseTermText() {
if (!this.myRoomData) {
return '--'
}
const start = this.myRoomData.startDate || this.myRoomData.start || (this.myRoomData.checkInPeriod && this.myRoomData.checkInPeriod.start)
const end = this.myRoomData.endDate || this.myRoomData.end || (this.myRoomData.checkInPeriod && this.myRoomData.checkInPeriod.end)
const leaseText = this.myRoomData.leaseText || [start, end].filter(Boolean).map(item => formatStayDate(item)).join(' - ')
return leaseText || '--'
},
unpaidBillCount() {
return this.unpaidBillList.length
},
unpaidBillTotal() {
return this.unpaidBillList.reduce((sum, item) => {
return sum + Number(item && item.remainingAmount ? item.remainingAmount : 0)
}, 0)
},
unpaidBillPrimaryType() {
if (!this.unpaidBillList.length) {
return this.$tr('暂无待支付账单')
}
const firstBill = this.unpaidBillList[0] || {}
return firstBill.referenceTypeDesc || getAccountReferenceTypeLabel(firstBill.referenceType, this.$tr('账单记录'))
},
latestBillTimeText() {
if (!this.unpaidBillList.length || !this.unpaidBillList[0].createTime) {
return '--'
}
return this.formatDateTime(this.unpaidBillList[0].createTime)
},
smartSummaryText() {
if (!this.hasCheckedIn) {
return this.$tr('入住后可使用智能设备')
}
if (this.isSmartDevicesLoading) {
return this.$tr('正在同步智能设备...')
}
if (this.smartDevices.length) {
return this.$tr('已接入 {count} 台智能设备').replace('{count}', this.smartDevices.length)
}
if (this.smartDeviceError) {
return this.$tr('智能设备同步失败')
}
return this.$tr('当前房间暂无智能设备')
},
smartQuickDeviceCards() {
return this.smartDevices.map((device, index) => ({
...device,
key: device.renderKey || device.deviceId || device.deviceNo || `smart-device-${index}`,
name: device.deviceName || this.$tr('设备 {index}').replace('{index}', index + 1),
stateText: device.stateText || this.$tr('可控制')
}))
},
stayLocationText() {
if (!this.myRoomData) {
return ''
}
const bedLabel = this.myRoomData.slot || this.myRoomData.slotCode
return [
this.myRoomData.areaName || this.myRoomData.campusName || '',
this.myRoomData.buildingName || '',
this.myRoomData.buildingSectionName || '',
this.myRoomData.floorName || this.myRoomData.floor || '',
bedLabel ? this.$tr('床位 {label}').replace('{label}', bedLabel) : ''
].filter(Boolean).join(' · ')
},
butlerAvatar() {
const manager = this.primaryAreaManager || {}
return addFilePrefix(
manager.avatar
|| manager.avatarUrl
|| manager.headImg
|| manager.photo
|| ''
)
},
primaryAreaManager() {
return this.areaManagers[0] || this.areaManager || null
},
butlerDisplayName() {
const manager = this.primaryAreaManager || {}
const managerName = manager.displayName || manager.name || manager.userName || manager.nickname || manager.userCode || ''
if (!managerName) {
return this.$tr('专属社区管家')
}
if (this.areaManagers.length > 1) {
return this.$tr('{name} 等{count}位管家')
.replace('{name}', managerName)
.replace('{count}', this.language === 'zh' ? this.areaManagers.length : this.areaManagers.length - 1)
}
return managerName
},
butlerSubtitle() {
if (this.isAreaManagerLoading) {
return this.$tr('正在同步管家信息...')
}
if (this.areaManagers.length > 1) {
return this.$tr('当前区域共有 {count} 位管家可联系').replace('{count}', this.areaManagers.length)
}
const manager = this.primaryAreaManager || {}
const phone = this.getManagerPhone(manager)
if (phone) {
return `${this.$tr('联系电话 ')}${phone}`
}
return this.$tr('当前在线 · 响应时间 < 5分钟')
},
butlerCountText() {
if (this.isAreaManagerLoading) {
return this.$tr('同步中')
}
if (this.areaManagers.length > 1) {
return this.$tr('{count}人值班').replace('{count}', this.areaManagers.length)
}
if (this.areaManagers.length === 1) {
return this.$tr('1人在线')
}
return this.$tr('待同步')
},
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() {
fetchEnums(false, this.language)
this.checkLocalStatus()
this.loadUnpaidBills()
},
methods: {
pickItemText(item = {}, zhField, enField, fallback = '') {
if (this.language === 'en') {
const shortField = `${enField}Short`
return item[shortField] || item[enField] || item[zhField] || fallback
}
return item[zhField] || item[enField] || fallback
},
normalizeManager(manager = {}) {
if (!manager || typeof manager !== 'object') {
return null
}
const phoneSource = manager.phone || manager.mobile || manager.tel || manager.managerPhone || ''
const phone = typeof phoneSource === 'string' ? phoneSource.trim() : phoneSource
const userCode = manager.userCode || manager.code || ''
const displayName = manager.name || manager.userName || manager.nickname || userCode || ''
return {
...manager,
phone,
userCode,
displayName
}
},
getStoreUser() {
return (this.$store && this.$store.state && this.$store.state.vuex_user) || {}
},
getStoreCurrentOrder() {
return (this.$store && this.$store.state && this.$store.state.vuex_current_order) || null
},
getStoreCheckinStatus() {
return !!(this.$store && this.$store.state && this.$store.state.vuex_checkin_status)
},
getStorePendingContracts() {
const list = this.$store && this.$store.state && this.$store.state.vuex_pending_contracts
return Array.isArray(list) ? list : []
},
isCurrentLogin() {
const storeUser = this.getStoreUser()
if (storeUser && Object.keys(storeUser).length) {
return true
}
return !!getSessionSnapshot().isLogin
},
formatMoney(value) {
const amount = Number(value || 0)
return amount.toFixed(2)
},
formatDateTime(value) {
if (!value) {
return '--'
}
const text = String(value).replace('T', ' ')
return text.length > 16 ? text.slice(0, 16) : text
},
handleApplicationHeroChange(event) {
const detail = event && event.detail ? event.detail : {}
this.applicationHeroIndex = Number(detail.current || 0)
},
getCurrentOrderNo() {
const storeCurrentOrder = this.getStoreCurrentOrder()
if (storeCurrentOrder && storeCurrentOrder.orderNo) {
return String(storeCurrentOrder.orderNo).trim()
}
if (Number(this.currentReservationRawStep || 0) === 5) {
const reservationOrderNo = String(this.currentReservationRefNo || '').trim()
if (reservationOrderNo) {
return reservationOrderNo
}
}
const bookingOrderNo = String((this.currentBooking && this.currentBooking.orderNo) || '').trim()
if (bookingOrderNo) {
return bookingOrderNo
}
const roomData = this.myRoomData || {}
return String(roomData.orderNo || '').trim()
},
getCurrentAreaCode() {
const roomData = this.myRoomData || {}
return String(
roomData.areaCode
|| roomData.code
|| roomData.areaNo
|| ''
).trim()
},
getCurrentSmartBrandId() {
const roomData = this.myRoomData || {}
return pickFirstFilledValue(
roomData.brandId,
roomData.platform,
roomData.brand,
DEFAULT_SMART_BRAND_ID
) || DEFAULT_SMART_BRAND_ID
},
getCurrentSmartRoomCode() {
const roomData = this.myRoomData || {}
return pickFirstFilledValue(
roomData.roomCode,
roomData.facilityCode,
roomData.gatewayRoomCode,
roomData.iotRoomCode
)
},
getCurrentSmartRoomName() {
const roomData = this.myRoomData || {}
return pickReadableValue(
roomData.roomName,
roomData.roomLabel,
roomData.roomNo
)
},
mergeCurrentOrderDetail(orderDetail = {}) {
const detail = buildRoomDataFromOrder(orderDetail)
if (!detail) {
return
}
this.myRoomData = {
...(this.myRoomData || {}),
...detail
}
saveMyRoomData(this.myRoomData)
if (this.$store && this.$store.commit) {
const currentOrder = this.getStoreCurrentOrder()
this.$store.commit('$tStore', {
name: 'vuex_current_order',
value: {
...(currentOrder || {}),
...orderDetail
}
})
this.$store.commit('$tStore', {
name: 'vuex_user',
value: {
...this.getStoreUser(),
currentOrder: {
...((this.getStoreUser() && this.getStoreUser().currentOrder) || {}),
...orderDetail
}
}
})
}
this.loadAreaManager()
this.loadSmartDevices()
},
resetSmartDevicesState() {
this.smartDevices = []
this.smartDeviceError = ''
},
loadSmartDevices() {
if (!this.hasCheckedIn) {
this.resetSmartDevicesState()
return Promise.resolve()
}
const brandId = this.getCurrentSmartBrandId()
const roomCode = this.getCurrentSmartRoomCode()
if (!brandId || !roomCode) {
this.resetSmartDevicesState()
this.smartDeviceError = this.$tr('房间智能设备未配置')
return Promise.resolve()
}
const requestKey = `${brandId}::${roomCode}`
if (this._loadSmartDevicesPromise && this._loadSmartDevicesKey === requestKey) {
return this._loadSmartDevicesPromise
}
this._loadSmartDevicesKey = requestKey
this.isSmartDevicesLoading = true
const requestPromise = gatewayRoomDeviceList(brandId, roomCode)
.then((response) => {
if (`${this.getCurrentSmartBrandId()}::${this.getCurrentSmartRoomCode()}` !== requestKey) {
return
}
const deviceList = normalizeSmartDeviceList(response)
this.smartDevices = deviceList
this.smartDeviceError = ''
})
.catch((error) => {
console.error('load stay smart devices failed', error)
this.smartDevices = []
this.smartDeviceError = (error && (error.message || error.msg)) || this.$tr('智能设备加载失败')
})
.finally(() => {
this.isSmartDevicesLoading = false
this._loadSmartDevicesPromise = null
})
this._loadSmartDevicesPromise = requestPromise
return requestPromise
},
loadAreaManager() {
if (!this.hasCheckedIn) {
this.areaManager = null
this.areaManagers = []
return Promise.resolve()
}
const areaCode = this.getCurrentAreaCode()
if (!areaCode) {
this.areaManager = null
this.areaManagers = []
return Promise.resolve()
}
if (this._loadAreaManagerPromise && this._loadAreaManagerAreaCode === areaCode) {
return this._loadAreaManagerPromise
}
this._loadAreaManagerAreaCode = areaCode
this.isAreaManagerLoading = true
this._loadAreaManagerPromise = facilityManagers({
FacilityType: 10,
FacilityCode: areaCode
}, this.language)
.then((res) => {
if (this.getCurrentAreaCode() !== areaCode) {
return
}
const rawData = res && res.data !== undefined ? res.data : res
const managerList = Array.isArray(rawData)
? rawData
: (
Array.isArray(rawData && rawData.data)
? rawData.data
: (Array.isArray(rawData && rawData.managers) ? rawData.managers : [])
)
const normalizedManagers = Array.isArray(managerList)
? managerList.map(item => this.normalizeManager(item)).filter(item => item && item.phone)
: []
this.areaManagers = normalizedManagers
this.areaManager = normalizedManagers[0] || null
})
.catch((error) => {
console.warn('load area manager failed', error)
this.areaManager = null
this.areaManagers = []
})
.finally(() => {
this.isAreaManagerLoading = false
this._loadAreaManagerPromise = null
})
return this._loadAreaManagerPromise
},
// 滚动事件处理:顶部导航栏加背景色
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()
}
const orderNo = this.getCurrentOrderNo()
if (!orderNo) {
return Promise.resolve()
}
if (this._fetchCurrentOrderDetailPromise && this._fetchCurrentOrderDetailOrderNo === orderNo) {
return this._fetchCurrentOrderDetailPromise
}
this._fetchCurrentOrderDetailOrderNo = orderNo
this._fetchCurrentOrderDetailPromise = orderSearchedDetail(orderNo)
.then((response) => {
if (this.getCurrentOrderNo() !== orderNo) {
return
}
const detail = normalizeBookingDetail(parseOrderDetailResponse(response))
if (detail) {
this.mergeCurrentOrderDetail(detail)
}
})
.catch((error) => {
console.error('fetch current order detail failed', error)
})
.finally(() => {
this._fetchCurrentOrderDetailPromise = null
})
return this._fetchCurrentOrderDetailPromise
},
checkLocalStatus() {
const session = getSessionSnapshot()
const storeUser = this.getStoreUser()
const storeCurrentOrder = this.getStoreCurrentOrder()
if (!this.isCurrentLogin()) {
this.hasCheckedIn = false
this.currentBooking = null
this.reservationContractDetail = null
this.reservationInspectionDetail = null
this.myRoomData = null
this.dormitoryManagers = []
this.counselor = null
return
}
this.currentBooking = storeUser.currentBooking || session.currentBooking || null
this.currentFlow = storeUser.currentFlow || session.currentFlow || null
this.currentReservationStep = storeUser.currentReservationStep || session.currentReservationStep || (this.currentBooking && this.currentBooking.step) || null
this.currentReservationRefNo = storeUser.currentReservationRefNo || session.currentReservationRefNo || (this.currentBooking && this.currentBooking.refNo) || ''
this.syncCurrentApplicationDetail()
this.ensureCurrentContractDetail()
this.ensureCurrentInspectionDetail()
const shouldTreatAsCheckedIn = !!(this.getStoreCheckinStatus()
|| session.hasCheckedIn
|| (Number(this.currentReservationRawStep || 0) === 5 && String(this.currentReservationRefNo || '').trim()))
if (shouldTreatAsCheckedIn) {
this.hasCheckedIn = true
this.myRoomData = buildRoomDataFromOrder(storeCurrentOrder) || buildRoomDataFromOrder(session.myRoomData) || session.myRoomData || null
this.fetchCurrentOrderDetail()
this.loadAreaManager()
this.loadSmartDevices()
} else {
this.hasCheckedIn = false
this.myRoomData = null
this.areaManager = null
this.areaManagers = []
this.resetSmartDevicesState()
}
},
loadUnpaidBills() {
if (!this.isCurrentLogin()) {
this.unpaidBillList = []
return Promise.resolve()
}
return accountQueryBills({
status: ACCOUNT_BILL_STATUS.ACTIVE,
paymentStatus: ACCOUNT_BILL_PAYMENT_STATUS.UNPAID
})
.then((response) => {
const list = normalizeBillList(response)
this.unpaidBillList = Array.isArray(list) ? list : []
})
.catch(() => {
this.unpaidBillList = []
})
},
handlePendingPrimaryAction() {
if (this.isCurrentStepPendingDispatch) {
this.refreshPendingStatus()
return
}
if (this.isContractSigningStep && !this.hasSignedContract) {
this.goSignContract()
return
}
if (this.isBillStep) {
this.goPayBill()
return
}
if (this.isInspectionStep) {
this.goRoomInspection()
return
}
if (this.isApplicationPendingPayment) {
this.goPayDeposit()
return
}
this.refreshPendingStatus()
},
getCancelApplicationContent() {
return this.isViewingApplicationStep
? this.$tr('取消后将关闭当前预约看房申请,是否继续?')
: this.$tr('取消后将关闭当前申请单,是否继续?')
},
confirmCancelApplication() {
return new Promise((resolve) => {
uni.showModal({
title: this.$tr('取消申请'),
content: this.getCancelApplicationContent(),
confirmColor: '#ef4444',
success: (res) => resolve(!!(res && res.confirm)),
fail: () => resolve(false)
})
})
},
handleCancelApplication() {
if (!this.canCancelCurrentApplication || this.isCancelingApplication) {
return
}
this.confirmCancelApplication()
.then((confirmed) => {
if (!confirmed) {
return
}
this.isCancelingApplication = true
return userDetailCancel({
detailCode: this.currentReservationRefNo
}, this.language)
.then(() => {
if (this.currentApplicationDetail && typeof this.currentApplicationDetail === 'object') {
this.currentApplicationDetail = {
...this.currentApplicationDetail,
status: 99,
statusDesc: this.$tr('已结束')
}
}
uni.showToast({
title: this.$tr('申请已取消'),
icon: 'success'
})
this.$emit('refresh-shell-state')
})
})
.catch((error) => {
uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('取消申请失败'),
icon: 'none'
})
})
.finally(() => {
this.isCancelingApplication = false
})
},
goPayDeposit() {
const refNo = this.currentReservationRefNo
if (!refNo) {
uni.showToast({
title: this.$tr('申请单编号缺失'),
icon: 'none'
})
return
}
this.openDepositNoticeForPayment({
referenceTypeNo: refNo,
description: this.$tr('支付定金'),
mode: 'deposit',
sourceItem: this.currentBooking || this.currentApplicationDetail || this.currentFlow || {}
})
},
getPaymentContextMode(context = {}) {
const explicitMode = String(context.mode || '').trim()
if (explicitMode) {
return explicitMode
}
return context.billNo ? 'bill' : 'deposit'
},
isBillPaymentNotice() {
return this.getPaymentContextMode(this.pendingDepositPaymentContext || {}) === 'bill'
},
getPaymentNoticeTitle() {
return this.$tr('确认支付信息')
},
getPaymentNoticeConfirmText() {
return this.$tr('确认并支付')
},
getPaymentNoticeBillNo() {
const context = this.pendingDepositPaymentContext || {}
const detail = this.paymentNoticeBillDetail || {}
return String(this.getBillNo(detail) || context.billNo || '').trim()
},
getPaymentNoticeBillTitle() {
const detail = this.paymentNoticeBillDetail || {}
return String(
detail.description
|| detail.paymentFeeType
|| detail.remark
|| detail.createTypeDesc
|| detail.referenceTypeDesc
|| detail.billName
|| detail.name
|| (this.pendingDepositPaymentContext && this.pendingDepositPaymentContext.description)
|| this.$tr('账单支付')
).trim()
},
getPaymentNoticeBillAmount() {
const detail = this.paymentNoticeBillDetail || {}
const amount = detail.remainingAmount
if (amount !== '' && amount !== null && amount !== undefined) {
return amount
}
const source = this.paymentNoticeSourceItem || {}
const sourceAmount = source.remainingAmount
if (sourceAmount !== '' && sourceAmount !== null && sourceAmount !== undefined) {
return sourceAmount
}
const detailTotal = detail.totalCharge !== undefined && detail.totalCharge !== null && detail.totalCharge !== ''
? detail.totalCharge
: (detail.totalAmount !== undefined && detail.totalAmount !== null && detail.totalAmount !== '' ? detail.totalAmount : detail.amount)
if (detailTotal !== '' && detailTotal !== null && detailTotal !== undefined) {
return detailTotal
}
const sourceTotal = source.totalCharge !== undefined && source.totalCharge !== null && source.totalCharge !== ''
? source.totalCharge
: (source.totalAmount !== undefined && source.totalAmount !== null && source.totalAmount !== '' ? source.totalAmount : source.amount)
if (sourceTotal !== '' && sourceTotal !== null && sourceTotal !== undefined) {
return sourceTotal
}
return ''
},
getPaymentNoticeBillAmountDisplay() {
const amount = this.getPaymentNoticeBillAmount()
if (amount === '' || amount === null || amount === undefined) {
return '--'
}
return `¥${this.formatMoney(amount)}`
},
buildPaymentNoticePayMethod(source = {}) {
const pricePlanDetail = source && source.pricePlanDetail && typeof source.pricePlanDetail === 'object'
? source.pricePlanDetail
: {}
const payMethodText = String(
source.depositPrepayDesc
|| source.depositAndPrepayDesc
|| source.depositPrepayText
|| source.payMethod
|| source.payMethodDesc
|| source.paymentMethod
|| source.paymentMethodDesc
|| pricePlanDetail.payMethod
|| ''
).trim()
if (payMethodText) {
return localizePayMethodText(payMethodText, this.language)
}
const deposit = pricePlanDetail.deposit
const prepay = pricePlanDetail.prepay
if (deposit !== '' && deposit !== null && deposit !== undefined && prepay !== '' && prepay !== null && prepay !== undefined) {
return localizePayMethodText(`押${deposit}付${prepay}`, this.language)
}
return ''
},
getPaymentNoticeDetailRows() {
const source = this.paymentNoticeSourceItem || {}
const formatShortDate = (value) => {
if (!value) {
return ''
}
return String(value).replace('T', ' ').slice(0, 10)
}
const residentName = String(
source.userName
|| source.name
|| ''
).trim()
const areaName = String(
source.areaText
|| source.intendedAreaName
|| source.areaName
|| source.campusName
|| (this.currentApplicationDetail && this.currentApplicationDetail.areaName)
|| ''
).trim()
const roomTypeName = String(
source.roomTypeText
|| source.roomTypeName
|| source.intendedRoomTypeName
|| source.roomTypeCode
|| source.roomName
|| source.titleZh
|| source.titleEn
|| (this.currentApplicationDetail && this.currentApplicationDetail.intendedRoomTypeName)
|| ''
).trim()
const roomNo = String(
source.roomNoText
|| source.roomNo
|| source.roomLabel
|| ''
).trim()
const moveInDate = formatShortDate(
source.startDateText
|| source.expectedCheckInTime
|| source.startDate
|| source.start
|| source.checkinDate
|| (this.currentApplicationDetail && this.currentApplicationDetail.start)
|| ''
)
const leaseStart = formatShortDate(
source.startDateText
|| source.expectedCheckInTime
|| source.startDate
|| source.start
|| source.checkinDate
|| ''
)
const leaseEnd = formatShortDate(
source.endDateText
|| source.expectedCheckOutTime
|| source.endDate
|| source.end
|| source.checkoutDate
|| ''
)
const leasePeriod = leaseStart && leaseEnd
? `${leaseStart}${this.$tr(' 至 ')}${leaseEnd}`
: ''
const floorLevel = String(
source.intendedFloorLevelDesc
|| source.floorLevelDesc
|| ''
).trim()
const payMethod = this.buildPaymentNoticePayMethod(source)
return [
residentName ? { label: this.$tr('入住人'), value: residentName } : null,
areaName ? { label: this.$tr('所属公寓'), value: areaName } : null,
roomTypeName ? { label: this.$tr('房型'), value: roomTypeName } : null,
roomNo ? { label: this.$tr('房间号'), value: roomNo } : null,
floorLevel ? { label: this.$tr('意向楼层'), value: floorLevel } : null,
moveInDate ? { label: this.$tr('预计入住'), value: moveInDate } : null,
leasePeriod ? { label: this.$tr('租期'), value: leasePeriod } : null,
payMethod ? { label: this.$tr('押付方式'), value: payMethod } : null,
].filter(Boolean)
},
normalizePaymentNoticePreview(response, orderNo = '') {
const payload = parseJsonStringMaybe(response && response.data ? response.data : response)
const normalizeList = (list = []) => list.filter(item => item && typeof item === 'object')
const normalizedOrderNo = String(orderNo || '').trim()
let candidates = []
if (Array.isArray(payload)) {
candidates = normalizeList(payload)
} else if (payload && Array.isArray(payload.data)) {
candidates = normalizeList(payload.data)
} else if (payload && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
candidates = [payload.data]
} else if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
candidates = [payload]
}
if (!candidates.length) {
return null
}
return candidates.find(item => String(item.orderNo || '').trim() === normalizedOrderNo) || candidates[0]
},
resolvePaymentNoticeOrderNo(context = {}, sourceItem = null) {
return String(
context.orderNo
|| (sourceItem && (sourceItem.orderNo || sourceItem.reservationOrderNo || getReservationOrderNo(sourceItem)))
|| (this.currentBooking && this.currentBooking.orderNo)
|| (this.currentFlow && this.currentFlow.orderNo)
|| ''
).trim()
},
loadPaymentNoticeSourceDetail(referenceTypeNo = '', fallbackSource = null) {
const normalizedReferenceTypeNo = String(referenceTypeNo || this.getApplicationDetailCodeFromFlow() || '').trim()
if (!normalizedReferenceTypeNo) {
this.paymentNoticeSourceLoading = false
this.paymentNoticeSourceItem = fallbackSource || this.paymentNoticeSourceItem || null
return Promise.resolve(this.paymentNoticeSourceItem)
}
this.paymentNoticeSourceLoading = true
return this.resolveApplicationDetailPaymentContext(normalizedReferenceTypeNo)
.then((context) => {
const detail = context && context.detail ? context.detail : null
this.paymentNoticeSourceItem = detail
? {
...(fallbackSource || {}),
...detail
}
: (fallbackSource || this.paymentNoticeSourceItem || null)
return this.paymentNoticeSourceItem
})
.catch(() => {
this.paymentNoticeSourceItem = fallbackSource || this.paymentNoticeSourceItem || null
return this.paymentNoticeSourceItem
})
.finally(() => {
this.paymentNoticeSourceLoading = false
})
},
loadPaymentNoticeBillDetail(orderNo = '') {
const normalizedOrderNo = String(orderNo || '').trim()
if (!normalizedOrderNo) {
this.paymentNoticeBillLoading = false
this.paymentNoticeBillDetail = null
return Promise.resolve(null)
}
const cacheKey = `preview:${normalizedOrderNo}`
const cached = this.reservationBillDetailMap[cacheKey] || null
if (cached) {
this.paymentNoticeBillLoading = false
this.paymentNoticeBillDetail = cached
return Promise.resolve(cached)
}
this.paymentNoticeBillLoading = true
this.paymentNoticeBillDetail = null
return accountQueryBillPreview({
OrderNo: normalizedOrderNo
}, this.language)
.then((response) => {
const detail = this.normalizePaymentNoticePreview(response, normalizedOrderNo)
const normalizedDetail = detail && Object.keys(detail).length ? detail : null
if (normalizedDetail) {
this.$set(this.reservationBillDetailMap, cacheKey, normalizedDetail)
}
this.paymentNoticeBillDetail = normalizedDetail
return normalizedDetail
})
.catch(() => {
this.paymentNoticeBillDetail = null
return null
})
.finally(() => {
this.paymentNoticeBillLoading = false
})
},
openDepositNoticeForPayment(context = {}) {
const paymentSourceItem = context.sourceItem || this.currentBooking || this.currentApplicationDetail || this.currentFlow || null
const paymentReferenceTypeNo = String(
context.referenceTypeNo
|| this.getApplicationDetailCodeFromFlow()
|| ''
).trim()
const paymentOrderNo = this.resolvePaymentNoticeOrderNo(context, paymentSourceItem)
this.pendingDepositPaymentContext = {
referenceTypeNo: paymentReferenceTypeNo,
orderNo: paymentOrderNo,
billNo: String(context.billNo || '').trim(),
paymentNo: String(context.paymentNo || '').trim(),
description: String(context.description || '').trim(),
mode: this.getPaymentContextMode(context)
}
this.paymentNoticeSourceItem = paymentSourceItem
this.pendingDepositReferenceTypeNo = this.pendingDepositPaymentContext.referenceTypeNo
this.loadPaymentNoticeBillDetail(paymentOrderNo)
.then((preview) => {
const mergedSource = preview
? {
...(paymentSourceItem || {}),
...preview
}
: paymentSourceItem
this.paymentNoticeSourceItem = mergedSource
const nextReferenceTypeNo = String(
(preview && (preview.applicationDetailCode || preview.detailCode))
|| this.pendingDepositPaymentContext.referenceTypeNo
|| ''
).trim()
this.pendingDepositPaymentContext.referenceTypeNo = nextReferenceTypeNo
this.pendingDepositReferenceTypeNo = nextReferenceTypeNo
return this.loadPaymentNoticeSourceDetail(nextReferenceTypeNo, mergedSource)
})
.catch(() => {
this.loadPaymentNoticeSourceDetail(this.pendingDepositPaymentContext.referenceTypeNo, this.paymentNoticeSourceItem)
})
this.showDepositNotice = true
},
closeDepositNotice() {
this.showDepositNotice = false
this.pendingDepositReferenceTypeNo = ''
this.pendingDepositPaymentContext = null
this.paymentNoticeSourceItem = null
this.paymentNoticeSourceLoading = false
this.paymentNoticeBillLoading = false
this.paymentNoticeBillDetail = null
},
async confirmAndPayDeposit() {
const paymentContext = this.pendingDepositPaymentContext || {
referenceTypeNo: this.pendingDepositReferenceTypeNo
}
this.showDepositNotice = false
this.pendingDepositReferenceTypeNo = ''
this.pendingDepositPaymentContext = null
return this.payByPaymentContext(paymentContext)
},
normalizePayingBills(response) {
const payload = parseJsonStringMaybe(response && response.data ? response.data : response)
if (Array.isArray(payload)) {
return payload.filter(item => item && typeof item === 'object')
}
if (payload && Array.isArray(payload.data)) {
return payload.data.filter(item => item && typeof item === 'object')
}
return []
},
async getPendingPaymentOrder(billNo = '') {
const normalizedBillNo = String(billNo || '').trim()
if (!normalizedBillNo) {
return null
}
const response = await paymentPayingBills({
BillNo: normalizedBillNo
})
const orderList = this.normalizePayingBills(response)
return orderList.length ? orderList[0] : null
},
normalizeBillList(response) {
const payload = response && response.data ? response.data : response
if (Array.isArray(payload)) {
return payload.filter(item => item && typeof item === 'object')
}
if (payload && Array.isArray(payload.data)) {
return payload.data.filter(item => item && typeof item === 'object')
}
return []
},
normalizeBillDetail(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 {}
},
normalizeApplicationDetail(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 {}
},
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 {}
},
ensureCurrentContractDetail() {
const contractNo = String(this.currentReservationRefNo || '').trim()
if (!this.isContractSigningStep || !contractNo) {
this.reservationContractDetail = null
return Promise.resolve(null)
}
return contractDetail({
contractNo
}, this.language)
.then((response) => {
if (!this.isContractSigningStep || String(this.currentReservationRefNo || '').trim() !== contractNo) {
return this.reservationContractDetail
}
const detail = this.normalizeContractDetail(response)
this.reservationContractDetail = detail && Object.keys(detail).length ? detail : null
return this.reservationContractDetail
})
.catch(() => {
if (String(this.currentReservationRefNo || '').trim() === contractNo) {
this.reservationContractDetail = null
}
return null
})
},
ensureCurrentInspectionDetail() {
const inspectionNo = String(this.currentReservationRefNo || '').trim()
if (!this.isInspectionStep || !inspectionNo) {
this.reservationInspectionDetail = null
return Promise.resolve(null)
}
return roomInspectionDetail({
inspectionNo
}, this.language)
.then((response) => {
if (!this.isInspectionStep || String(this.currentReservationRefNo || '').trim() !== inspectionNo) {
return this.reservationInspectionDetail
}
const detail = this.normalizeInspectionDetail(response)
this.reservationInspectionDetail = detail && Object.keys(detail).length ? detail : null
return this.reservationInspectionDetail
})
.catch(() => {
if (String(this.currentReservationRefNo || '').trim() === inspectionNo) {
this.reservationInspectionDetail = null
}
return null
})
},
async resolveApplicationDetailPaymentContext(referenceTypeNo = '') {
const normalizedReferenceTypeNo = String(referenceTypeNo || '').trim()
if (!normalizedReferenceTypeNo) {
return null
}
const detail = this.normalizeApplicationDetail(await applicationFormDetail(normalizedReferenceTypeNo, this.language))
if (!detail || !Object.keys(detail).length) {
return null
}
return {
referenceTypeNo: String(detail.detailCode || normalizedReferenceTypeNo).trim(),
billNo: String(detail.billNo || detail.BillNo || '').trim(),
isViewingApplication: isViewingApplicationRecord(detail),
detail: {
...detail,
isViewingApplication: isViewingApplicationRecord(detail)
}
}
},
getApplicationDetailCodeFromFlow() {
const flow = this.currentFlow || {}
const flowNodes = Array.isArray(flow.flowNodes) ? flow.flowNodes : []
const applicationNode = [...flowNodes]
.filter(item => item && typeof item === 'object' && Number(item.step || 0) === 1)
.sort((prev, next) => Number(prev.sort || 0) - Number(next.sort || 0))[0] || null
return String(
(applicationNode && (applicationNode.detailCode || applicationNode.code || applicationNode.refNo))
|| (this.isApplicationStep ? this.currentReservationRefNo : '')
|| ''
).trim()
},
syncCurrentApplicationDetail() {
const applicationDetailCode = this.getApplicationDetailCodeFromFlow()
if (!applicationDetailCode) {
this.currentApplicationDetail = null
return Promise.resolve(null)
}
return applicationFormDetail(applicationDetailCode, this.language)
.then((response) => {
if (applicationDetailCode !== this.getApplicationDetailCodeFromFlow()) {
return this.currentApplicationDetail
}
const detail = this.normalizeApplicationDetail(response)
this.currentApplicationDetail = detail && Object.keys(detail).length
? {
...detail,
isViewingApplication: isViewingApplicationRecord(detail)
}
: null
this.applicationHeroIndex = 0
return this.currentApplicationDetail
})
.catch(() => {
if (applicationDetailCode === this.getApplicationDetailCodeFromFlow()) {
this.currentApplicationDetail = null
this.applicationHeroIndex = 0
}
return null
})
},
getBillPayRequests(bill = {}) {
return (Array.isArray(bill.payRequests) ? bill.payRequests : [])
.filter(item => item && typeof item === 'object')
.sort((prev, next) => new Date(next.createTime || 0).getTime() - new Date(prev.createTime || 0).getTime())
},
buildPendingPaymentOrderFromBillDetail(bill = {}) {
const payRequest = this.getBillPayRequests(bill).find((item) => {
return !item.paidTime
})
if (!payRequest) {
return null
}
return {
amount: Number(payRequest.amount || bill.remainingAmount || bill.totalCharge || 0),
expireTime: payRequest.expireTime || '',
payRequestNo: payRequest.no || '',
paymentNo: payRequest.no || '',
cashier: {
paymentInfo: payRequest.paymentInfo || '',
expireTime: payRequest.expireTime || ''
}
}
},
getPendingPaymentOrderNo(order = {}) {
const cashier = order && order.cashier ? order.cashier : {}
return String(
order.payRequestNo
|| order.payNo
|| order.paymentNo
|| cashier.payRequestNo
|| cashier.payNo
|| cashier.paymentNo
|| ''
).trim()
},
getBillNo(bill = {}) {
return bill.no || bill.billNo || ''
},
parsePaymentInfo(paymentInfo) {
if (!paymentInfo) {
return {}
}
if (typeof paymentInfo === 'object') {
return paymentInfo
}
const text = String(paymentInfo).trim()
if (!text) {
return {}
}
try {
return JSON.parse(text)
} catch (error) {
return {}
}
},
normalizeWechatPayOptions(paymentInfo) {
const source = this.parsePaymentInfo(paymentInfo)
return {
provider: 'wxpay',
timeStamp: String(source.timeStamp || source.timestamp || source.time_stamp || ''),
nonceStr: source.nonceStr || source.noncestr || source.nonce_str || '',
package: source.package || source.packageValue || source.package_value || '',
signType: source.signType || source.sign_type || 'MD5',
paySign: source.paySign || source.paysign || source.pay_sign || ''
}
},
requestWechatPayment(paymentInfo) {
const payOptions = this.normalizeWechatPayOptions(paymentInfo)
if (!payOptions.timeStamp || !payOptions.nonceStr || !payOptions.package || !payOptions.paySign) {
return Promise.reject(new Error(this.$tr('获取支付参数失败')))
}
return new Promise((resolve, reject) => {
uni.requestPayment({
...payOptions,
success: resolve,
fail: reject
})
})
},
getWechatLoginCode() {
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => {
if (!res || !res.code) {
reject(new Error(this.$tr('获取微信登录凭证失败')))
return
}
resolve(res.code)
},
fail: reject
})
})
},
async payByPaymentContext(context = {}) {
const paymentMode = this.getPaymentContextMode(context)
let referenceTypeNo = String(context.referenceTypeNo || '').trim()
let billNo = String(context.billNo || '').trim()
const paymentNo = String(context.paymentNo || '').trim()
const description = String(context.description || '').trim() || (paymentMode === 'bill' ? this.$tr('账单支付') : this.$tr('支付定金'))
const emptyBillMessage = paymentMode === 'bill'
? this.$tr('暂无待支付账单')
: this.$tr('暂无待支付定金账单')
const missingContextMessage = paymentMode === 'bill'
? this.$tr('账单编号缺失')
: this.$tr('申请单编号缺失')
if (!referenceTypeNo && !billNo && !paymentNo) {
uni.showToast({
title: missingContextMessage,
icon: 'none'
})
return
}
if (referenceTypeNo) {
saveActiveApplicationCode(referenceTypeNo)
}
uni.showLoading({ title: this.$tr('正在获取账单...') })
try {
let bill = null
if (referenceTypeNo && !billNo) {
const detailPaymentContext = await this.resolveApplicationDetailPaymentContext(referenceTypeNo).catch(() => null)
if (detailPaymentContext) {
if (detailPaymentContext.isViewingApplication) {
uni.showToast({
title: this.$tr('预约看房申请无需支付定金'),
icon: 'none'
})
return
}
referenceTypeNo = detailPaymentContext.referenceTypeNo || referenceTypeNo
billNo = detailPaymentContext.billNo || billNo
}
}
let pendingPaymentOrder = billNo ? await this.getPendingPaymentOrder(billNo).catch(() => null) : null
if (referenceTypeNo && !billNo) {
const detailPaymentContext = await this.resolveApplicationDetailPaymentContext(referenceTypeNo).catch(() => null)
if (detailPaymentContext) {
if (detailPaymentContext.isViewingApplication) {
uni.showToast({
title: this.$tr('预约看房申请无需支付定金'),
icon: 'none'
})
return
}
referenceTypeNo = detailPaymentContext.referenceTypeNo || referenceTypeNo
billNo = detailPaymentContext.billNo || billNo
}
if (!pendingPaymentOrder && billNo) {
pendingPaymentOrder = await this.getPendingPaymentOrder(billNo).catch(() => null)
}
}
if (billNo && !bill) {
const billDetail = this.normalizeBillDetail(await accountQueryBill({
billNo
}, this.language))
if (billDetail && Object.keys(billDetail).length) {
bill = billDetail
billNo = this.getBillNo(bill) || billNo
this.$set(this.reservationBillDetailMap, billNo, billDetail)
referenceTypeNo = referenceTypeNo || String(bill.referenceTypeNo || '').trim()
if (!pendingPaymentOrder) {
pendingPaymentOrder = this.buildPendingPaymentOrderFromBillDetail(bill)
}
}
}
const shouldQueryBillListByReference = !!referenceTypeNo && !(paymentMode === 'bill' && billNo)
if (shouldQueryBillListByReference) {
const billRes = await accountQueryBills({
referenceTypeNo,
status: 10,
paymentStatus: 0,
ProviderType: 'WechatMiniProgram'
})
const billList = this.normalizeBillList(billRes)
if (!Array.isArray(billList) || !billList.length) {
if (pendingPaymentOrder && billNo) {
bill = {
billNo,
no: billNo,
referenceTypeNo,
remainingAmount: Number(pendingPaymentOrder.amount || 0),
amount: Number(pendingPaymentOrder.amount || 0),
billName: description
}
} else {
uni.showToast({ title: emptyBillMessage, icon: 'none' })
return
}
} else {
bill = billNo
? (billList.find(item => String(this.getBillNo(item) || '').trim() === billNo) || null)
: null
if (!bill) {
bill = billList[0]
}
billNo = this.getBillNo(bill)
referenceTypeNo = referenceTypeNo || String(bill.referenceTypeNo || '').trim()
if (!pendingPaymentOrder && billNo) {
pendingPaymentOrder = await this.getPendingPaymentOrder(billNo).catch(() => null)
}
if (!pendingPaymentOrder) {
pendingPaymentOrder = this.buildPendingPaymentOrderFromBillDetail(bill)
}
}
}
if (!billNo) {
uni.showToast({ title: emptyBillMessage, icon: 'none' })
return
}
uni.showLoading({ title: this.$tr('正在拉起支付...') })
const isPendingPaymentOrderExpired = (order = {}) => {
const expireTimestamp = parseExpireTimeToTimestamp(order.expireTime || (order.cashier && order.cashier.expireTime) || '')
if (!expireTimestamp) {
return false
}
return expireTimestamp <= Date.now()
}
const reusablePendingPaymentOrder = pendingPaymentOrder && !isPendingPaymentOrderExpired(pendingPaymentOrder)
? pendingPaymentOrder
: null
if (reusablePendingPaymentOrder && reusablePendingPaymentOrder.cashier && reusablePendingPaymentOrder.cashier.paymentInfo) {
await this.requestWechatPayment(reusablePendingPaymentOrder.cashier.paymentInfo)
} else {
if (!referenceTypeNo || !bill) {
throw new Error(this.$tr('获取支付参数失败'))
}
const wechatCode = await this.getWechatLoginCode()
const payRes = await paymentBillPay({
referenceTypeNo,
billNo,
payMethod: 'Wechat',
amount: Number((bill && (bill.remainingAmount || bill.amount)) || 0),
description: (bill && (bill.billName || bill.name)) || description,
ExtraInfo: wechatCode
})
const payload = payRes && payRes.data ? payRes.data : payRes
const cashier = payload && payload.cashier ? payload.cashier : {}
await this.requestWechatPayment(cashier.paymentInfo)
}
uni.showToast({ title: this.$tr('支付成功'), icon: 'success' })
} catch (error) {
const errMsg = error && (error.errMsg || error.message || error.msg) ? (error.errMsg || error.message || error.msg) : this.$tr('支付失败')
if (/cancel/i.test(errMsg)) {
uni.showToast({ title: this.$tr('已取消支付'), icon: 'none' })
} else {
uni.showToast({ title: errMsg, icon: 'none' })
}
} finally {
uni.hideLoading()
setTimeout(() => {
this.refreshPendingStatus()
}, 1500)
}
},
goPayBill() {
const billNo = String(this.currentReservationRefNo || '').trim()
if (billNo) {
this.openDepositNoticeForPayment({
referenceTypeNo: this.getApplicationDetailCodeFromFlow(),
billNo,
description: this.$tr('账单支付'),
mode: 'bill',
sourceItem: this.currentBooking || this.currentApplicationDetail || {}
})
return
}
const orderNo = this.currentBooking && this.currentBooking.orderNo
if (orderNo) {
uni.navigateTo({
url: `/pkgBooking/pay/pay?orderNo=${encodeURIComponent(orderNo)}`
})
return
}
this.goBookingDetail()
},
goRoomInspection() {
const orderNo = this.currentBooking && this.currentBooking.orderNo
uni.navigateTo({
url: orderNo
? `/pkgService/room_inspection/room_inspection?orderNo=${encodeURIComponent(orderNo)}`
: '/pkgService/room_inspection/room_inspection'
})
},
goSignContract() {
const contractNo = String(this.currentReservationRefNo || '').trim()
const orderNo = String(this.currentBooking && this.currentBooking.orderNo || '').trim()
if (!contractNo) {
uni.showToast({
title: this.$tr('合同编号缺失'),
icon: 'none'
})
return
}
if (this.isCurrentContractAwaitingReview) {
uni.showToast({
title: this.$tr('合同已签,等待管家审核'),
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pkgService/contract_sign/contract_sign?no=${encodeURIComponent(contractNo)}&orderNo=${encodeURIComponent(orderNo)}`
})
},
refreshPendingStatus() {
if (this.isPendingStatusRefreshing) {
return
}
this.isPendingStatusRefreshing = true
// 通知父页只重新获取流程
this.$emit('refresh-shell-state')
},
onFlowRefreshed(result = {}) {
this.checkLocalStatus()
this.loadUnpaidBills()
this.isPendingStatusRefreshing = false
if (result.success === false) {
uni.showToast({
title: this.$tr('刷新失败,请稍后重试'),
icon: 'none'
})
return
}
const pendingContracts = this.getStorePendingContracts()
uni.showToast({
title: pendingContracts.length
? this.$tr('发现待签合同')
: this.$tr('状态已刷新'),
icon: 'success'
})
},
goSelectRoom() {
uni.reLaunch({
url: '/pages/index/index?index=0&pageId=select-room'
});
},
goBookingDetail() {
const orderNo = this.currentBooking && this.currentBooking.orderNo
? encodeURIComponent(this.currentBooking.orderNo)
: ''
uni.navigateTo({
url: orderNo
? `/pkgBooking/booking_detail/booking_detail?orderNo=${orderNo}`
: '/pkgBooking/booking_detail/booking_detail'
});
},
buildServiceSubmitUrl(item = {}) {
const roomData = this.myRoomData || {}
const titleZh = item.title || item.titleEn || item.nameZh || item.nameEn || this.$tr('提交申请')
const titleEn = item.titleEnShort || item.titleEn || item.title || item.nameEn || item.nameZh || this.$tr('提交申请')
const descZh = item.desc || item.descEn || this.$tr('请完善表单信息后提交。')
const descEn = compactEnglishText(item.descEn || item.desc || '', 68) || item.descEn || item.desc || this.$tr('请完善表单信息后提交。')
const title = this.language === 'en'
? titleEn
: titleZh
const desc = this.language === 'en'
? descEn
: descZh
const payload = {
businessType: item.key || '',
sourceType: 'stay-service',
roomLabel: roomData.roomLabel || '',
currentRoomLabel: roomData.roomLabel || '',
areaCode: roomData.areaCode || '',
areaName: roomData.areaName || '',
roomTypeCode: item.key === 'change-room' ? (roomData.roomTypeCode || '') : '',
roomTypeName: item.key === 'change-room' ? (roomData.roomTypeName || roomData.titleZh || '') : '',
roomName: roomData.roomLabel || ''
}
return `/pkgService/submit_request/submit_request?title=${encodeURIComponent(title || this.$tr('提交申请'))}&titleZh=${encodeURIComponent(titleZh)}&titleEn=${encodeURIComponent(titleEn)}&desc=${encodeURIComponent(desc || this.$tr('请完善表单信息后提交。'))}&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 (!this.stayApplicationsEnabled && ['change-room', 'renew-stay', 'checkout'].includes(item.key)) {
uni.showToast({
title: this.$tr('该功能暂未开放'),
icon: 'none'
})
return
}
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.$tr('跳转送餐小程序失败'),
icon: 'none'
})
}
})
},
getManagerName(manager = {}) {
return manager.displayName || manager.name || manager.userName || manager.nickname || manager.userCode || manager.phone || ''
},
getManagerPhone(manager = {}) {
return manager.phone || manager.mobile || manager.tel || manager.managerPhone || ''
},
getManagerAvatar(manager = {}) {
return addFilePrefix(manager.avatar || manager.avatarUrl || manager.headImg || manager.photo || '')
},
getManagerInitial(manager = {}) {
const name = this.getManagerName(manager)
return String(name).trim().slice(0, 1) || this.$tr('管')
},
handleButlerWechat() {
const manager = this.primaryAreaManager || {}
const wechat = manager.wechat || manager.wechatNo || manager.wechatCode || ''
if (wechat) {
uni.setClipboardData({
data: String(wechat),
success: () => {
uni.showToast({
title: this.$tr('管家微信已复制'),
icon: 'success'
})
}
})
return
}
uni.showToast({
title: this.$tr('暂无管家微信'),
icon: 'none'
})
},
handleAreaManagerCall(manager = {}) {
const phone = this.getManagerPhone(manager)
if (!phone) {
uni.showToast({
title: this.$tr('暂无联系电话'),
icon: 'none'
})
return
}
uni.makePhoneCall({
phoneNumber: String(phone)
})
},
handleButlerCall() {
if (!this.butlerPhoneNumber) {
uni.showToast({
title: this.isAreaManagerLoading ? this.$tr('正在加载管家信息') : this.$tr('暂无联系电话'),
icon: 'none'
})
return
}
uni.makePhoneCall({
phoneNumber: String(this.butlerPhoneNumber)
})
},
goUtilityRecharge() {
uni.navigateTo({
url: '/pkgService/utility_recharge/utility_recharge'
});
},
goSmartControl() {
const roomCode = this.getCurrentSmartRoomCode()
const roomName = this.getCurrentSmartRoomName()
const brandId = this.getCurrentSmartBrandId()
uni.navigateTo({
url: `/pkgService/smart_control/smart_control?brandId=${encodeURIComponent(brandId)}&roomCode=${encodeURIComponent(roomCode || '')}&roomName=${encodeURIComponent(roomName || '')}`
})
},
openSmartDevice(device = {}) {
if (!device || !Object.keys(device).length) {
this.goSmartControl()
return
}
const roomCode = this.getCurrentSmartRoomCode()
const roomName = this.getCurrentSmartRoomName()
const brandId = this.getCurrentSmartBrandId()
const payload = encodeURIComponent(JSON.stringify(device))
uni.vibrateShort()
uni.navigateTo({
url: `/pkgService/device_control/device_control?brandId=${encodeURIComponent(brandId)}&roomCode=${encodeURIComponent(roomCode || '')}&roomName=${encodeURIComponent(roomName || '')}&device=${payload}`
})
}
}
}
</script>
<style lang="scss" scoped>
.service-page {
background:
radial-gradient(circle at top, rgba(56, 189, 248, 0.14) 0%, rgba(56, 189, 248, 0) 26%),
linear-gradient(180deg, #EAF3FF 0%, #F5F8FF 28%, #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);
}
.empty-state {
min-height: calc(100vh - 100rpx);
}
/* 无预订卡片 */
.no-booking-card {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(246, 250, 255, 0.98) 100%);
border-radius: 38rpx;
box-shadow: 0 24rpx 60rpx rgba(15, 23, 42, 0.1);
border: 1rpx solid rgba(255, 255, 255, 0.88);
backdrop-filter: blur(20rpx);
margin-top: 20rpx;
}
.no-booking-glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.no-booking-glow--one {
top: -70rpx;
right: -30rpx;
width: 220rpx;
height: 220rpx;
background: rgba(56, 189, 248, 0.12);
}
.no-booking-glow--two {
left: -60rpx;
bottom: -50rpx;
width: 180rpx;
height: 180rpx;
background: rgba(15, 23, 42, 0.04);
}
.no-booking-bg {
display: none;
}
.no-booking-content {
position: relative;
z-index: 1;
padding: 96rpx 48rpx 88rpx;
}
.no-booking-card .icon-wrap {
width: 132rpx;
height: 132rpx;
border-radius: 40rpx;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.12) 0%, rgba(56, 189, 248, 0.06) 100%);
border: 1rpx solid rgba(56, 189, 248, 0.08);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.75);
display: flex;
align-items: center;
justify-content: center;
}
.no-booking-card .icon-wrap .tn-icon-home-smile {
font-size: 64rpx;
color: $brand-primary;
}
.no-booking-kicker {
margin-top: 28rpx;
font-size: 20rpx;
letter-spacing: 4rpx;
color: #94A3B8;
}
.no-booking-title {
font-size: 42rpx;
font-weight: 700;
color: #0F172A;
margin-top: 22rpx;
text-align: center;
}
.no-booking-desc {
max-width: 520rpx;
font-size: 26rpx;
color: #64748B;
text-align: center;
line-height: 1.8;
margin-top: 18rpx;
}
.no-booking-action {
min-width: 220rpx;
height: 72rpx;
padding: 0 34rpx;
margin-top: 40rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #0F172A 0%, #163E63 100%);
color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
box-shadow: 0 10rpx 22rpx rgba(15, 23, 42, 0.18);
transition: all 0.2s ease;
}
.no-booking-action:active {
transform: scale(0.98);
}
/* 流程作业页面重构 */
.flow-page {
padding-bottom: 60rpx;
}
.flow-banner {
position: relative;
height: 456rpx;
overflow: hidden;
background:
radial-gradient(circle at 18% 22%, rgba(125, 211, 252, 0.22) 0%, rgba(125, 211, 252, 0) 28%),
radial-gradient(circle at 82% 18%, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0) 24%),
linear-gradient(145deg, #0C2741 0%, #133A62 42%, #0F5F86 100%);
}
.flow-banner-swiper,
.flow-banner-image {
width: 100%;
height: 100%;
}
.flow-banner-placeholder {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background:
radial-gradient(circle at 20% 20%, rgba(126, 214, 255, 0.26) 0%, rgba(126, 214, 255, 0) 42%),
linear-gradient(135deg, #15385F 0%, #1A5784 56%, #1697C9 100%);
}
.flow-banner-placeholder-glow {
position: absolute;
top: 40rpx;
right: 40rpx;
width: 140rpx;
height: 140rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
filter: blur(12rpx);
}
.flow-banner-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.06) 0%, rgba(15, 23, 42, 0.18) 38%, rgba(15, 23, 42, 0.72) 100%);
}
.flow-banner-content {
position: absolute;
left: 32rpx;
right: 32rpx;
bottom: 118rpx;
z-index: 3;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.flow-banner-topline {
display: flex;
align-items: center;
margin-bottom: 18rpx;
}
.flow-banner-chip {
align-self: flex-start;
padding: 8rpx 20rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
color: #FFFFFF;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(12rpx);
border: 1rpx solid rgba(255, 255, 255, 0.3);
}
.flow-banner-title {
font-size: 44rpx;
font-weight: 700;
color: #FFFFFF;
line-height: 1.3;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
}
.flow-banner-subtitle {
margin-top: 8rpx;
font-size: 26rpx;
color: rgba(255, 255, 255, 0.85);
}
.flow-banner-pagination {
position: absolute;
right: 32rpx;
bottom: 34rpx;
z-index: 3;
display: flex;
align-items: center;
gap: 10rpx;
}
.flow-banner-pagination-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.4);
transition: all 0.3s ease;
}
.flow-banner-pagination-dot.active {
width: 24rpx;
border-radius: 999rpx;
background: #FFFFFF;
}
/* 主体内收,卡片式布局 */
.flow-body {
padding: 0 24rpx;
margin-top: -98rpx;
position: relative;
z-index: 10;
}
.flow-unified-panel {
position: relative;
overflow: hidden;
border-radius: 38rpx;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, rgba(246, 250, 255, 0.98) 100%);
box-shadow: 0 24rpx 60rpx rgba(15, 23, 42, 0.1);
border: 1rpx solid rgba(255, 255, 255, 0.88);
}
.flow-unified-panel-glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.flow-unified-panel-glow--one {
top: -80rpx;
right: -30rpx;
width: 220rpx;
height: 220rpx;
background: rgba(56, 189, 248, 0.12);
}
.flow-unified-panel-glow--two {
left: -50rpx;
top: 180rpx;
width: 180rpx;
height: 180rpx;
background: rgba(15, 23, 42, 0.04);
}
.flow-meta-card {
position: relative;
z-index: 1;
padding: 34rpx 28rpx 26rpx;
}
.flow-meta-header {
margin-bottom: 24rpx;
}
.flow-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
}
.flow-meta-grid-item {
padding: 22rpx 20rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.62);
border: 1rpx solid rgba(15, 23, 42, 0.04);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.78);
}
.flow-meta-grid-label {
font-size: 22rpx;
color: #64748B;
}
.flow-meta-grid-value {
margin-top: 8rpx;
font-size: 26rpx;
font-weight: 600;
color: #0F172A;
line-height: 1.4;
}
.flow-panel-divider {
height: 1rpx;
margin: 0 28rpx;
background: linear-gradient(90deg, rgba(15, 23, 42, 0) 0%, rgba(15, 23, 42, 0.08) 18%, rgba(15, 23, 42, 0.08) 82%, rgba(15, 23, 42, 0) 100%);
}
.flow-timeline-card {
position: relative;
z-index: 1;
padding: 30rpx 28rpx 28rpx;
}
.flow-timeline-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20rpx;
}
.flow-timeline-title {
font-size: 32rpx;
font-weight: 700;
color: #0F172A;
letter-spacing: 1rpx;
}
.flow-progress-pill {
flex-shrink: 0;
min-width: 108rpx;
height: 54rpx;
padding: 0 20rpx;
border-radius: 999rpx;
background: rgba(15, 23, 42, 0.05);
color: #0F172A;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 700;
letter-spacing: 1rpx;
}
.flow-step-rail {
display: flex;
flex-direction: column;
gap: 8rpx;
margin-top: 30rpx;
padding-top: 6rpx;
}
.flow-step-item {
display: flex;
align-items: flex-start;
gap: 18rpx;
}
.flow-step-axis {
width: 60rpx;
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
}
.flow-step-line {
width: 2rpx;
min-height: 54rpx;
margin: 10rpx 0 0;
background: linear-gradient(180deg, rgba($brand-primary, 0.2) 0%, rgba(15, 23, 42, 0.08) 100%);
}
.flow-step-orb {
width: 60rpx;
height: 60rpx;
border-radius: 22rpx;
background: rgba(255, 255, 255, 0.8);
border: 1rpx solid rgba(15, 23, 42, 0.08);
box-shadow: 0 10rpx 24rpx rgba(15, 23, 42, 0.06);
display: flex;
align-items: center;
justify-content: center;
color: #94A3B8;
font-size: 24rpx;
transition: all 0.25s ease;
}
.flow-step-index {
font-size: 22rpx;
font-weight: 700;
}
.flow-step-item.completed .flow-step-orb {
background: rgba($brand-primary, 0.12);
border-color: rgba($brand-primary, 0.14);
color: $brand-primary;
}
.flow-step-item.current .flow-step-orb {
background: linear-gradient(135deg, #0F172A 0%, #163E63 100%);
border-color: transparent;
color: #FFFFFF;
box-shadow: 0 14rpx 30rpx rgba(15, 23, 42, 0.22);
}
.flow-step-main {
flex: 1;
min-width: 0;
padding-bottom: 12rpx;
}
.flow-step-label-row {
min-height: 60rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14rpx;
}
.flow-step-label {
font-size: 28rpx;
font-weight: 600;
color: #475569;
}
.flow-step-item.current .flow-step-label,
.flow-step-item.completed .flow-step-label {
color: #0F172A;
}
.flow-stage-content {
margin-top: 8rpx;
padding: 10rpx 0 18rpx 2rpx;
}
.flow-stage-copy {
flex: 1;
}
.flow-stage-kicker {
font-size: 20rpx;
letter-spacing: 4rpx;
color: #94A3B8;
}
.flow-stage-title {
margin-top: 10rpx;
font-size: 32rpx;
line-height: 1.35;
color: #0F172A;
font-weight: 700;
}
.flow-stage-meta-line {
margin-top: 14rpx;
display: flex;
align-items: center;
gap: 12rpx;
flex-wrap: wrap;
}
.flow-stage-meta-label {
font-size: 22rpx;
color: #94A3B8;
}
.flow-stage-meta-value {
font-size: 24rpx;
color: #0F172A;
font-weight: 600;
}
.flow-stage-status {
flex-shrink: 0;
min-width: 104rpx;
height: 52rpx;
padding: 0 18rpx;
border-radius: 999rpx;
background: rgba($brand-primary, 0.1);
color: $brand-primary;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 600;
}
.flow-stage-desc {
margin-top: 14rpx;
font-size: 24rpx;
color: #475569;
line-height: 1.75;
}
.flow-stage-next {
margin-top: 12rpx;
font-size: 22rpx;
color: #64748B;
}
.flow-stage-actions {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin-top: 18rpx;
}
.action-btn {
min-width: 164rpx;
height: 64rpx;
padding: 0 24rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
transition: all 0.2s ease;
flex-shrink: 0;
}
.action-btn:active {
transform: scale(0.98);
}
.action-btn.primary {
background: linear-gradient(135deg, #0F172A 0%, #163E63 100%);
color: #FFFFFF;
box-shadow: 0 10rpx 22rpx rgba(15, 23, 42, 0.18);
}
.action-btn.secondary {
background: rgba(255, 255, 255, 0.88);
color: #0F172A;
border: 1rpx solid rgba(15, 23, 42, 0.08);
}
.action-btn.danger {
background: rgba(239, 68, 68, 0.06);
color: #DC2626;
border: 1rpx solid rgba(239, 68, 68, 0.12);
}
/* 操作按钮 */
.flow-task-footer {
padding: 0 32rpx 36rpx;
display: flex;
gap: 20rpx;
}
.flow-btn {
flex: 1;
height: 92rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
transition: all 0.3s ease;
}
.flow-btn:active {
transform: scale(0.98);
}
.flow-btn--primary {
background: linear-gradient(135deg, $brand-primary 0%, #0099CC 100%);
color: #FFFFFF;
box-shadow: 0 12rpx 28rpx rgba($brand-primary, 0.25);
}
.flow-btn--primary:active {
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.18);
}
.flow-btn--secondary {
background: #FFFFFF;
color: $brand-primary;
border: 2rpx solid rgba($brand-primary, 0.2);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.06);
}
.flow-btn--secondary:active {
background: rgba($brand-primary, 0.04);
}
/* --- 融合版房卡 (Integrated Room Card) --- */
.integrated-room-card {
border-radius: 36rpx;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #122d52 0%, #1c4678 46%, #16355d 100%);
box-shadow: 0 20rpx 54rpx rgba(15, 36, 68, 0.2);
border: 1rpx solid rgba(255, 255, 255, 0.08);
}
.irc-bg {
width: 100%;
height: 100%;
position: absolute;
z-index: 1;
transform: scale(1.04);
}
.irc-placeholder {
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(135deg, #143764 0%, #255694 46%, #102b4d 100%);
}
.irc-placeholder-glow {
position: absolute;
width: 360rpx;
height: 360rpx;
right: -100rpx;
top: -100rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.24) 0%, rgba(255, 255, 255, 0) 72%);
}
.irc-mask {
width: 100%;
height: 100%;
position: absolute;
z-index: 3;
background:
linear-gradient(135deg, rgba(5, 18, 38, 0.85) 0%, rgba(8, 27, 55, 0.6) 42%, rgba(6, 18, 36, 0.8) 100%),
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0) 100%);
}
.irc-content {
position: relative;
z-index: 4;
padding: 36rpx;
}
.irc-header {
border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
padding-bottom: 30rpx;
}
.irc-room-no {
font-size: 64rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
text-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.2);
}
.irc-status-badge {
height: 48rpx;
padding: 0 20rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(255, 219, 128, 0.9) 0%, rgba(255, 190, 92, 0.9) 100%);
color: #7a4c00;
font-size: 22rpx;
font-weight: 700;
}
.irc-room-title {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
.irc-lease {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.5);
}
.irc-smart-area {
padding-top: 30rpx;
}
.irc-env-text {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
}
.irc-smart-btn {
font-size: 24rpx;
color: #4facfe;
background: rgba(79, 172, 254, 0.1);
padding: 8rpx 20rpx;
border-radius: 20rpx;
border: 1rpx solid rgba(79, 172, 254, 0.2);
}
.irc-smart-btn:active {
background: rgba(79, 172, 254, 0.2);
}
.irc-quick-controls {
width: 100%;
white-space: nowrap;
margin-top: 24rpx;
}
.irc-qc-list {
display: inline-flex;
gap: 20rpx;
min-width: 100%;
padding-bottom: 4rpx;
}
.irc-qc-item {
width: 144rpx;
height: 144rpx;
padding: 0 8rpx;
background: rgba(255, 255, 255, 0.08);
border: 1rpx solid rgba(255, 255, 255, 0.06);
border-radius: 24rpx;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-sizing: border-box;
flex-shrink: 0;
}
.irc-qc-item:active {
transform: scale(0.95);
background: rgba(255, 255, 255, 0.12);
}
.irc-qc-item.is-active {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0.1) 100%);
border-color: rgba(255, 255, 255, 0.3);
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.1);
}
.irc-qc-icon {
font-size: 46rpx;
color: rgba(255, 255, 255, 0.6);
margin-bottom: 12rpx;
transition: all 0.3s;
}
.irc-qc-item.is-active .irc-qc-icon {
color: #ffffff;
text-shadow: 0 0 16rpx rgba(255, 255, 255, 0.4);
}
.irc-qc-name {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.7);
transition: all 0.3s;
width: 100%;
text-align: center;
font-weight: 500;
white-space: normal;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.irc-qc-item.is-active .irc-qc-name {
color: #ffffff;
}
.irc-qc-empty {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 136rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.05);
border: 1rpx dashed rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.5);
font-size: 26rpx;
letter-spacing: 2rpx;
}
/* --- 新版管家列表 (Butler Card New) --- */
.butler-card-new {
background-color: #ffffff;
border-radius: 24rpx;
padding: 30rpx;
box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.03);
}
.butler-card-new__inner {
margin-top: 10rpx;
}
.butler-member-new {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.butler-member-new:last-child {
border-bottom: none;
padding-bottom: 0;
}
.butler-member-new__identity {
display: flex;
align-items: center;
}
.butler-avatar-new {
width: 80rpx;
height: 80rpx;
border-radius: 40rpx;
position: relative;
}
.butler-avatar-new image {
width: 100%;
height: 100%;
border-radius: 40rpx;
}
.butler-avatar-new-fallback {
width: 100%;
height: 100%;
border-radius: 40rpx;
background: linear-gradient(135deg, rgba($brand-primary, 0.6) 0%, rgba($brand-primary, 0.8) 100%);
}
.butler-avatar-new-fallback-text {
font-size: 32rpx;
color: #fff;
font-weight: 700;
}
.butler-member-new__info {
margin-left: 20rpx;
}
.butler-member-new__name {
font-size: 30rpx;
font-weight: 600;
color: #333333;
display: flex;
align-items: center;
}
.butler-member-new__tag {
font-size: 20rpx;
background: rgba($brand-primary, 0.1);
color: $brand-primary;
padding: 2rpx 10rpx;
border-radius: 8rpx;
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;
margin-top: 6rpx;
}
.butler-member-new__action {
width: 64rpx;
height: 64rpx;
border-radius: 32rpx;
background: #f0f6ff;
color: $brand-primary;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
}
.butler-member-new__action:active {
background: #e6f0ff;
}
.butler-member-new__action.disabled {
background: #f5f5f5;
color: #cccccc;
}
.stay-action-btn {
flex: 1;
height: 88rpx;
margin-right: 20rpx;
background-color: #FFFFFF;
border-radius: 44rpx;
font-size: 26rpx;
color: #333333;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.03);
transition: all 0.3s ease;
font-weight: bold;
}
.stay-action-btn:last-child {
margin-right: 0;
}
.stay-action-btn:active {
transform: scale(0.96);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.02);
background-color: #F8FAFF;
color: #2B8EFF;
}
.stay-action-icon-box {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: linear-gradient(135deg, #F2F5FC, #E8EEFA);
color: #2B8EFF;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12rpx;
font-size: 28rpx;
}
.service-item {
width: 48%;
}
.service-card-visual {
height: 260rpx;
border-radius: 24rpx;
position: relative;
overflow: hidden;
box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.03);
}
.service-card-bg {
width: 100%;
height: 100%;
position: absolute;
z-index: 1;
}
.service-card-mask {
width: 100%;
height: 100%;
position: absolute;
z-index: 2;
background: linear-gradient(to bottom, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.8) 100%);
}
.service-card-content {
position: relative;
z-index: 3;
}
.service-icon-box {
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.92) 0%, rgba($brand-primary, 0.18) 100%);
box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.28);
}
.service-icon-symbol {
font-size: 28rpx;
color: $icon-tone-strong;
line-height: 1;
}
.section-arrow-icon {
margin-left: 4rpx;
font-size: 24rpx;
line-height: 1;
color: $icon-tone-muted;
}
.status-tag {
padding: 4rpx 16rpx;
border-radius: 20rpx;
font-weight: bold;
}
.tag-free {
background-color: rgba(255,255,255,0.2);
color: #FFFFFF;
backdrop-filter: blur(4px);
}
.tag-busy {
background-color: rgba(255, 90, 95, 0.9);
color: #FFFFFF;
}
/* 自定义按钮样式 */
.custom-btn {
display: inline-flex;
justify-content: center;
align-items: center;
border-radius: 100rpx;
padding: 12rpx 36rpx;
transition: all 0.3s;
}
.custom-btn:active {
transform: scale(0.98);
}
.btn-primary {
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
color: #ffffff;
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.25);
}
.btn-dark {
background: #ffffff;
color: $brand-primary;
border: 1rpx solid rgba($brand-primary, 0.3);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
}
.animation-fade-in {
animation: fadeIn 0.4s ease-out forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20rpx); }
to { opacity: 1; transform: translateY(0); }
}
.bill-section {
background-color: #FFFFFF;
border-radius: 24rpx;
box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.03);
}
.event-card {
background-color: #FFFFFF;
border-radius: 24rpx;
box-shadow: 0 8rpx 20rpx rgba(0,0,0,0.02);
transition: all 0.3s;
}
.event-card:active {
transform: scale(0.98);
}
.event-img-box {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
}
.event-img {
width: 100%;
height: 100%;
}
.avatar-stack {
display: flex;
align-items: center;
}
.stack-item {
width: 48rpx;
height: 48rpx;
border-radius: 24rpx;
border: 4rpx solid #FFFFFF;
margin-left: -16rpx;
}
.stack-item:first-child {
margin-left: 0;
}
.stack-more {
background-color: #F5F5F5;
color: #888888;
}
/* 支付定金须知弹窗 */
.deposit-notice-popup {
background: #FFFFFF;
border-radius: 32rpx 32rpx 0 0;
display: flex;
flex-direction: column;
height: 80vh;
}
.popup-header {
padding: 18rpx 88rpx 20rpx;
text-align: center;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(251, 253, 255, 0.96) 100%);
}
.popup-handle {
width: 88rpx;
height: 8rpx;
margin: 0 auto 18rpx;
border-radius: 999rpx;
background: rgba(148, 163, 184, 0.35);
}
.popup-title {
font-size: 34rpx;
font-weight: 600;
line-height: 1.2;
letter-spacing: 1rpx;
color: #1A2233;
}
.popup-body {
flex: 1;
padding: 28rpx 28rpx 24rpx;
overflow-y: auto;
background: linear-gradient(180deg, #F7FAFF 0%, #F2F5FC 100%);
}
.notice-content {
padding-bottom: 24rpx;
}
.payment-sheet {
position: relative;
padding: 28rpx;
border-radius: 32rpx;
background:
radial-gradient(circle at top right, rgba(43, 142, 255, 0.16) 0, rgba(43, 142, 255, 0) 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(246, 250, 255, 0.96) 100%);
border: 1rpx solid rgba(43, 142, 255, 0.1);
box-shadow: 0 24rpx 60rpx rgba(43, 142, 255, 0.1);
}
.payment-hero {
padding: 18rpx 6rpx 14rpx;
text-align: center;
}
.payment-hero-label {
font-size: 24rpx;
color: #7C8CA5;
letter-spacing: 2rpx;
}
.payment-hero-amount {
margin-top: 14rpx;
font-size: 68rpx;
line-height: 1;
font-weight: 700;
color: #183B73;
}
.payment-hero-hint {
margin-top: 16rpx;
font-size: 24rpx;
color: #6F809C;
}
.payment-sync-group {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin-top: 18rpx;
}
.payment-sync-chip {
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(43, 142, 255, 0.08);
color: #4C6A95;
font-size: 22rpx;
}
.payment-detail-panel {
margin-top: 26rpx;
padding: 24rpx;
border-radius: 26rpx;
background: rgba(255, 255, 255, 0.82);
border: 1rpx solid rgba(43, 142, 255, 0.08);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.8);
}
.payment-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.payment-detail-title {
font-size: 28rpx;
line-height: 1.2;
color: #22324D;
font-weight: 700;
}
.payment-detail-list {
display: flex;
flex-direction: column;
}
.payment-detail-row {
padding: 22rpx 4rpx;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 28rpx;
}
.payment-detail-row + .payment-detail-row {
border-top: 1rpx solid rgba(43, 142, 255, 0.08);
}
.payment-detail-label {
flex: 0 0 148rpx;
font-size: 22rpx;
color: #7C8CA5;
line-height: 1.5;
}
.payment-detail-value {
flex: 1;
font-size: 26rpx;
line-height: 1.4;
color: #22324D;
word-break: break-all;
font-weight: 600;
text-align: right;
}
.popup-footer {
padding: 18rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.92) 26%, #FFFFFF 100%);
display: flex;
justify-content: center;
backdrop-filter: blur(8rpx);
}
.notice-action-btn {
width: 100%;
height: 88rpx;
padding: 0 40rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
background: linear-gradient(135deg, #2B8EFF 0%, #1F6FE5 100%);
color: #ffffff;
box-shadow: 0 14rpx 28rpx rgba(43, 142, 255, 0.22);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.notice-action-btn:active {
transform: scale(0.98);
}
</style>