Files
QSY-lvjuxcx/pages/index/component/StayServiceTab.vue
2026-06-29 09:42:53 +08:00

2228 lines
72 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="transparent" :customBackStyle="{ transition: 'all 0.3s ease' }">
<view class="tn-text-bold tn-text-xl tn-color-black">{{ t('title') }}</view>
</tn-nav-bar>
<scroll-view class="service-scroll" scroll-y :style="{paddingTop: vuex_custom_bar_height + 'px'}">
<view v-if="!hasCheckedIn" class="empty-state tn-padding animation-fade-in">
<!-- 无预订:去选房 -->
<view v-if="!currentBooking" class="no-booking-card">
<view class="no-booking-bg"></view>
<view class="no-booking-content tn-flex tn-flex-direction-column tn-flex-col-center">
<view class="icon-wrap tn-margin-bottom">
<text class="tn-icon-home-smile tn-color-white"></text>
</view>
<view class="tn-text-bold tn-color-white" style="font-size: 40rpx;">{{ t('notCheckedInTitle') }}</view>
<view class="tn-color-white tn-margin-top-sm tn-text-center" style="opacity: 0.8; line-height: 1.6;">
{{ t('notCheckedInDesc') }}
</view>
<view class="custom-btn btn-glass tn-margin-top-xl" @click="goSelectRoom" style="width: 80%;">
<text class="tn-text-bold">{{ t('goSelectRoom') }}</text>
</view>
</view>
</view>
<!-- 有预订:审核中 -->
<view v-else class="pending-booking-card animation-fade-in" style="animation-delay: 0.1s;">
<!-- 顶部状态大图与标识 -->
<view class="pending-hero">
<view class="hero-bg-shape"></view>
<view class="hero-content tn-flex tn-flex-col-center tn-flex-row-center">
<view class="status-icon-ring tn-flex tn-flex-col-center tn-flex-row-center">
<text class="tn-icon-time-fill status-icon"></text>
</view>
</view>
</view>
<view class="pending-body tn-padding-xl">
<!-- 核心文案 -->
<view class="status-text-area tn-text-center">
<view class="tn-text-bold tn-text-xxl tn-color-black">{{ pendingStageTitle }}</view>
<view class="tn-color-gray tn-margin-top-sm tn-text-sm status-desc">
{{ pendingStageDesc }}
</view>
</view>
<!-- 预定信息卡片 (玻璃态拟物风格) -->
<view class="booking-ticket-card tn-margin-top-xl">
<view class="ticket-header tn-flex tn-flex-row-between tn-flex-col-center">
<view class="tn-flex tn-flex-col-center">
<view class="ticket-icon-wrap"><text class="tn-icon-order-fill"></text></view>
<text class="tn-text-bold tn-text-lg tn-color-black tn-margin-left-sm">{{ t('bookingInfo') }}</text>
</view>
<view class="ticket-status-badge">{{ bookingStatusText }}</view>
</view>
<view class="ticket-divider">
<view class="divider-line"></view>
</view>
<view class="ticket-content">
<view class="ticket-row">
<text class="ticket-label">{{ pendingInfoLabel }}</text>
<text class="ticket-value tn-text-bold">{{ bookingRoomText }}</text>
</view>
<view class="ticket-note tn-margin-top-sm">
{{ pendingStageNote }}
</view>
</view>
</view>
<!-- 流程节点 (微动效进度条) -->
<view class="process-track tn-margin-top-xl">
<view class="track-item active">
<view class="track-dot"><text class="tn-icon-success"></text></view>
<text class="track-text">{{ t('submitBookingStep') }}</text>
</view>
<view class="track-line active-line"></view>
<view class="track-item" :class="isContractSigningStep ? 'active' : 'current'">
<view class="track-dot">
<view v-if="!isContractSigningStep" class="dot-pulse"></view>
<view v-if="!isContractSigningStep" class="dot-inner"></view>
<text v-if="isContractSigningStep" class="tn-icon-success"></text>
</view>
<text class="track-text">{{ t('reviewStep') }}</text>
</view>
<view class="track-line" :class="{ 'active-line': isContractSigningStep }"></view>
<view class="track-item" :class="{ active: hasSignedContract, current: isContractSigningStep && !hasSignedContract }">
<view class="track-dot">
<view v-if="isContractSigningStep && !hasSignedContract" class="dot-pulse"></view>
<view v-if="isContractSigningStep && !hasSignedContract" class="dot-inner"></view>
<text v-if="hasSignedContract" class="tn-icon-success"></text>
</view>
<text class="track-text">{{ t('contractStep') }}</text>
</view>
<view class="track-line" :class="{ 'active-line': hasSignedContract }"></view>
<view class="track-item">
<view class="track-dot"></view>
<text class="track-text">{{ t('checkinStep') }}</text>
</view>
</view>
<!-- 底部操作按钮 -->
<view class="action-btn-group action-btn-group--single tn-margin-top-xxl tn-flex tn-flex-row-between">
<view class="pending-action-btn pending-action-btn--primary" @click="handlePendingPrimaryAction">
<text class="tn-text-bold">{{ pendingPrimaryActionText }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 已入住状态下的服务大厅内容 -->
<view v-else class="animation-fade-in tn-padding-bottom">
<view class="tn-margin-top tn-margin-left tn-margin-right">
<view class="tn-text-bold tn-text tn-color-black tn-margin-bottom">{{ t('stayService') }}</view>
<view class="tn-flex tn-flex-row-between">
<view
v-for="item in stayActions"
:key="item.key"
class="stay-action-card"
@click="openServicePage(item)"
>
<view class="stay-action-icon"><text :class="item.icon" class="stay-action-icon-symbol"></text></view>
<view class="stay-action-title tn-margin-top-sm">{{ pickItemText(item, 'label', 'labelEn') }}</view>
</view>
</view>
</view>
<!-- 融合房卡与智能控制的新卡片 -->
<view class="integrated-room-card tn-margin-left tn-margin-right tn-margin-top">
<!-- 房卡背景 -->
<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>{{ t('activeStatus') }}
</view>
</view>
<view class="irc-room-title clamp-text-1 tn-margin-top-xs">{{ roomTitleText }}</view>
<view class="irc-lease tn-margin-top-sm">{{ t('leaseTerm') }}:{{ 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">
{{ t('smartControl') }} <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>
{{ t('addSmartDevice') }}
</view>
</scroll-view>
</view>
</view>
</view>
<view class="tn-margin-top tn-margin-left tn-margin-right">
<view class="tn-flex tn-flex-row-between tn-flex-col-bottom tn-margin-bottom">
<view class="tn-text-bold tn-text tn-color-black">{{ t('publicService') }}</view>
<view class="tn-text-sm tn-color-gray">{{ t('viewAll') }} <text class="tn-icon-right section-arrow-icon"></text></view>
</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">{{ t('myBill') }}</view>
<view class="status-tag tn-text-xs" :class="unpaidBillCount ? 'tag-busy' : 'tag-free'">{{ unpaidBillCount ? t('unpaid') : t('paidOff') }}</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 ? t('payBill') : t('viewBill') }}</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">{{ t('billType') }}</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">{{ t('billCount') }}</text>
<text class="tn-text-bold tn-text-sm tn-margin-top-xs tn-color-black">{{ unpaidBillCount }} {{ t('billUnit') }}</text>
</view>
<view class="tn-flex tn-flex-direction-column">
<text class="tn-color-gray tn-text-xs">{{ t('lastCreateTime') }}</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">{{ t('butler') }}</view>
<view class="butler-card-new__inner">
<view v-if="areaManagers.length" class="butler-card-new__list">
<view
v-for="(manager, index) in areaManagers"
:key="manager.userCode || manager.userName || manager.name || index"
class="butler-member-new"
>
<view class="butler-member-new__identity">
<view class="butler-avatar-new">
<image v-if="getManagerAvatar(manager)" :src="getManagerAvatar(manager)" mode="aspectFill"></image>
<view v-else class="butler-avatar-new-fallback tn-flex tn-flex-col-center tn-flex-row-center">
<text class="butler-avatar-new-fallback-text">{{ getManagerInitial(manager) }}</text>
</view>
<view class="online-dot"></view>
</view>
<view class="butler-member-new__info">
<view class="butler-member-new__name">
{{ getManagerName(manager) }}
<text v-if="index === 0" class="butler-member-new__tag">{{ t('primaryButlerTag') }}</text>
</view>
<view class="butler-member-new__phone">{{ getManagerPhone(manager) || t('noContactPhone') }}</view>
</view>
</view>
<view
class="butler-member-new__action"
:class="{ 'disabled': !getManagerPhone(manager) }"
@click.stop="handleAreaManagerCall(manager)"
>
<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-service-fill" style="font-size: 32rpx; color: #fff;"></text>
</view>
</view>
<view class="butler-member-new__info">
<view class="butler-member-new__name">{{ t('butler') }}</view>
<view class="butler-member-new__phone">{{ isAreaManagerLoading ? t('loadingButlerInfo') : t('noButlerInfo') }}</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { accountQueryBills, facilityManagers, gatewayRoomDeviceList, orderSearchedDetail } from '../../../api/index.js'
import { getSessionSnapshot, saveMyRoomData } from '../../../utils/app-state'
import { ACCOUNT_BILL_PAYMENT_STATUS, ACCOUNT_BILL_STATUS, fetchEnums, getAccountReferenceTypeLabel, getApplicationDetailStatusLabel } from '../../../utils/enum-store'
import { getBookingStatusText } from '../../../utils/flow'
import { normalizeBookingDetail, parseOrderDetailResponse } from '../../../utils/booking-helper'
import { normalizeSmartDeviceList } from '../../../utils/smart-device'
import { syncLoginStayState } from '../../../utils/session-sync'
import { compactEnglishText } from '../../../utils/select-room-normalizers'
import { addFilePrefix } from '../../../utils/common'
const DEFAULT_SMART_BRAND_ID = 'Tuya'
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 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 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,
isPendingStatusRefreshing: false,
areaManager: null,
areaManagers: [],
isAreaManagerLoading: false,
unpaidBillList: [],
smartDevices: [],
isSmartDevicesLoading: false,
smartDeviceError: '',
stayActions: [
{ key: 'change-room', label: '换房申请', labelEn: 'Change Room', icon: 'tn-icon-home-smile-fill', title: '换房申请', titleEn: 'Change Room Request', titleEnShort: 'Room Change', desc: '提交换房原因、期望房型和管家审核说明。', descEn: 'Submit your reason, expected room type, and butler review notes.' },
{ key: 'renew-stay', label: '续住申请', labelEn: 'Renew Stay', icon: 'tn-icon-calendar-fill', title: '续住申请', titleEn: 'Renew Stay Request', titleEnShort: 'Renew Stay', desc: '提交续住时长、续住原因和新的租期需求。', descEn: 'Submit extension duration, reason, and new lease request.' },
{ key: 'checkout', label: '退房申请', labelEn: 'Check Out', icon: 'tn-icon-home-in-fill', title: '退房申请', titleEn: 'Checkout Request', titleEnShort: 'Check Out', desc: '提交退房时间、离场说明和验房申请。', descEn: 'Submit checkout time, leave notes, and inspection request.' }
],
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: '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.'
}
],
i18n: {
zh: {
title: '我的入住',
bookingInfo: '预定信息',
submitBookingStep: '提交预定',
reviewStep: '管家审核',
contractStep: '签订合同',
checkinStep: '办理入住',
notCheckedInTitle: '您还没有在租房间',
notCheckedInDesc: '长租公寓为海外青年提供高端社区服务\n请先前往选房大厅挑选心仪套间',
bookingPendingTitle: '您的预订正在审核中',
bookingPendingDesc: '定金已支付,预定单正在审批流中。审批通过后将分配管家并等待分房号。',
goSelectRoom: '去选房',
stayService: '住中申请',
butler: '专属社区管家',
online: '当前在线 · 响应时间 < 5分钟',
callNow: '拨打',
publicService: '社区设施预约',
viewAll: '全部',
leaseTerm: '租期',
activeStatus: '使用中',
myBill: '本月账单',
unpaid: '待支付',
paidOff: '已结清',
payBill: '去支付',
viewBill: '查看账单',
smartControl: '全屋智能',
addSmartDevice: '添加智能设备',
smartAvailableAfterCheckin: '入住后可使用智能设备',
syncingSmartDevices: '正在同步智能设备...',
smartDeviceConnected: '已接入 {count} 台智能设备',
smartDeviceSyncFailed: '智能设备同步失败',
smartDeviceEmpty: '当前房间暂无智能设备',
roomDeviceUnconfigured: '房间智能设备未配置',
smartDeviceLoadFailed: '智能设备加载失败',
primaryButlerTag: '主责',
noContactPhone: '暂无联系电话',
loadingButlerInfo: '正在同步管家信息...',
noButlerInfo: '暂未获取到管家信息',
managerContactSummary: '当前区域共有 {count} 位管家可联系',
contactPhonePrefix: '联系电话 ',
loadingShort: '同步中',
onlineSingle: '1人在线',
pendingSync: '待同步',
billEmpty: '暂无待支付账单',
billRecordFallback: '账单记录',
pendingContractFound: '发现待签合同',
statusUpdated: '状态已刷新',
refreshFailed: '刷新失败,请稍后重试',
managerWechatCopied: '管家微信已复制',
noManagerWechat: '暂无管家微信',
loadingManagerInfo: '正在加载管家信息',
managerInitialFallback: '管',
billCount: '账单数量',
billUnit: '笔',
billType: '账单类型',
lastCreateTime: '最近生成',
communityEvent: '近期活动',
joinEvent: '立即报名',
signContractNow: '请尽快签订合同',
signContractDesc: '系统已生成待签合同,当前流程已进入签订合同阶段,完成签约后即可继续后续入住流程。',
contractSignedTitle: '合同已签署,等待办理入住',
contractSignedDesc: '您的住宿合同已经完成签署,管家会尽快为您安排办理入住流程。',
contractPendingNote: '检测到待签合同后,流程将停留在签订合同节点,请先完成签名。',
contractSignedNote: '合同签署完成,请等待管家通知办理入住。',
bookingPendingNote: '订单已提交,管家审核通过后会同步更多入住信息。',
contractRoom: '合同房型',
bookedRoomType: '预定房型',
refreshing: '刷新中...',
signContract: '去签约',
refreshStatus: '刷新状态',
pendingAssignment: '待审核分配',
currentStay: '当前入住房间',
controllable: '可控制',
deviceFallback: '设备 {index}',
bedPrefix: '床位 {label}',
multiButlerName: '{name} 等{count}位管家',
onDutyCount: '{count}人值班',
submitRequest: '提交申请',
completeFormBeforeSubmit: '请完善表单信息后提交。'
},
en: {
title: 'My Stay',
bookingInfo: 'Booking Info',
submitBookingStep: 'Submit',
reviewStep: 'Review',
contractStep: 'Contract',
checkinStep: 'Check-in',
notCheckedInTitle: 'No Leased Room Yet',
notCheckedInDesc: 'We provide premium co-living services.\nPlease explore and find your suite first.',
bookingPendingTitle: 'Your booking is under review',
bookingPendingDesc: 'Deposit has been paid. The booking is waiting for approval and butler assignment.',
goSelectRoom: 'Explore Rooms',
stayService: 'Service Request',
butler: 'Community Butler',
online: 'Online · Response < 5 mins',
callNow: 'Call',
publicService: 'Facility Booking',
viewAll: 'View All',
leaseTerm: 'Lease',
activeStatus: 'Active',
myBill: 'Monthly Bill',
unpaid: 'Unpaid',
paidOff: 'Paid Off',
payBill: 'Pay Now',
viewBill: 'View Bills',
smartControl: 'Smart Control',
addSmartDevice: 'Add Smart Device',
smartAvailableAfterCheckin: 'Smart devices are available after check-in',
syncingSmartDevices: 'Syncing smart devices...',
smartDeviceConnected: '{count} smart devices connected',
smartDeviceSyncFailed: 'Smart device sync failed',
smartDeviceEmpty: 'No smart devices in this room',
roomDeviceUnconfigured: 'Smart devices are not configured for this room',
smartDeviceLoadFailed: 'Failed to load smart devices',
primaryButlerTag: 'Lead',
noContactPhone: 'No contact number',
loadingButlerInfo: 'Syncing butler info...',
noButlerInfo: 'No butler information yet',
managerContactSummary: '{count} butlers are available in this area',
contactPhonePrefix: 'Phone ',
loadingShort: 'Syncing',
onlineSingle: '1 online',
pendingSync: 'Pending',
billEmpty: 'No unpaid bills',
billRecordFallback: 'Bill',
pendingContractFound: 'Pending contract found',
statusUpdated: 'Status updated',
refreshFailed: 'Refresh failed, please try again later',
managerWechatCopied: 'Butler Wechat copied',
noManagerWechat: 'No butler Wechat',
loadingManagerInfo: 'Loading butler info',
managerInitialFallback: 'B',
billCount: 'Bills',
billUnit: '',
billType: 'Type',
lastCreateTime: 'Latest',
communityEvent: 'Community Events',
joinEvent: 'Join Now',
signContractNow: 'Please sign your contract',
signContractDesc: 'A contract is ready for signature. You are now at the contract signing step.',
contractSignedTitle: 'Contract Signed',
contractSignedDesc: 'Your stay contract has been signed. The butler will arrange check-in for you shortly.',
contractPendingNote: 'The flow stays at the contract step until the contract is signed.',
contractSignedNote: 'Contract signed successfully. Awaiting check-in arrangement.',
bookingPendingNote: 'Your booking has been submitted. More stay details will appear after review.',
contractRoom: 'Contract Room',
bookedRoomType: 'Room Type',
refreshing: 'Refreshing...',
signContract: 'Sign Contract',
refreshStatus: 'Refresh Status',
pendingAssignment: 'Pending assignment',
currentStay: 'Current Stay',
controllable: 'Controllable',
deviceFallback: 'Device {index}',
bedPrefix: 'Bed {label}',
multiButlerName: '{name} +{count}',
onDutyCount: '{count} on duty',
submitRequest: 'Submit Request',
completeFormBeforeSubmit: 'Please complete the form before submitting.'
}
}
}
},
computed: {
language() {
return this.$getLanguage()
},
bookingStatusText() {
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 : []
},
isContractSigningStep() {
if (!this.hasCheckedIn && this.pendingContracts.length > 0) {
return true
}
return false
},
hasSignedContract() {
if (!this.hasCheckedIn && this.currentBooking && this.currentBooking.contractStatus === 1) {
return true
}
return false
},
pendingStageTitle() {
if (this.hasSignedContract) {
return this.t('contractSignedTitle')
}
if (this.isContractSigningStep) {
return this.t('signContractNow')
}
return this.t('bookingPendingTitle')
},
pendingStageDesc() {
if (this.hasSignedContract) {
return this.t('contractSignedDesc')
}
if (this.isContractSigningStep) {
return this.t('signContractDesc')
}
return this.t('bookingPendingDesc')
},
pendingInfoLabel() {
return this.hasSignedContract || this.isContractSigningStep ? this.t('contractRoom') : this.t('bookedRoomType')
},
pendingStageNote() {
if (this.hasSignedContract) {
return this.t('contractSignedNote')
}
if (this.isContractSigningStep) {
return this.t('contractPendingNote')
}
return this.t('bookingPendingNote')
},
pendingPrimaryActionText() {
if (this.isPendingStatusRefreshing) {
return this.t('refreshing')
}
if (this.isContractSigningStep && !this.hasSignedContract) {
return this.t('signContract')
}
return this.t('refreshStatus')
},
bookingRoomText() {
if (!this.currentBooking) {
return '--'
}
return this.currentBooking.roomName
|| this.currentBooking.roomTypeName
|| this.currentBooking.roomTypeNameEn
|| this.t('pendingAssignment')
},
roomCardCover() {
return this.myRoomData && this.myRoomData.image ? this.myRoomData.image : ''
},
roomTitleText() {
if (!this.myRoomData) {
return this.t('currentStay')
}
return this.language === 'zh'
? (pickReadableValue(this.myRoomData.titleZh, this.myRoomData.roomTypeName, this.myRoomData.roomName, this.myRoomData.titleEn) || this.t('currentStay'))
: (pickReadableValue(this.myRoomData.titleEn, this.myRoomData.roomTypeNameEn, this.myRoomData.roomTypeName, this.myRoomData.titleZh) || this.t('currentStay'))
},
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.t('billEmpty')
}
const firstBill = this.unpaidBillList[0] || {}
return firstBill.referenceTypeDesc || getAccountReferenceTypeLabel(firstBill.referenceType, this.t('billRecordFallback'))
},
latestBillTimeText() {
if (!this.unpaidBillList.length || !this.unpaidBillList[0].createTime) {
return '--'
}
return this.formatDateTime(this.unpaidBillList[0].createTime)
},
smartSummaryText() {
if (!this.hasCheckedIn) {
return this.t('smartAvailableAfterCheckin')
}
if (this.isSmartDevicesLoading) {
return this.t('syncingSmartDevices')
}
if (this.smartDevices.length) {
return this.t('smartDeviceConnected').replace('{count}', this.smartDevices.length)
}
if (this.smartDeviceError) {
return this.t('smartDeviceSyncFailed')
}
return this.t('smartDeviceEmpty')
},
smartQuickDeviceCards() {
return this.smartDevices.map((device, index) => ({
...device,
key: device.renderKey || device.deviceId || device.deviceNo || `smart-device-${index}`,
name: device.deviceName || this.t('deviceFallback').replace('{index}', index + 1),
stateText: device.stateText || this.t('controllable')
}))
},
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.t('bedPrefix').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.t('butler')
}
if (this.areaManagers.length > 1) {
return this.t('multiButlerName')
.replace('{name}', managerName)
.replace('{count}', this.language === 'zh' ? this.areaManagers.length : this.areaManagers.length - 1)
}
return managerName
},
butlerSubtitle() {
if (this.isAreaManagerLoading) {
return this.t('loadingButlerInfo')
}
if (this.areaManagers.length > 1) {
return this.t('managerContactSummary').replace('{count}', this.areaManagers.length)
}
const manager = this.primaryAreaManager || {}
const phone = this.getManagerPhone(manager)
if (phone) {
return `${this.t('contactPhonePrefix')}${phone}`
}
return this.t('online')
},
butlerCountText() {
if (this.isAreaManagerLoading) {
return this.t('loadingShort')
}
if (this.areaManagers.length > 1) {
return this.t('onDutyCount').replace('{count}', this.areaManagers.length)
}
if (this.areaManagers.length === 1) {
return this.t('onlineSingle')
}
return this.t('pendingSync')
},
butlerPhoneNumber() {
const manager = this.primaryAreaManager || {}
return this.getManagerPhone(manager)
}
},
mounted() {
fetchEnums(false, this.language)
this.checkLocalStatus()
this.loadUnpaidBills()
},
methods: {
t(key, params) {
return this.$tm('pages.index.component.StayServiceTab', key, params)
},
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)
},
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
},
getCurrentOrderNo() {
const storeCurrentOrder = this.getStoreCurrentOrder()
if (storeCurrentOrder && storeCurrentOrder.orderNo) {
return String(storeCurrentOrder.orderNo).trim()
}
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.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.t('roomDeviceUnconfigured')
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.t('smartDeviceLoadFailed')
})
.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
},
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.myRoomData = null
return
}
this.currentBooking = storeUser.currentBooking || session.currentBooking || null
if (this.getStoreCheckinStatus() || session.hasCheckedIn) {
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.isContractSigningStep && !this.hasSignedContract) {
this.goSignContract()
return
}
this.refreshPendingStatus()
},
goSignContract() {
const contract = this.pendingContracts[0]
if (!contract) {
this.refreshPendingStatus()
return
}
uni.navigateTo({
url: `/pkgService/contract_sign/contract_sign?no=${encodeURIComponent(contract.no || contract.contractNo || '')}&orderNo=${encodeURIComponent(contract.orderNo || '')}`
})
},
refreshPendingStatus() {
if (this.isPendingStatusRefreshing) {
return
}
let refreshedStayState = {}
this.isPendingStatusRefreshing = true
syncLoginStayState({ force: true })
.then((result = {}) => {
refreshedStayState = result
this.checkLocalStatus()
return this.loadUnpaidBills()
})
.then(() => {
const pendingContracts = Array.isArray(refreshedStayState.pendingContracts)
? refreshedStayState.pendingContracts
: []
this.$emit('refresh-shell-state', {
pendingContracts
})
uni.showToast({
title: pendingContracts.length
? this.t('pendingContractFound')
: this.t('statusUpdated'),
icon: 'success'
})
})
.catch((error) => {
console.error('refresh pending status failed', error)
uni.showToast({
title: this.t('refreshFailed'),
icon: 'none'
})
})
.finally(() => {
this.isPendingStatusRefreshing = false
})
},
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.t('submitRequest')
const titleEn = item.titleEnShort || item.titleEn || item.title || item.nameEn || item.nameZh || this.t('submitRequest')
const descZh = item.desc || item.descEn || this.t('completeFormBeforeSubmit')
const descEn = compactEnglishText(item.descEn || item.desc || '', 68) || item.descEn || item.desc || this.t('completeFormBeforeSubmit')
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.t('submitRequest'))}&titleZh=${encodeURIComponent(titleZh)}&titleEn=${encodeURIComponent(titleEn)}&desc=${encodeURIComponent(desc || this.t('completeFormBeforeSubmit'))}&descZh=${encodeURIComponent(descZh)}&descEn=${encodeURIComponent(descEn)}&payload=${encodeURIComponent(JSON.stringify(payload))}`
},
openServicePage(item) {
if (item.key === 'utility') {
uni.navigateTo({
url: '/pkgService/utility_recharge/utility_recharge'
});
return
}
uni.navigateTo({
url: this.buildServiceSubmitUrl(item)
});
},
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.t('managerInitialFallback')
},
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.t('managerWechatCopied'),
icon: 'success'
})
}
})
return
}
uni.showToast({
title: this.t('noManagerWechat'),
icon: 'none'
})
},
handleAreaManagerCall(manager = {}) {
const phone = this.getManagerPhone(manager)
if (!phone) {
uni.showToast({
title: this.t('noContactPhone'),
icon: 'none'
})
return
}
uni.makePhoneCall({
phoneNumber: String(phone)
})
},
handleButlerCall() {
if (!this.butlerPhoneNumber) {
uni.showToast({
title: this.isAreaManagerLoading ? this.t('loadingManagerInfo') : this.t('noContactPhone'),
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: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
min-height: 100vh;
}
.service-scroll {
height: calc(100vh - 100rpx);
}
.empty-state {
min-height: calc(100vh - 100rpx);
}
/* 无预订卡片 */
.no-booking-card {
position: relative;
border-radius: 32rpx;
overflow: hidden;
box-shadow: 0 16rpx 40rpx rgba($brand-primary, 0.15);
margin-top: 20rpx;
}
.no-booking-bg {
position: absolute;
inset: 0;
background: linear-gradient(135deg, #1f4f8a 0%, #2a5caa 45%, #15345e 100%);
z-index: 1;
}
.no-booking-content {
position: relative;
z-index: 2;
padding: 80rpx 40rpx;
}
.icon-wrap {
width: 120rpx;
height: 120rpx;
border-radius: 60rpx;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
}
.icon-wrap .tn-icon-home-smile {
font-size: 60rpx;
}
.btn-glass {
background: rgba(255, 255, 255, 0.2);
border: 1rpx solid rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
color: #ffffff;
}
/* 审核中卡片 - 重新设计 */
.pending-booking-card {
background: #ffffff;
border-radius: 32rpx;
box-shadow: 0 16rpx 40rpx rgba(43, 142, 255, 0.08);
overflow: hidden;
margin-top: 20rpx;
border: 1rpx solid rgba(255, 255, 255, 0.8);
position: relative;
}
.pending-hero {
height: 220rpx;
position: relative;
background: linear-gradient(135deg, rgba($brand-primary, 0.06) 0%, rgba($brand-primary, 0.12) 100%);
overflow: hidden;
}
.hero-bg-shape {
position: absolute;
top: -100rpx;
right: -50rpx;
width: 300rpx;
height: 300rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba($brand-primary, 0.1) 0%, rgba(255, 255, 255, 0) 70%);
}
.hero-content {
position: absolute;
inset: 0;
z-index: 2;
}
.status-icon-ring {
width: 140rpx;
height: 140rpx;
border-radius: 70rpx;
background: #ffffff;
box-shadow: 0 8rpx 24rpx rgba($brand-primary, 0.12);
border: 8rpx solid rgba(255, 255, 255, 0.6);
backdrop-filter: blur(8px);
}
.status-icon {
font-size: 70rpx;
color: #FFA500; /* 使用琥珀色表示审核中,与规范一致 */
}
.status-text-area {
margin-top: -10rpx;
}
.status-desc {
line-height: 1.6;
padding: 0 20rpx;
}
/* 预定信息卡片拟物化 */
.booking-ticket-card {
background: linear-gradient(180deg, #f8faff 0%, #ffffff 100%);
border-radius: 24rpx;
border: 1rpx solid rgba($brand-primary, 0.08);
box-shadow: inset 0 2rpx 0 rgba(255, 255, 255, 1), 0 8rpx 20rpx rgba(15, 23, 42, 0.03);
margin: 0 10rpx;
}
.ticket-header {
padding: 24rpx 32rpx;
}
.ticket-icon-wrap {
width: 48rpx;
height: 48rpx;
border-radius: 12rpx;
background: rgba($brand-primary, 0.1);
color: $brand-primary;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
}
.ticket-status-badge {
font-size: 22rpx;
color: #FFA500;
background: rgba(255, 165, 0, 0.1);
padding: 6rpx 16rpx;
border-radius: 12rpx;
font-weight: bold;
}
.ticket-divider {
padding: 0 32rpx;
}
.divider-line {
height: 2rpx;
background: repeating-linear-gradient(to right, rgba($brand-primary, 0.15) 0, rgba($brand-primary, 0.15) 8rpx, transparent 8rpx, transparent 16rpx);
}
.ticket-content {
padding: 32rpx;
}
.ticket-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.ticket-label {
color: #64748b;
font-size: 26rpx;
min-width: 120rpx;
}
.ticket-value {
color: #1e293b;
font-size: 28rpx;
flex: 1;
text-align: right;
margin-left: 20rpx;
word-break: break-all;
line-height: 1.4;
}
.ticket-note {
color: #64748b;
font-size: 24rpx;
line-height: 1.6;
}
/* 进度条样式升级 */
.process-track {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 0 10rpx;
}
.track-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
flex: 1;
}
.track-dot {
width: 40rpx;
height: 40rpx;
border-radius: 20rpx;
background: #e2e8f0;
border: 4rpx solid #ffffff;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 20rpx;
position: relative;
}
.track-item.active .track-dot {
background: $brand-primary;
}
/* 呼吸灯效果 */
.track-item.current .track-dot {
background: transparent;
border: none;
box-shadow: none;
}
.dot-pulse {
position: absolute;
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: rgba(#FFA500, 0.4);
animation: pulse 2s infinite;
}
.dot-inner {
position: relative;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #FFA500;
z-index: 2;
box-shadow: 0 0 10rpx rgba(#FFA500, 0.6);
}
@keyframes pulse {
0% { transform: scale(0.8); opacity: 1; }
100% { transform: scale(2); opacity: 0; }
}
.track-text {
font-size: 22rpx;
color: #94a3b8;
margin-top: 16rpx;
white-space: nowrap;
}
.track-item.active .track-text {
color: $brand-primary;
font-weight: bold;
}
.track-item.current .track-text {
color: #FFA500;
font-weight: bold;
}
.track-line {
flex: 1;
height: 4rpx;
background: #e2e8f0;
margin: 18rpx -30rpx 0;
z-index: 1;
border-radius: 2rpx;
}
.track-line.active-line {
background: linear-gradient(90deg, $brand-primary 0%, rgba($brand-primary, 0.3) 100%);
}
/* 操作按钮 */
.action-btn-group {
gap: 24rpx;
}
.action-btn-group--single {
justify-content: center;
}
.pending-action-btn {
flex: 1;
height: 92rpx;
border-radius: 24rpx;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 24rpx;
transition: all 0.3s ease;
font-size: 28rpx;
}
.action-btn-group--single .pending-action-btn {
flex: none;
width: 100%;
}
.pending-action-btn:active {
transform: scale(0.98);
}
.pending-action-btn--secondary {
background: #ffffff;
color: $brand-primary;
border: 2rpx solid rgba($brand-primary, 0.22);
box-shadow: 0 8rpx 18rpx rgba($brand-primary, 0.08);
}
.pending-action-btn--secondary:active {
box-shadow: 0 4rpx 10rpx rgba($brand-primary, 0.06);
}
.pending-action-btn--primary {
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
color: #ffffff;
border: 2rpx solid transparent;
box-shadow: 0 10rpx 24rpx rgba($brand-primary, 0.22);
}
.pending-action-btn--primary:active {
box-shadow: 0 6rpx 14rpx rgba($brand-primary, 0.16);
}
.stay-action-card {
width: 31%;
background: #ffffff;
border-radius: 24rpx;
padding: 24rpx 12rpx;
text-align: center;
box-shadow: 0 10rpx 26rpx rgba(0, 0, 0, 0.03);
}
.stay-action-icon {
width: 72rpx;
height: 72rpx;
margin: 0 auto;
border-radius: 50%;
background: linear-gradient(135deg, $icon-surface-brand-soft 0%, rgba($brand-primary, 0.04) 100%);
color: $icon-tone-brand-deep;
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx;
box-shadow: inset 0 0 0 1rpx rgba($brand-primary, 0.08);
}
.stay-action-icon-symbol {
font-size: 32rpx;
line-height: 1;
}
.stay-action-title {
font-size: 24rpx;
font-weight: bold;
color: #222222;
line-height: 1.3;
text-align: center;
word-break: break-word;
}
.half-btn {
width: 48%;
height: 92rpx;
}
/* --- 融合版房卡 (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__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;
}
.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;
}
</style>