feat: 重构核心租住流程页面与交互体验

统一注册、看房、申请与入住等核心页面的视觉和流程衔接,补齐支付、签约与状态流转闭环,提升移动端操作连贯性。
This commit is contained in:
liangjian
2026-07-29 09:05:38 +08:00
parent da6bdc6a02
commit 1111ac148b
34 changed files with 8734 additions and 2801 deletions

View File

@@ -237,6 +237,21 @@ export function applicationFormMePage(params = {}, language) {
}) })
} }
export function applicationFormDetail(detailCode = '', language) {
const normalizedDetailCode = String(detailCode || '').trim()
if (!normalizedDetailCode) {
return Promise.reject(new Error('申请明细编码不能为空'))
}
return request({
url: `/applicationform/detail/${encodeURIComponent(normalizedDetailCode)}`,
method: 'GET',
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
// --- 工单 --- // --- 工单 ---
export function ticketOrderPage(params = {}, language) { export function ticketOrderPage(params = {}, language) {
return request({ return request({
@@ -526,6 +541,24 @@ export function accountQueryBill(params = {}, language) {
}) })
} }
export function accountQueryBillPreview(params = {}, language) {
const query = params || {}
const orderNo = String(query.orderNo || query.OrderNo || '').trim()
if (!orderNo) {
return Promise.reject(new Error('订单号不能为空'))
}
return request({
url: '/accountquery/bill-preview',
method: 'GET',
params: {
OrderNo: orderNo
},
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function paymentBillPay(data = {}, language) { export function paymentBillPay(data = {}, language) {
const payload = data || {} const payload = data || {}
return request({ return request({
@@ -576,9 +609,13 @@ export function paymentStatusByBillNo(params = {}, language) {
} }
export function userDetailCancel(data = {}, language) { export function userDetailCancel(data = {}, language) {
const payload = data || {} const source = data || {}
const payload = {
detailCode: String(source.detailCode || source.code || source.referenceTypeNo || '').trim(),
remark: source.remark == null ? '' : String(source.remark)
}
return request({ return request({
url: '/ApplicationForm/user/detail/cancel', url: '/applicationform/user/detail/cancel',
method: 'POST', method: 'POST',
data: payload, data: payload,
header: { header: {

View File

@@ -2,8 +2,8 @@ export const config = {
// 根据不同环境维护接口基准地址 // 根据不同环境维护接口基准地址
// baseURL: 'http://192.168.199.182:5003/api', // baseURL: 'http://192.168.199.182:5003/api',
// baseURL: 'http://192.168.199.124:5111/admin/api', // baseURL: 'http://192.168.199.124:5111/admin/api',
// baseURL: 'https://api.xhct.yaens.com/admin/api', baseURL: 'https://api.xhct.yaens.com/admin/api',
baseURL: 'https://apartment.lvjuwuye.cn/admin/api', // baseURL: 'https://apartment.lvjuwuye.cn/admin/api',
filePrefix: 'https://apartment.lvjuwuye.cn/p' // filePrefix: 'https://apartment.lvjuwuye.cn/p'
// filePrefix: 'https://api.xhct.yaens.com/p' filePrefix: 'https://api.xhct.yaens.com/p'
} }

View File

@@ -91,6 +91,7 @@ const messages = {
apartmentLabel: "Apartment", apartmentLabel: "Apartment",
cancelOrder: "Cancel Order", cancelOrder: "Cancel Order",
cancelOrderContent: "This will close the current unpaid deposit order. Continue?", cancelOrderContent: "This will close the current unpaid deposit order. Continue?",
cancelViewingContent: "This will close the current viewing request. Continue?",
cancelOrderFailed: "Failed to cancel order", cancelOrderFailed: "Failed to cancel order",
cancelOrderLoading: "Canceling...", cancelOrderLoading: "Canceling...",
cancelOrderSuccess: "Order canceled", cancelOrderSuccess: "Order canceled",
@@ -158,6 +159,7 @@ const messages = {
stayApplicationTitle: "Stay Application", stayApplicationTitle: "Stay Application",
submittedAtLabel: "Submitted At", submittedAtLabel: "Submitted At",
toolbarSummary: "{total} applications", toolbarSummary: "{total} applications",
viewingNoDepositToast: "Viewing requests do not require a deposit",
viewingApplicationTitle: "Viewing Request", viewingApplicationTitle: "Viewing Request",
viewingTimeLabel: "Viewing Time" viewingTimeLabel: "Viewing Time"
}, },
@@ -305,6 +307,36 @@ const messages = {
refreshStatus: "Refresh Status", refreshStatus: "Refresh Status",
refreshing: "Refreshing...", refreshing: "Refreshing...",
reviewStep: "Review", reviewStep: "Review",
applicationPendingTitle: "Application submitted, waiting for review",
applicationPendingDesc: "Your application has been submitted. It will enter the room inspection step after butler review.",
applicationPendingNote: "Application is under review. Please wait for butler notification.",
applicationPendingPaymentTitle: "Please pay the deposit",
applicationPendingPaymentDesc: "Your stay application has been submitted. Please pay the deposit to confirm your booking.",
applicationPendingPaymentNote: "After the deposit is paid, the manager will review your application and arrange the next steps.",
viewingPendingTitle: "Viewing request submitted, waiting for confirmation",
viewingPendingDesc: "Your viewing request has been submitted. Please wait for the manager to confirm the viewing schedule.",
viewingPendingNote: "No deposit is required for viewing requests. Please watch for the viewing time and follow-up notice.",
viewingNoDepositToast: "Viewing requests do not require a deposit",
inspectionPendingTitle: "Please schedule and complete room inspection",
inspectionPendingDesc: "The flow has entered the room inspection step. Please complete the inspection on time.",
inspectionPendingNote: "After the room inspection is completed, the system will automatically proceed to bill payment.",
billPendingTitle: "Bill generated, please pay as soon as possible",
billPendingDesc: "The flow has entered the bill payment step. Complete payment to proceed to contract signing.",
billPendingNote: "Please complete the bill payment within the specified time. Overdue payment may affect your room selection.",
signContractDoneTitle: "Contract signed, waiting for check-in",
signContractDoneNote: "Contract signing is complete. Please wait for butler notification for check-in.",
applicationNo: "Application No.",
billNo: "Bill No.",
applicationStep: "Application",
inspectionStep: "Inspection",
billStep: "Bill",
contractShort: "Contract",
checkinShort: "Check-in",
applying: "Applying",
inspecting: "Inspecting",
pendingPayment: "Pending Payment",
pendingSignature: "Pending Signature",
pendingCheckin: "Pending Check-in",
roomDeviceUnconfigured: "Smart devices are not configured for this room", roomDeviceUnconfigured: "Smart devices are not configured for this room",
signContract: "Sign Contract", signContract: "Sign Contract",
signContractDesc: "A contract is ready for signature. You are now at the contract signing step.", signContractDesc: "A contract is ready for signature. You are now at the contract signing step.",
@@ -323,7 +355,11 @@ const messages = {
title: "My Stay", title: "My Stay",
unpaid: "Unpaid", unpaid: "Unpaid",
viewAll: "View All", viewAll: "View All",
viewBill: "View Bills" viewBill: "View Bills",
currentTaskTitle: "Current Task",
flowProgressTitle: "Flow Progress",
goInspection: "Book Inspection",
viewDetail: "View Details"
} }
}, },
index: { index: {
@@ -544,7 +580,9 @@ const messages = {
}, },
book_viewing: { book_viewing: {
book_viewing: { book_viewing: {
availableTimeLabel: "Available Slots",
backTitle: "Back", backTitle: "Back",
bookingDateLabel: "Booking Date",
budgetLabel: "Budget (CNY/month)", budgetLabel: "Budget (CNY/month)",
budgetPlaceholder: "Enter your expected budget", budgetPlaceholder: "Enter your expected budget",
defaultInfoDesc: "A dedicated manager will arrange a one-on-one viewing for you", defaultInfoDesc: "A dedicated manager will arrange a one-on-one viewing for you",
@@ -554,7 +592,12 @@ const messages = {
duration4: "10-12 months", duration4: "10-12 months",
duration5: "Over 1 year", duration5: "Over 1 year",
durationLabel: "Expected Stay Duration", durationLabel: "Expected Stay Duration",
emptyRoomTypesDesc: "No room types available, please try again later",
loadFailed: "Failed to load room types",
mapSelectFailed: "Map selection failed, please enter manually",
navTitle: "On-site Viewing", navTitle: "On-site Viewing",
popupSubtitle: "Select your preferred room type and area",
priceNegotiable: "Price negotiable",
roomTypeLabel: "Preferred Room Type", roomTypeLabel: "Preferred Room Type",
remarkArea: "Preferred Area: {area}", remarkArea: "Preferred Area: {area}",
remarkBudget: "Budget: {budget} CNY/month", remarkBudget: "Budget: {budget} CNY/month",
@@ -569,11 +612,13 @@ const messages = {
rentalNeeds: "Rental Needs", rentalNeeds: "Rental Needs",
rentalNeedsDesc: "Help the manager recommend a suitable room", rentalNeedsDesc: "Help the manager recommend a suitable room",
selectDurationRequired: "Please select the expected stay duration", selectDurationRequired: "Please select the expected stay duration",
selectedTimeLabel: "Selected Time",
selectTimeRequired: "Please select a viewing time slot", selectTimeRequired: "Please select a viewing time slot",
selectViewingTime: "Select Viewing Time", selectViewingTime: "Select Viewing Time",
selectViewingTimeDesc: "Choose a time slot that works for you", selectViewingTimeDesc: "Choose a time slot that works for you",
submitBooking: "Submit Request", submitBooking: "Submit Request",
submitFailed: "Failed to submit the booking", submitFailed: "Failed to submit the booking",
submitHint: "A dedicated manager will contact you shortly after submission",
submitSuccessContent: "Your viewing request has been submitted.\nThe system will forward it to the authorized manager of the selected area. After the manager confirms the viewing, the order can be completed directly or converted into a booking for payment once a room is selected.", submitSuccessContent: "Your viewing request has been submitted.\nThe system will forward it to the authorized manager of the selected area. After the manager confirms the viewing, the order can be completed directly or converted into a booking for payment once a room is selected.",
submitSuccessTitle: "Booking Submitted", submitSuccessTitle: "Booking Submitted",
submitting: "Submitting...", submitting: "Submitting...",
@@ -711,6 +756,7 @@ const messages = {
}, },
store_detail: { store_detail: {
store_detail: { store_detail: {
addressLabel: "Apartment Address",
apartmentName: "Community", apartmentName: "Community",
collapse: "Collapse", collapse: "Collapse",
contactManager: "Contact Butler", contactManager: "Contact Butler",
@@ -1250,9 +1296,12 @@ const messages = {
submit_request: { submit_request: {
components: { components: {
BuildingSelector: { BuildingSelector: {
emptyDesc: "No available buildings, please try again later",
emptyText: "No buildings available", emptyText: "No buildings available",
loadFailed: "Failed to load buildings", loadFailed: "Failed to load buildings",
loadFailedShort: "Failed to load buildings",
loadingText: "Loading buildings...", loadingText: "Loading buildings...",
popupSubtitle: "Select your preferred building",
popupTitle: "Select Building", popupTitle: "Select Building",
selectAreaFirst: "Please select an area first", selectAreaFirst: "Please select an area first",
selectBuilding: "Select building" selectBuilding: "Select building"
@@ -1266,9 +1315,12 @@ const messages = {
}, },
FloorSelector: { FloorSelector: {
clearSelection: "Not specified", clearSelection: "Not specified",
emptyDesc: "No available floors, please try again later",
emptyText: "No floors available", emptyText: "No floors available",
loadFailed: "Failed to load floor list", loadFailed: "Failed to load floor list",
loadFailedShort: "Failed to load floors",
loadingText: "Loading floors...", loadingText: "Loading floors...",
popupSubtitle: "Select your preferred floor",
popupTitle: "Select Floor", popupTitle: "Select Floor",
selectFloor: "Select floor" selectFloor: "Select floor"
}, },
@@ -1576,6 +1628,7 @@ const messages = {
apartmentLabel: "所属公寓", apartmentLabel: "所属公寓",
cancelOrder: "取消订单", cancelOrder: "取消订单",
cancelOrderContent: "取消后将关闭当前未支付的定金订单,是否继续?", cancelOrderContent: "取消后将关闭当前未支付的定金订单,是否继续?",
cancelViewingContent: "取消后将关闭当前预约看房申请,是否继续?",
cancelOrderFailed: "取消订单失败", cancelOrderFailed: "取消订单失败",
cancelOrderLoading: "取消中...", cancelOrderLoading: "取消中...",
cancelOrderSuccess: "订单已取消", cancelOrderSuccess: "订单已取消",
@@ -1643,6 +1696,7 @@ const messages = {
stayApplicationTitle: "入住申请", stayApplicationTitle: "入住申请",
submittedAtLabel: "提交时间", submittedAtLabel: "提交时间",
toolbarSummary: "共 {total} 条申请", toolbarSummary: "共 {total} 条申请",
viewingNoDepositToast: "预约看房申请无需支付定金",
viewingApplicationTitle: "预约看房申请", viewingApplicationTitle: "预约看房申请",
viewingTimeLabel: "预约时间" viewingTimeLabel: "预约时间"
}, },
@@ -1790,6 +1844,36 @@ const messages = {
refreshStatus: "刷新状态", refreshStatus: "刷新状态",
refreshing: "刷新中...", refreshing: "刷新中...",
reviewStep: "管家审核", reviewStep: "管家审核",
applicationPendingTitle: "申请单已提交,等待审核",
applicationPendingDesc: "您的申请单已提交,管家审核通过后将进入验房环节。",
applicationPendingNote: "申请单审核中,请耐心等待管家通知。",
applicationPendingPaymentTitle: "请支付定金",
applicationPendingPaymentDesc: "您的入住申请已提交,请先支付定金以确认预订。",
applicationPendingPaymentNote: "支付定金后,管家会尽快审核您的申请并安排后续入住流程。",
viewingPendingTitle: "预约看房已提交,等待确认",
viewingPendingDesc: "您的预约看房申请已提交,请等待管家与您确认看房安排。",
viewingPendingNote: "预约看房无需支付定金,请留意看房时间与后续通知。",
viewingNoDepositToast: "预约看房申请无需支付定金",
inspectionPendingTitle: "请预约并完成验房",
inspectionPendingDesc: "当前流程已进入验房阶段,请按预约时间完成房间验收。",
inspectionPendingNote: "验房完成后,系统将自动进入账单支付环节。",
billPendingTitle: "账单已生成,请尽快支付",
billPendingDesc: "当前流程已进入账单支付阶段,完成支付后即可进入合同签署环节。",
billPendingNote: "请在规定时间内完成账单支付,逾期可能影响选房资格。",
signContractDoneTitle: "合同已签署,等待办理入住",
signContractDoneNote: "合同签署完成,请等待管家通知办理入住。",
applicationNo: "申请编号",
billNo: "账单编号",
applicationStep: "申请单",
inspectionStep: "验房",
billStep: "账单",
contractShort: "合同",
checkinShort: "入住",
applying: "申请中",
inspecting: "验房中",
pendingPayment: "待支付",
pendingSignature: "待签约",
pendingCheckin: "待入住",
roomDeviceUnconfigured: "房间智能设备未配置", roomDeviceUnconfigured: "房间智能设备未配置",
signContract: "去签约", signContract: "去签约",
signContractDesc: "系统已生成待签合同,当前流程已进入签订合同阶段,完成签约后即可继续后续入住流程。", signContractDesc: "系统已生成待签合同,当前流程已进入签订合同阶段,完成签约后即可继续后续入住流程。",
@@ -1808,7 +1892,12 @@ const messages = {
title: "我的入住", title: "我的入住",
unpaid: "待支付", unpaid: "待支付",
viewAll: "全部", viewAll: "全部",
viewBill: "查看账单" viewBill: "查看账单",
currentTaskTitle: "当前任务",
currentStepTag: "当前",
flowProgressTitle: "流程进度",
goInspection: "去验房",
viewDetail: "查看详情"
} }
}, },
index: { index: {
@@ -2029,7 +2118,9 @@ const messages = {
}, },
book_viewing: { book_viewing: {
book_viewing: { book_viewing: {
availableTimeLabel: "可约时段",
backTitle: "返回", backTitle: "返回",
bookingDateLabel: "预约日期",
budgetLabel: "租金预算 (元/月)", budgetLabel: "租金预算 (元/月)",
budgetPlaceholder: "请输入您的心理价位", budgetPlaceholder: "请输入您的心理价位",
defaultInfoDesc: "专属管家为您提供一对一带看服务", defaultInfoDesc: "专属管家为您提供一对一带看服务",
@@ -2039,7 +2130,12 @@ const messages = {
duration4: "10-12个月", duration4: "10-12个月",
duration5: "1年以上", duration5: "1年以上",
durationLabel: "预计租住时长", durationLabel: "预计租住时长",
emptyRoomTypesDesc: "当前暂无可用房型,请稍后再试",
loadFailed: "房型加载失败",
mapSelectFailed: "地图选择失败,请手动输入",
navTitle: "预约现场带看", navTitle: "预约现场带看",
popupSubtitle: "选择您心仪的房型与区域",
priceNegotiable: "价格面议",
roomTypeLabel: "意向房型", roomTypeLabel: "意向房型",
remarkArea: "意向区域:{area}", remarkArea: "意向区域:{area}",
remarkBudget: "租金预算:{budget} 元/月", remarkBudget: "租金预算:{budget} 元/月",
@@ -2054,11 +2150,13 @@ const messages = {
rentalNeeds: "您的租住需求", rentalNeeds: "您的租住需求",
rentalNeedsDesc: "帮助管家更好地为您推荐房源", rentalNeedsDesc: "帮助管家更好地为您推荐房源",
selectDurationRequired: "请选择预计租住时长", selectDurationRequired: "请选择预计租住时长",
selectedTimeLabel: "已选时间",
selectTimeRequired: "请选择看房时间段", selectTimeRequired: "请选择看房时间段",
selectViewingTime: "选择看房时间", selectViewingTime: "选择看房时间",
selectViewingTimeDesc: "请选择您方便的时间段", selectViewingTimeDesc: "请选择您方便的时间段",
submitBooking: "提交预约", submitBooking: "提交预约",
submitFailed: "预约提交失败", submitFailed: "预约提交失败",
submitHint: "提交后专属管家将尽快与您联系",
submitSuccessContent: "您的预约看房申请已提交,请等待管家联系", submitSuccessContent: "您的预约看房申请已提交,请等待管家联系",
submitSuccessTitle: "预约成功", submitSuccessTitle: "预约成功",
submitting: "提交中...", submitting: "提交中...",
@@ -2196,6 +2294,7 @@ const messages = {
}, },
store_detail: { store_detail: {
store_detail: { store_detail: {
addressLabel: "公寓地址",
apartmentName: "公寓详情", apartmentName: "公寓详情",
collapse: "收起", collapse: "收起",
contactManager: "联系管家", contactManager: "联系管家",
@@ -2735,9 +2834,12 @@ const messages = {
submit_request: { submit_request: {
components: { components: {
BuildingSelector: { BuildingSelector: {
emptyDesc: "当前暂无可用楼栋,请稍后再试",
emptyText: "暂无可选楼栋", emptyText: "暂无可选楼栋",
loadFailed: "楼栋列表加载失败", loadFailed: "楼栋列表加载失败",
loadFailedShort: "楼栋加载失败",
loadingText: "楼栋加载中...", loadingText: "楼栋加载中...",
popupSubtitle: "选择您期望入住的楼栋",
popupTitle: "选择意向楼栋", popupTitle: "选择意向楼栋",
selectAreaFirst: "请先选择区域", selectAreaFirst: "请先选择区域",
selectBuilding: "请选择楼栋" selectBuilding: "请选择楼栋"
@@ -2751,9 +2853,12 @@ const messages = {
}, },
FloorSelector: { FloorSelector: {
clearSelection: "不选择", clearSelection: "不选择",
emptyDesc: "当前暂无可用楼层,请稍后再试",
emptyText: "暂无可选楼层", emptyText: "暂无可选楼层",
loadFailed: "楼层列表加载失败", loadFailed: "楼层列表加载失败",
loadFailedShort: "楼层加载失败",
loadingText: "楼层加载中...", loadingText: "楼层加载中...",
popupSubtitle: "选择您期望入住的楼层",
popupTitle: "选择意向楼层", popupTitle: "选择意向楼层",
selectFloor: "请选择楼层" selectFloor: "请选择楼层"
}, },

View File

@@ -248,48 +248,50 @@
<view class="empty-desc tn-margin-top-lg">{{ $tr('正在同步您的入住申请记录,请稍候。') }}</view> <view class="empty-desc tn-margin-top-lg">{{ $tr('正在同步您的入住申请记录,请稍候。') }}</view>
</view> </view>
<!-- 支付定金须知弹窗 --> <!-- 支付确认弹窗 -->
<tn-popup v-model="showDepositNotice" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true" @close="closeDepositNotice"> <tn-popup v-model="showDepositNotice" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true" @close="closeDepositNotice">
<view class="deposit-notice-popup"> <view class="deposit-notice-popup">
<view class="popup-header"> <view class="popup-header">
<view class="popup-title">{{ $tr('租房定金须知') }}</view> <view class="popup-handle"></view>
<view class="popup-title">{{ getPaymentNoticeTitle() }}</view>
</view> </view>
<scroll-view scroll-y class="popup-body" @scrolltolower="onNoticeScrollToBottom"> <view class="popup-body">
<view class="notice-content"> <view class="notice-content">
<view class="notice-item"> <view class="payment-sheet">
<view class="notice-item-zh">1、缴纳线上定金可锁定房源并预约专属看房,平台收取定金后该房源临时下架,不再接待其他客户看房。</view> <view class="payment-hero">
<view class="notice-item-en">Payment of the online deposit reserves the housing unit and arranges exclusive on-site viewing for you. After receiving the deposit, we will temporarily remove the property from the rental list and stop arranging viewings for other clients.</view> <view class="payment-hero-label">{{ $tr('本次支付') }}</view>
</view> <view class="payment-hero-amount">{{ getPaymentNoticeBillAmountDisplay() }}</view>
<view class="notice-item"> <view class="payment-hero-hint">{{ $tr('确认后将发起微信支付') }}</view>
<view class="notice-item-zh">2、请于约定时间完成实地看房,并在看房结束后 3 个工作日内签署租赁合同;若逾期未看房或未完成签约,视作您主动放弃房源,定金不予退还。</view> </view>
<view class="notice-item-en">You shall complete the on-site viewing at the agreed time and sign the rental contract within 3 working days after the viewing. Failure to complete the viewing or sign the contract within the time limit shall be deemed your voluntary waiver of the property, and the deposit will not be refunded.</view> <view v-if="paymentNoticeSourceLoading || paymentNoticeBillLoading" class="payment-sync-group">
</view> <view v-if="paymentNoticeSourceLoading" class="payment-sync-chip">
<view class="notice-item"> {{ $tr('正在同步申请详情,请稍候...') }}
<view class="notice-item-zh">3、如因租客个人原因取消看房、看房后不愿承租房屋或拒绝签署租赁合同,定金不予退还。</view> </view>
<view class="notice-item-en">The deposit is non-refundable if you cancel the viewing, refuse to rent the property or decline to sign the contract due to personal reasons.</view> <view v-if="paymentNoticeBillLoading" class="payment-sync-chip">
</view> {{ $tr('正在同步支付预览,请稍候...') }}
<view class="notice-item"> </view>
<view class="notice-item-zh">4、若因我方房源信息虚假、房屋无法正常交付等我方过错,造成无法看房或签约,定金将全额按原支付路径退还。</view> </view>
<view class="notice-item-en">If the viewing or contract signing cannot be conducted due to our liabilities such as false property information or failure to deliver the housing normally, the full deposit will be refunded via your original payment channel.</view> <view v-if="getPaymentNoticeDetailRows().length" class="payment-detail-panel">
</view> <view class="payment-detail-header">
<view class="notice-item"> <view class="payment-detail-title">{{ $tr('入住申请信息') }}</view>
<view class="notice-item-zh">5、待双方正式签署租赁合同后,该定金可直接抵扣首期租金。</view> </view>
<view class="notice-item-en">After both parties formally sign the rental contract, this deposit may be directly deducted from your initial rent.</view> <view class="payment-detail-list">
</view> <view
<view class="notice-item"> v-for="item in getPaymentNoticeDetailRows()"
<view class="notice-item-zh">6、您支付定金,即代表已完整知晓房源全部信息,并自愿接受本须知全部条款约束。</view> :key="item.label"
<view class="notice-item-en">By making the deposit payment, you confirm that you have fully reviewed all property information and voluntarily accept all terms and conditions set forth in this notice.</view> 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>
</scroll-view> </view>
<view class="popup-footer"> <view class="popup-footer">
<view <view class="notice-action-btn" @click="confirmAndPayDeposit">
class="action-btn" <text class="tn-text-bold">{{ getPaymentNoticeConfirmText() }}</text>
:class="{ 'action-btn--disabled': !hasReadNotice }"
@click="confirmAndPayDeposit"
>
<text class="tn-text-bold">{{ $tr('同意并支付') }}</text>
<text v-if="!hasReadNotice" class="tn-margin-left-xs tn-text-sm">({{ $tr('请滑动阅读至底部') }})</text>
</view> </view>
</view> </view>
</view> </view>
@@ -298,7 +300,7 @@
</template> </template>
<script> <script>
import { applicationFormMePage, accountQueryBill, accountQueryBills, contractDetail, orderSearchedMePage, orderSearchedReservationFlow, paymentBillPay, paymentPayingBills, roomInspectionDetail, userDetailCancel } from '@/api/index.js' import { applicationFormDetail, applicationFormMePage, accountQueryBill, accountQueryBillPreview, accountQueryBills, contractDetail, orderSearchedMePage, orderSearchedReservationFlow, paymentBillPay, paymentPayingBills, roomInspectionDetail, userDetailCancel } from '@/api/index.js'
import { getMyApplicationList, saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state' import { getMyApplicationList, saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state'
import { import {
RESERVATION_FLOW_STEP, RESERVATION_FLOW_STEP,
@@ -311,6 +313,7 @@ import {
parseReservationFlowResponse parseReservationFlowResponse
} from '@/utils/reservation-flow' } from '@/utils/reservation-flow'
import { getOrderStatusCode, normalizeBookingList, parseOrderListResponse } from '@/utils/booking-helper' import { getOrderStatusCode, normalizeBookingList, parseOrderListResponse } from '@/utils/booking-helper'
import { localizePayMethodText } from '@/utils/select-room-normalizers'
function formatPageResponse(response) { function formatPageResponse(response) {
const payload = response && response.data ? response.data : response const payload = response && response.data ? response.data : response
@@ -420,9 +423,12 @@ export default {
loadingMore: false, loadingMore: false,
hasMore: true, hasMore: true,
showDepositNotice: false, showDepositNotice: false,
hasReadNotice: false,
pendingDepositReferenceTypeNo: '', pendingDepositReferenceTypeNo: '',
pendingDepositPaymentContext: null, pendingDepositPaymentContext: null,
paymentNoticeSourceItem: null,
paymentNoticeSourceLoading: false,
paymentNoticeBillLoading: false,
paymentNoticeBillDetail: null,
reservationBillDetailMap: {}, reservationBillDetailMap: {},
reservationContractDetailMap: {}, reservationContractDetailMap: {},
reservationInspectionDetailMap: {}, reservationInspectionDetailMap: {},
@@ -599,6 +605,57 @@ export default {
} }
return {} 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 {}
},
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
}
const normalizedDetail = {
...detail,
isViewingApplication: isViewingApplicationRecord(detail)
}
const detailBillNo = String(normalizedDetail.billNo || normalizedDetail.BillNo || '').trim()
const detailCode = String(normalizedDetail.detailCode || normalizedReferenceTypeNo).trim()
if (Array.isArray(this.list) && this.list.length) {
const nextList = this.list.map((item) => {
if (String(this.resolveReferenceTypeNo(item)) !== normalizedReferenceTypeNo) {
return item
}
return {
...item,
...normalizedDetail,
detailCode: detailCode || item.detailCode || normalizedReferenceTypeNo,
billNo: detailBillNo || item.billNo || ''
}
})
this.list = normalizeApplicationList(nextList)
saveMyApplicationList(this.list)
}
return {
referenceTypeNo: detailCode || normalizedReferenceTypeNo,
billNo: detailBillNo,
isViewingApplication: !!normalizedDetail.isViewingApplication,
detail: normalizedDetail
}
},
normalizeContractDetail(response) { normalizeContractDetail(response) {
const payload = parseJsonStringMaybe(response && response.data ? response.data : response) const payload = parseJsonStringMaybe(response && response.data ? response.data : response)
if (payload && typeof payload === 'object' && !Array.isArray(payload)) { if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
@@ -852,6 +909,20 @@ export default {
getReservationCurrentStepValue(item = {}) { getReservationCurrentStepValue(item = {}) {
return Number(item && item.reservationCurrentNode ? item.reservationCurrentNode.step : 0) return Number(item && item.reservationCurrentNode ? item.reservationCurrentNode.step : 0)
}, },
getApplicationDetailCodeFromOrder(order = {}) {
const flowNodes = order && order.reservationFlow && Array.isArray(order.reservationFlow.flowNodes)
? order.reservationFlow.flowNodes
: []
const applicationNode = [...flowNodes]
.filter(item => item && typeof item === 'object' && Number(item.step || 0) === RESERVATION_FLOW_STEP.APPLICATION)
.sort((prev, next) => Number(prev.sort || 0) - Number(next.sort || 0))[0] || null
return String(
(applicationNode && (applicationNode.detailCode || applicationNode.code || applicationNode.refNo))
|| order.detailCode
|| ''
).trim()
},
isReservationBillStep(item = {}) { isReservationBillStep(item = {}) {
return this.getReservationCurrentStepValue(item) === RESERVATION_FLOW_STEP.BILL return this.getReservationCurrentStepValue(item) === RESERVATION_FLOW_STEP.BILL
}, },
@@ -1390,20 +1461,29 @@ export default {
&& (this.shouldPayDeposit(item) || this.isReservationBillStep(item)) && (this.shouldPayDeposit(item) || this.isReservationBillStep(item))
&& this.resolveReferenceTypeNo(item) && this.resolveReferenceTypeNo(item)
}, },
canCancelApplication(item = {}) {
return !!(this.resolveReferenceTypeNo(item)
&& Number(item.status) === 0
&& (item.isViewingApplication || Number(item.payStatus) !== 10))
},
canCancelOrder(item = {}) { canCancelOrder(item = {}) {
return this.shouldPayDeposit(item) return this.canCancelApplication(item)
&& this.resolveReferenceTypeNo(item)
}, },
getCancelButtonText(item = {}) { getCancelButtonText(item = {}) {
return this.cancelingReferenceTypeNo === this.resolveReferenceTypeNo(item) return this.cancelingReferenceTypeNo === this.resolveReferenceTypeNo(item)
? this.$tr('取消中...') ? this.$tr('取消中...')
: this.$tr('取消订单') : this.$tr('取消申请')
}, },
confirmCancelOrder() { getCancelOrderContent(item = {}) {
return item.isViewingApplication
? this.$tr('取消后将关闭当前预约看房申请,是否继续?')
: this.$tr('取消后将关闭当前申请单,是否继续?')
},
confirmCancelOrder(item = {}) {
return new Promise((resolve) => { return new Promise((resolve) => {
uni.showModal({ uni.showModal({
title: this.$tr('取消订单'), title: this.$tr('取消申请'),
content: this.$tr('取消后将关闭当前未支付的定金订单,是否继续?'), content: this.getCancelOrderContent(item),
confirmColor: '#ef4444', confirmColor: '#ef4444',
success: (res) => resolve(!!(res && res.confirm)), success: (res) => resolve(!!(res && res.confirm)),
fail: () => resolve(false) fail: () => resolve(false)
@@ -1439,33 +1519,34 @@ export default {
const referenceTypeNo = dataset.referenceTypeNo || dataset.referenceTypeNo === 0 const referenceTypeNo = dataset.referenceTypeNo || dataset.referenceTypeNo === 0
? String(dataset.referenceTypeNo) ? String(dataset.referenceTypeNo)
: '' : ''
const currentItem = (Array.isArray(this.list) ? this.list : []).find((item) => {
return String(this.resolveReferenceTypeNo(item)) === referenceTypeNo
}) || {}
if (!referenceTypeNo || this.cancelingReferenceTypeNo) { if (!referenceTypeNo || this.cancelingReferenceTypeNo) {
return return
} }
this.confirmCancelOrder() this.confirmCancelOrder(currentItem)
.then((confirmed) => { .then((confirmed) => {
if (!confirmed) { if (!confirmed) {
return return
} }
this.cancelingReferenceTypeNo = referenceTypeNo this.cancelingReferenceTypeNo = referenceTypeNo
return userDetailCancel({ return userDetailCancel({
detailCode: referenceTypeNo, detailCode: referenceTypeNo
code: referenceTypeNo,
referenceTypeNo
}, this.language) }, this.language)
.then(async () => { .then(async () => {
this.syncCanceledItem(referenceTypeNo) this.syncCanceledItem(referenceTypeNo)
await this.reloadList(true) await this.reloadList(true)
this.$emit('refresh-shell-state') this.$emit('refresh-shell-state')
uni.showToast({ uni.showToast({
title: this.$tr('订单已取消'), title: this.$tr('申请已取消'),
icon: 'success' icon: 'success'
}) })
}) })
}) })
.catch((error) => { .catch((error) => {
uni.showToast({ uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('取消订单失败'), title: (error && (error.message || error.msg)) || this.$tr('取消申请失败'),
icon: 'none' icon: 'none'
}) })
}) })
@@ -1511,35 +1592,339 @@ export default {
}) })
return return
} }
const currentItem = (Array.isArray(this.list) ? this.list : []).find((item) => {
this.pendingDepositReferenceTypeNo = referenceTypeNo return String(this.resolveReferenceTypeNo(item)) === referenceTypeNo
this.hasReadNotice = false }) || {}
this.showDepositNotice = true this.openDepositNoticeForPayment({
referenceTypeNo,
description: this.$tr('支付定金'),
mode: 'deposit',
sourceItem: currentItem
})
}, },
onNoticeScrollToBottom() { getPaymentContextMode(context = {}) {
this.hasReadNotice = true 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
|| ''
).trim()
const roomTypeName = String(
source.roomTypeText
|| source.roomTypeName
|| source.intendedRoomTypeName
|| source.roomTypeCode
|| source.roomName
|| ''
).trim()
const roomNo = String(
source.roomNoText
|| source.roomNo
|| source.roomLabel
|| ''
).trim()
const moveInDate = formatShortDate(
source.startDateText
|| source.expectedCheckInTime
|| source.startDate
|| source.start
|| source.checkinDate
|| ''
)
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.activeReservationOrder && this.activeReservationOrder.orderNo)
|| ''
).trim()
},
loadPaymentNoticeSourceDetail(referenceTypeNo = '', fallbackSource = null) {
const fallbackReferenceTypeNo = fallbackSource ? this.getApplicationDetailCodeFromOrder(fallbackSource) : ''
const normalizedReferenceTypeNo = String(referenceTypeNo || fallbackReferenceTypeNo || '').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 = {}) { openDepositNoticeForPayment(context = {}) {
const paymentSourceItem = context.sourceItem || this.activeReservationOrder || null
const paymentReferenceTypeNo = String(
context.referenceTypeNo
|| (paymentSourceItem ? this.getApplicationDetailCodeFromOrder(paymentSourceItem) : '')
|| ''
).trim()
const paymentOrderNo = this.resolvePaymentNoticeOrderNo(context, paymentSourceItem)
this.pendingDepositPaymentContext = { this.pendingDepositPaymentContext = {
referenceTypeNo: String(context.referenceTypeNo || '').trim(), referenceTypeNo: paymentReferenceTypeNo,
orderNo: paymentOrderNo,
billNo: String(context.billNo || '').trim(), billNo: String(context.billNo || '').trim(),
paymentNo: String(context.paymentNo || '').trim(), paymentNo: String(context.paymentNo || '').trim(),
description: String(context.description || '').trim() description: String(context.description || '').trim(),
mode: this.getPaymentContextMode(context)
} }
this.paymentNoticeSourceItem = paymentSourceItem
this.pendingDepositReferenceTypeNo = this.pendingDepositPaymentContext.referenceTypeNo this.pendingDepositReferenceTypeNo = this.pendingDepositPaymentContext.referenceTypeNo
this.hasReadNotice = false 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 this.showDepositNotice = true
}, },
closeDepositNotice() { closeDepositNotice() {
this.showDepositNotice = false this.showDepositNotice = false
this.pendingDepositReferenceTypeNo = '' this.pendingDepositReferenceTypeNo = ''
this.pendingDepositPaymentContext = null this.pendingDepositPaymentContext = null
this.paymentNoticeSourceItem = null
this.paymentNoticeSourceLoading = false
this.paymentNoticeBillLoading = false
this.paymentNoticeBillDetail = null
}, },
resolveReservationBillPaymentContext(order = {}, fallbackContext = {}) { resolveReservationBillPaymentContext(order = {}, fallbackContext = {}) {
const currentNode = order && order.reservationCurrentNode ? order.reservationCurrentNode : {} const currentNode = order && order.reservationCurrentNode ? order.reservationCurrentNode : {}
const isBillStep = Number(currentNode.step || 0) === RESERVATION_FLOW_STEP.BILL const isBillStep = Number(currentNode.step || 0) === RESERVATION_FLOW_STEP.BILL
const candidateReferenceTypeNos = [ const candidateReferenceTypeNos = [
fallbackContext.referenceTypeNo, fallbackContext.referenceTypeNo,
this.getApplicationDetailCodeFromOrder(order),
currentNode.referenceTypeNo, currentNode.referenceTypeNo,
currentNode.detailCode, currentNode.detailCode,
currentNode.code, currentNode.code,
@@ -1579,7 +1964,9 @@ export default {
referenceTypeNo: pickFirst(candidateReferenceTypeNos), referenceTypeNo: pickFirst(candidateReferenceTypeNos),
billNo: pickFirst(candidateBillNos), billNo: pickFirst(candidateBillNos),
paymentNo: pickFirst(candidatePaymentNos), paymentNo: pickFirst(candidatePaymentNos),
description: String(currentNode.stepDesc || fallbackContext.description || '').trim() || this.$tr('支付定金') description: String(currentNode.stepDesc || fallbackContext.description || '').trim() || this.$tr('账单支付'),
mode: isBillStep ? 'bill' : this.getPaymentContextMode(fallbackContext),
sourceItem: order
} }
}, },
resolvePendingPaymentContextByPaymentNo(paymentNo = '') { resolvePendingPaymentContextByPaymentNo(paymentNo = '') {
@@ -1632,14 +2019,21 @@ export default {
}) })
}, },
async payByPaymentContext(context = {}) { async payByPaymentContext(context = {}) {
const paymentMode = this.getPaymentContextMode(context)
let referenceTypeNo = String(context.referenceTypeNo || '').trim() let referenceTypeNo = String(context.referenceTypeNo || '').trim()
let billNo = String(context.billNo || '').trim() let billNo = String(context.billNo || '').trim()
const paymentNo = String(context.paymentNo || '').trim() const paymentNo = String(context.paymentNo || '').trim()
const description = String(context.description || '').trim() || this.$tr('支付定金') 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) { if (!referenceTypeNo && !billNo && !paymentNo) {
uni.showToast({ uni.showToast({
title: this.$tr('申请单编号缺失'), title: missingContextMessage,
icon: 'none' icon: 'none'
}) })
return return
@@ -1652,6 +2046,20 @@ export default {
uni.showLoading({ title: this.$tr('正在获取账单...') }) uni.showLoading({ title: this.$tr('正在获取账单...') })
try { try {
let bill = null 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 let pendingPaymentOrder = billNo ? await this.getPendingPaymentOrder(billNo).catch(() => null) : null
if (!pendingPaymentOrder && paymentNo) { if (!pendingPaymentOrder && paymentNo) {
const matchedContext = this.resolvePendingPaymentContextByPaymentNo(paymentNo) || this.resolveReservationPaymentContextByPaymentNo(paymentNo) const matchedContext = this.resolvePendingPaymentContextByPaymentNo(paymentNo) || this.resolveReservationPaymentContextByPaymentNo(paymentNo)
@@ -1663,6 +2071,24 @@ export default {
} }
} }
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) { if (billNo && !bill) {
const billDetail = this.normalizeBillDetail(await accountQueryBill({ const billDetail = this.normalizeBillDetail(await accountQueryBill({
billNo billNo
@@ -1678,7 +2104,8 @@ export default {
} }
} }
if (referenceTypeNo) { const shouldQueryBillListByReference = !!referenceTypeNo && !(paymentMode === 'bill' && billNo)
if (shouldQueryBillListByReference) {
const billRes = await accountQueryBills({ const billRes = await accountQueryBills({
referenceTypeNo, referenceTypeNo,
status: 10, status: 10,
@@ -1697,7 +2124,7 @@ export default {
billName: description billName: description
} }
} else { } else {
uni.showToast({ title: this.$tr('暂无待支付定金账单'), icon: 'none' }) uni.showToast({ title: emptyBillMessage, icon: 'none' })
return return
} }
} else { } else {
@@ -1719,7 +2146,7 @@ export default {
} }
if (!billNo) { if (!billNo) {
uni.showToast({ title: this.$tr('暂无待支付定金账单'), icon: 'none' }) uni.showToast({ title: emptyBillMessage, icon: 'none' })
return return
} }
@@ -1768,9 +2195,6 @@ export default {
} }
}, },
async confirmAndPayDeposit() { async confirmAndPayDeposit() {
if (!this.hasReadNotice) {
return
}
const paymentContext = this.pendingDepositPaymentContext || { const paymentContext = this.pendingDepositPaymentContext || {
referenceTypeNo: this.pendingDepositReferenceTypeNo referenceTypeNo: this.pendingDepositReferenceTypeNo
} }
@@ -2491,76 +2915,171 @@ export default {
} }
.popup-header { .popup-header {
padding: 32rpx; padding: 18rpx 88rpx 20rpx;
text-align: center; text-align: center;
border-bottom: 1px solid #F1F5F9; 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 { .popup-title {
font-size: 36rpx; font-size: 34rpx;
font-weight: bold; font-weight: 600;
line-height: 1.2;
letter-spacing: 1rpx;
color: #1A2233; color: #1A2233;
} }
.popup-body { .popup-body {
flex: 1; flex: 1;
padding: 32rpx; padding: 28rpx 28rpx 24rpx;
overflow-y: auto; overflow-y: auto;
background: linear-gradient(180deg, #F7FAFF 0%, #F2F5FC 100%);
} }
.notice-content { .notice-content {
padding-bottom: 40rpx; padding-bottom: 24rpx;
} }
.notice-item { .payment-sheet {
margin-bottom: 32rpx; position: relative;
padding: 28rpx;
border-radius: 32rpx;
background:
radial-gradient(circle at top right, rgba($brand-primary, 0.16) 0, rgba($brand-primary, 0) 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(246, 250, 255, 0.96) 100%);
border: 1rpx solid rgba($brand-primary, 0.1);
box-shadow: 0 24rpx 60rpx rgba($brand-primary, 0.1);
} }
.notice-item-zh { .payment-hero {
font-size: 28rpx; padding: 18rpx 6rpx 14rpx;
color: #334155; text-align: center;
line-height: 1.6;
margin-bottom: 8rpx;
font-weight: 500;
} }
.notice-item-en { .payment-hero-label {
font-size: 24rpx; font-size: 24rpx;
color: #64748B; 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($brand-primary, 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($brand-primary, 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($brand-primary, 0.08);
}
.payment-detail-label {
flex: 0 0 148rpx;
font-size: 22rpx;
color: #7C8CA5;
line-height: 1.5; line-height: 1.5;
} }
.popup-footer { .payment-detail-value {
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom)); flex: 1;
border-top: 1px solid #F1F5F9; font-size: 26rpx;
background: #FFFFFF; line-height: 1.4;
display: flex; color: #22324D;
justify-content: center; word-break: break-all;
font-weight: 600;
text-align: right;
} }
.action-btn { .popup-footer {
min-width: 420rpx; padding: 18rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
max-width: 100%; background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.92) 26%, #FFFFFF 100%);
height: 92rpx; display: flex;
justify-content: center;
backdrop-filter: blur(8rpx);
}
.notice-action-btn {
width: 100%;
height: 88rpx;
padding: 0 40rpx; padding: 0 40rpx;
border-radius: 999rpx; border-radius: 999rpx;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); background: linear-gradient(135deg, $brand-primary 0%, #1F6FE5 100%);
color: #ffffff; color: #ffffff;
box-shadow: 0 16rpx 34rpx rgba(34, 197, 94, 0.24); box-shadow: 0 14rpx 28rpx rgba($brand-primary, 0.22);
transition: transform 0.2s ease, box-shadow 0.2s ease; transition: transform 0.2s ease, box-shadow 0.2s ease;
} }
.action-btn:active { .notice-action-btn:active {
transform: scale(0.98); transform: scale(0.98);
} }
.action-btn--disabled {
opacity: 0.6;
background: #94A3B8;
box-shadow: none;
}
</style> </style>

View File

@@ -1,4 +1,4 @@
<template> <template>
<view class="profile-page tn-safe-area-inset-bottom"> <view class="profile-page tn-safe-area-inset-bottom">
<!-- 头部背景与用户信息 (悬浮质感设计) --> <!-- 头部背景与用户信息 (悬浮质感设计) -->

View File

@@ -196,12 +196,12 @@ export default {
watch: { watch: {
currentLang() { currentLang() {
this.loadBannerList() this.loadBannerList()
this.loadAreaList() this.refreshSelectRoomState()
} }
}, },
async mounted() { async mounted() {
this.checkLoginStatus() this.checkLoginStatus()
await Promise.all([this.loadBannerList(), this.loadAreaList()]) await this.loadBannerList()
}, },
methods: { methods: {
getLocalizedBannerText(banner = {}, field = '') { getLocalizedBannerText(banner = {}, field = '') {
@@ -236,6 +236,29 @@ export default {
this.isLogin = session.isLogin this.isLogin = session.isLogin
this.userInfo = this.isLogin ? getStoredUser() : null this.userInfo = this.isLogin ? getStoredUser() : null
}, },
hasActiveReservationFlow() {
const storeUser = (this.$store && this.$store.state && this.$store.state.vuex_user) || {}
if (storeUser.hasActiveReservationFlow === true) {
return true
}
if (storeUser.hasActiveReservationFlow === false) {
return false
}
const session = getSessionSnapshot()
const user = session.currentUser || session.registeredUser || session.resolvedUser || {}
if (user.hasActiveReservationFlow === true) {
return true
}
if (user.hasActiveReservationFlow === false) {
return false
}
return false
},
refreshSelectRoomState() {
if (!this.isLogin || !this.hasActiveReservationFlow()) {
this.loadAreaList()
}
},
loadBannerList() { loadBannerList() {
this.isBannerLoading = true this.isBannerLoading = true
this.bannerLoadError = '' this.bannerLoadError = ''

File diff suppressed because it is too large Load Diff

View File

@@ -50,14 +50,6 @@
></stay-service-tab> ></stay-service-tab>
</scroll-view> </scroll-view>
</view> </view>
<view v-if="hasPage('my-application')" v-show="currentPageId === 'my-application'">
<scroll-view class="custom-tabbar-page" scroll-y enable-back-to-top @scrolltolower="tabbarPageScrollLower">
<my-application-tab
ref="myApplicationTab"
@refresh-shell-state="handleMyApplicationRefreshState"
></my-application-tab>
</scroll-view>
</view>
<view v-if="hasPage('message-center')" v-show="currentPageId === 'message-center'"> <view v-if="hasPage('message-center')" v-show="currentPageId === 'message-center'">
<scroll-view class="custom-tabbar-page" scroll-y enable-back-to-top @scrolltolower="tabbarPageScrollLower"> <scroll-view class="custom-tabbar-page" scroll-y enable-back-to-top @scrolltolower="tabbarPageScrollLower">
<message-center-tab ref="messageCenterTab"></message-center-tab> <message-center-tab ref="messageCenterTab"></message-center-tab>
@@ -108,27 +100,28 @@
import StayServiceTab from './component/StayServiceTab.vue' import StayServiceTab from './component/StayServiceTab.vue'
import MessageCenterTab from './component/MessageCenterTab.vue' import MessageCenterTab from './component/MessageCenterTab.vue'
import ProfileCenterTab from './component/ProfileCenterTab.vue' import ProfileCenterTab from './component/ProfileCenterTab.vue'
import MyApplicationTab from './component/MyApplicationTab.vue'
import ContractAlert from '../../components/contract-alert/contract-alert.vue' import ContractAlert from '../../components/contract-alert/contract-alert.vue'
import { clearLegacyDemoCache, getHomePageTabId, getSessionSnapshot, saveHomePageTabId } from '../../utils/app-state' import {
import { applicationFormMePage, checkRecordByChecked, notificationPage, orderSearchedMePage, orderSearchedReservationFlow } from '@/api/index.js' clearLegacyDemoCache,
import { parseOrderListResponse, getOrderStatusCode } from '@/utils/booking-helper' getCurrentUser,
getHomePageTabId,
getSessionSnapshot,
saveBookingList,
saveCurrentBooking,
saveCurrentUser,
saveEffectiveContracts,
saveHomePageTabId,
savePendingContractCount,
savePendingContracts,
setCheckedInStatus
} from '../../utils/app-state'
import { checkRecordByChecked, notificationPage, orderSearchedReservationFlow, userMe } from '@/api/index.js'
import { mergeBackendUserInfo } from '@/utils/flow'
import { bindPrivacyAuthorizationListener, getPrivacyAuthorizationStatus, openWechatPrivacyContract, unbindPrivacyAuthorizationListener } from '@/utils/privacy.js' import { bindPrivacyAuthorizationListener, getPrivacyAuthorizationStatus, openWechatPrivacyContract, unbindPrivacyAuthorizationListener } from '@/utils/privacy.js'
import { countPendingInspectionRecords, extractInspectionList, resolveNotificationUnreadTotal } from '../../utils/message-helper' import { countPendingInspectionRecords, extractInspectionList, resolveNotificationUnreadTotal } from '../../utils/message-helper'
import { syncLoginStayState } from '../../utils/session-sync' import { getReservationCurrentNode, hasEffectiveReservationFlow, parseReservationFlowResponse } from '@/utils/reservation-flow'
import { getReservationOrderNo, getReservationCurrentNode, parseReservationFlowResponse } from '@/utils/reservation-flow'
function formatPageResponse(response) { const STAY_STATE_SYNC_TTL = 800
const payload = response && response.data ? response.data : response
return payload && typeof payload === 'object' ? payload : {}
}
function normalizeApplicationList(list = []) {
if (!Array.isArray(list)) {
return []
}
return list.filter(item => item && typeof item === 'object')
}
export default { export default {
components: { components: {
@@ -136,7 +129,6 @@
StayServiceTab, StayServiceTab,
MessageCenterTab, MessageCenterTab,
ProfileCenterTab, ProfileCenterTab,
MyApplicationTab,
ContractAlert ContractAlert
}, },
data() { data() {
@@ -150,15 +142,13 @@
sessionState: { sessionState: {
isLogin: false, isLogin: false,
hasCheckedIn: false, hasCheckedIn: false,
hasStayOrder: false,
hasPendingApplication: false, hasPendingApplication: false,
hasActiveReservationFlow: false, hasActiveReservationFlow: false,
pendingContracts: [] pendingContracts: []
}, },
tabDefinitions: [ tabDefinitions: [
{ id: 'select-room', labelKey: '选 房', icon: 'tn-icon-home-fill', image: '/static/tabbar/tn-tabbar0.png', component: 'SelectRoomTab', ref: 'selectRoomTab', refreshMethod: 'checkLoginStatus' }, { id: 'select-room', labelKey: '选 房', icon: 'tn-icon-home-fill', image: '/static/tabbar/tn-tabbar0.png', component: 'SelectRoomTab', ref: 'selectRoomTab', refreshMethod: 'refreshSelectRoomState' },
{ id: 'stay-service', labelKey: '入 住', icon: 'tn-icon-safe', image: '/static/tabbar/tn-tabbar1.png', component: 'StayServiceTab', ref: 'stayServiceTab', refreshMethod: 'checkLocalStatus' }, { id: 'stay-service', labelKey: '入 住', icon: 'tn-icon-safe', image: '/static/tabbar/tn-tabbar1.png', component: 'StayServiceTab', ref: 'stayServiceTab', refreshMethod: 'checkLocalStatus' },
{ id: 'my-application', labelKey: '申请单', icon: 'tn-icon-order', image: '/static/tabbar/tn-tabbar2.png', component: 'MyApplicationTab', ref: 'myApplicationTab', refreshMethod: 'load' },
{ id: 'message-center', labelKey: '消 息', icon: 'tn-icon-message', image: '/static/tabbar/tn-tabbar3.png', component: 'MessageCenterTab', ref: 'messageCenterTab', refreshMethod: 'load' }, { id: 'message-center', labelKey: '消 息', icon: 'tn-icon-message', image: '/static/tabbar/tn-tabbar3.png', component: 'MessageCenterTab', ref: 'messageCenterTab', refreshMethod: 'load' },
{ id: 'profile-center', labelKey: '我 的', icon: 'tn-icon-my-fill', image: '/static/tabbar/tn-tabbar3.png', component: 'ProfileCenterTab', ref: 'profileCenterTab', refreshMethod: 'checkLoginStatus' } { id: 'profile-center', labelKey: '我 的', icon: 'tn-icon-my-fill', image: '/static/tabbar/tn-tabbar3.png', component: 'ProfileCenterTab', ref: 'profileCenterTab', refreshMethod: 'checkLoginStatus' }
], ],
@@ -181,16 +171,15 @@
return ['select-room', 'profile-center'] return ['select-room', 'profile-center']
} }
const tabs = [] const tabs = []
if (this.sessionState.hasStayOrder) { const shouldShowStayService = this.sessionState.hasCheckedIn
|| this.sessionState.hasPendingApplication
|| this.sessionState.hasActiveReservationFlow
|| this.sessionState.pendingContracts.length > 0
if (shouldShowStayService) {
tabs.push('stay-service') tabs.push('stay-service')
} else if (this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow) {
tabs.push('my-application')
} else { } else {
tabs.push('select-room') tabs.push('select-room')
} }
if ((this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow) && !tabs.includes('my-application')) {
tabs.push('my-application')
}
tabs.push('message-center', 'profile-center') tabs.push('message-center', 'profile-center')
return tabs return tabs
}, },
@@ -316,112 +305,190 @@
getStoreCurrentOrder() { getStoreCurrentOrder() {
return (this.$store && this.$store.state && this.$store.state.vuex_current_order) || null return (this.$store && this.$store.state && this.$store.state.vuex_current_order) || null
}, },
getStoreCurrentBooking() {
const user = this.getStoreUser()
return (user && user.currentBooking) || null
},
getStorePendingContracts() { getStorePendingContracts() {
const list = this.$store && this.$store.state && this.$store.state.vuex_pending_contracts const list = this.$store && this.$store.state && this.$store.state.vuex_pending_contracts
return Array.isArray(list) ? list : [] return Array.isArray(list) ? list : []
}, },
getCachedStayStateResult() {
const storeUser = this.getStoreUser()
return {
hasCheckedIn: this.getStoreCheckinStatus(),
hasPendingApplication: !!storeUser.hasPendingApplication,
hasActiveReservationFlow: !!storeUser.hasActiveReservationFlow,
flowList: Array.isArray(storeUser.reservationFlowList) ? storeUser.reservationFlowList : []
}
},
syncStayState(force = false) { syncStayState(force = false) {
if (!this.isCurrentLogin()) { if (!this.isCurrentLogin()) {
this.refreshSessionState() this.refreshSessionState()
return Promise.resolve() return Promise.resolve({
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
})
} }
return syncLoginStayState({ force }).catch((error) => { const now = Date.now()
console.error('sync login stay state failed', error) if (this._syncStayStatePromise) {
}) return this._syncStayStatePromise
}, }
resolveApplicationState() { if (this._lastStayStateSyncAt && now - this._lastStayStateSyncAt < STAY_STATE_SYNC_TTL) {
return applicationFormMePage({ return Promise.resolve(this.getCachedStayStateResult())
Status: 0, }
PageIndex: 1,
PageSize: 1 const storeUser = this.getStoreUser()
}, this.$getLanguage()) const cachedUserCode = storeUser.userCode || storeUser.code || ''
// 先获取用户信息(含 userCode),再获取流程
this._syncStayStatePromise = userMe({}, this.$getLanguage())
.then((response) => { .then((response) => {
const pageData = formatPageResponse(response) const responseData = response && response.data ? response.data : response
const list = normalizeApplicationList(pageData.data || []) const info = responseData && responseData.data ? responseData.data : responseData
const total = Number(pageData.total || 0) const mergedUser = mergeBackendUserInfo(storeUser, response)
const hasPendingApplication = list.some(item => Number(item && item.status) === 0) const resolvedUserCode = mergedUser.userCode || mergedUser.code || info.userCode || info.code || cachedUserCode || ''
return { if (this.$store && this.$store.commit) {
hasPendingApplication: total > 0 || hasPendingApplication this.$store.commit('$tStore', { name: 'vuex_user', value: mergedUser })
} }
saveCurrentUser(mergedUser)
if (!resolvedUserCode) {
this.refreshSessionState()
return Promise.resolve({
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
})
}
return this.fetchReservationFlowWithUserCode(resolvedUserCode)
}) })
.catch((error) => { .catch((error) => {
console.error('resolve application state failed', error) console.error('fetch user info before flow failed', error)
return { if (!cachedUserCode) {
hasPendingApplication: false this.refreshSessionState()
return Promise.resolve({
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
})
} }
return this.fetchReservationFlowWithUserCode(cachedUserCode)
}) })
.then((result) => {
this._lastStayStateSyncAt = Date.now()
return result
})
.finally(() => {
this._syncStayStatePromise = null
})
return this._syncStayStatePromise
}, },
resolveApplicationFlowState(list = []) { fetchReservationFlowWithUserCode(userCode, options = {}) {
const orderList = normalizeApplicationList(list) const { silent = true } = options
const reservationOrderList = orderList.filter(item => getOrderStatusCode(item) === 0) const storeUser = this.getStoreUser()
const orderNos = [...new Set(reservationOrderList.map(item => getReservationOrderNo(item)).filter(Boolean))]
const result = {
hasPendingApplication: false,
hasActiveReservationFlow: false
}
if (!orderNos.length) {
return Promise.resolve(result)
}
return orderSearchedReservationFlow({ return orderSearchedReservationFlow({
orderNos userCode
}, this.$getLanguage()) }, this.$getLanguage())
.then((response) => { .then((response) => {
const flowList = parseReservationFlowResponse(response) const flowList = parseReservationFlowResponse(response)
const hasActiveReservationFlow = flowList.some(item => !!getReservationCurrentNode(item)) const currentFlow = flowList.find(item => {
const node = getReservationCurrentNode(item)
return node && node.isCurrent
}) || flowList[0] || null
const currentNode = currentFlow ? getReservationCurrentNode(currentFlow) : null
const currentStep = currentNode ? currentNode.step : null
const currentRefNo = currentNode ? String(currentNode.refNo || '').trim() : ''
const hasCheckedIn = currentStep === 5
const hasActiveReservationFlow = flowList.some(item => hasEffectiveReservationFlow(item))
const hasPendingApplication = currentStep === 1 && !!currentRefNo
const currentBooking = hasActiveReservationFlow && currentFlow
? { orderNo: currentFlow.orderNo || '', refNo: currentRefNo, step: currentStep }
: null
if (this.$store && this.$store.commit) {
this.$store.commit('$tStore', { name: 'vuex_checkin_status', value: hasCheckedIn })
this.$store.commit('$tStore', { name: 'vuex_current_order', value: null })
this.$store.commit('$tStore', { name: 'vuex_pending_contracts', value: [] })
this.$store.commit('$tStore', { name: 'vuex_pending_contract_count', value: 0 })
this.$store.commit('$tStore', { name: 'vuex_effective_contracts', value: [] })
this.$store.commit('$tStore', { name: 'vuex_valid_orders', value: [] })
this.$store.commit('$tStore', {
name: 'vuex_user',
value: {
...storeUser,
hasCheckedIn,
currentOrder: null,
currentBooking,
currentFlow,
bookingStatus: hasCheckedIn ? 'checked_in' : 'none',
reservationFlowList: flowList,
currentReservationStep: currentStep,
currentReservationRefNo: currentRefNo,
hasActiveReservationFlow,
hasPendingApplication
}
})
}
setCheckedInStatus(hasCheckedIn)
saveCurrentBooking(currentBooking)
saveBookingList([])
savePendingContracts([])
savePendingContractCount(0)
saveEffectiveContracts([])
const currentUser = getCurrentUser() || {}
saveCurrentUser({
...currentUser,
hasCheckedIn,
currentOrder: null,
currentBooking,
currentFlow,
bookingStatus: hasCheckedIn ? 'checked_in' : 'none',
reservationFlowList: flowList,
currentReservationStep: currentStep,
currentReservationRefNo: currentRefNo,
hasActiveReservationFlow,
hasPendingApplication
})
this.refreshSessionState()
return { return {
hasPendingApplication: result.hasPendingApplication || hasActiveReservationFlow, hasCheckedIn,
hasActiveReservationFlow hasPendingApplication,
hasActiveReservationFlow,
flowList
} }
}) })
.catch((error) => { .catch((error) => {
console.error('resolve application flow state failed', error) console.error('sync stay state by reservation flow failed', error)
return result this.refreshSessionState()
if (!silent) {
return Promise.reject(error)
}
return {
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
}
}) })
}, },
refreshShellState(force = false, options = {}) { refreshShellState(force = false, options = {}) {
const { skipMessageBadge = false } = options const { skipMessageBadge = false } = options
const tasks = [ const tasks = [
this.$store && this.$store.dispatch ? this.$store.dispatch('fetchGlobalEnums').catch(() => ({})) : Promise.resolve(), this.$store && this.$store.dispatch ? this.$store.dispatch('fetchGlobalEnums').catch(() => ({})) : Promise.resolve(),
this.syncStayState(force) this.syncStayState(force).then((flowState = {}) => {
this.sessionState.hasPendingApplication = !!flowState.hasPendingApplication
this.sessionState.hasActiveReservationFlow = !!flowState.hasActiveReservationFlow
})
] ]
if (!skipMessageBadge) { if (!skipMessageBadge) {
tasks.push(this.refreshMessageBadgeCount()) tasks.push(this.refreshMessageBadgeCount())
} }
if (this.isCurrentLogin()) {
tasks.push(
this.resolveApplicationState()
.then((applicationState = {}) => {
return orderSearchedMePage({ PageIndex: 1, PageSize: 100, IsExport: false })
.then(res => {
const orderList = parseOrderListResponse(res)
return this.resolveApplicationFlowState(orderList)
.then((flowState = {}) => ({
hasPendingApplication: !!applicationState.hasPendingApplication,
hasActiveReservationFlow: !!flowState.hasActiveReservationFlow
}))
})
})
.then((flowState = {}) => {
this.sessionState.hasPendingApplication = !!flowState.hasPendingApplication
this.sessionState.hasActiveReservationFlow = !!flowState.hasActiveReservationFlow
})
.catch(() => {
this.sessionState.hasPendingApplication = false
this.sessionState.hasActiveReservationFlow = false
})
)
} else {
this.sessionState.hasPendingApplication = false
this.sessionState.hasActiveReservationFlow = false
}
return Promise.all(tasks).finally(() => { return Promise.all(tasks).finally(() => {
this.refreshSessionState() this.refreshSessionState()
}) })
@@ -435,6 +502,10 @@
index: Number(options.index || 0) index: Number(options.index || 0)
}) })
this.$nextTick(() => {
this.refreshTabPageState('select-room')
})
setTimeout(() => { setTimeout(() => {
this.isAppInitLoading = false this.isAppInitLoading = false
}, 1500) }, 1500)
@@ -464,17 +535,17 @@
const storePendingContracts = this.getStorePendingContracts() const storePendingContracts = this.getStorePendingContracts()
const storeUser = this.getStoreUser() const storeUser = this.getStoreUser()
const storeHasCheckedIn = this.getStoreCheckinStatus() const storeHasCheckedIn = this.getStoreCheckinStatus()
const storeCurrentBooking = this.getStoreCurrentBooking()
const pendingContracts = storePendingContracts.length const pendingContracts = storePendingContracts.length
? storePendingContracts ? storePendingContracts
: (storeUser.pendingContracts || (Array.isArray(session.pendingContracts) ? session.pendingContracts : [])) : (storeUser.pendingContracts || (Array.isArray(session.pendingContracts) ? session.pendingContracts : []))
const hasStayOrder = !!(storeHasCheckedIn || storeCurrentBooking || session.currentBooking) const hasCheckedIn = !!(storeHasCheckedIn || session.hasCheckedIn)
const hasActiveReservationFlow = !!(storeUser.hasActiveReservationFlow || session.resolvedUser.hasActiveReservationFlow || this.sessionState.hasActiveReservationFlow)
const hasPendingApplication = !!(storeUser.hasPendingApplication || session.resolvedUser.hasPendingApplication || this.sessionState.hasPendingApplication)
this.sessionState = { this.sessionState = {
isLogin: this.isCurrentLogin(), isLogin: this.isCurrentLogin(),
hasCheckedIn: storeHasCheckedIn || session.hasCheckedIn, hasCheckedIn,
hasStayOrder: hasStayOrder || pendingContracts.length > 0, hasPendingApplication,
hasPendingApplication: !!this.sessionState.hasPendingApplication, hasActiveReservationFlow,
hasActiveReservationFlow: !!this.sessionState.hasActiveReservationFlow,
pendingContracts pendingContracts
} }
}, },
@@ -551,19 +622,13 @@
const explicitMatchedTab = list.find(item => item.id === pageId) const explicitMatchedTab = list.find(item => item.id === pageId)
if (explicitMatchedTab) return explicitMatchedTab if (explicitMatchedTab) return explicitMatchedTab
} }
const preferredPageId = getHomePageTabId() const shouldPreferFlowPage = this.sessionState.isLogin
const shouldPreferStayService = this.sessionState.isLogin && this.sessionState.hasStayOrder && (this.sessionState.hasCheckedIn || this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow || this.sessionState.pendingContracts.length > 0)
if (shouldPreferStayService && (!preferredPageId || preferredPageId === 'select-room')) { if (shouldPreferFlowPage) {
const stayServiceTab = list.find(item => item.id === 'stay-service') const stayServiceTab = list.find(item => item.id === 'stay-service')
if (stayServiceTab) return stayServiceTab if (stayServiceTab) return stayServiceTab
} }
const shouldPreferApplicationFlow = this.sessionState.isLogin const preferredPageId = getHomePageTabId()
&& !this.sessionState.hasStayOrder
&& (this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow)
if (shouldPreferApplicationFlow && (!preferredPageId || preferredPageId === 'select-room')) {
const applicationTab = list.find(item => item.id === 'my-application')
if (applicationTab) return applicationTab
}
if (preferredPageId) { if (preferredPageId) {
const matchedTab = list.find(item => item.id === preferredPageId) const matchedTab = list.find(item => item.id === preferredPageId)
if (matchedTab) return matchedTab if (matchedTab) return matchedTab
@@ -595,28 +660,74 @@
}, 60) }, 60)
} }
}, },
handleStayServiceRefreshState() { refreshReservationFlowOnly() {
this.refreshSessionState() if (!this.isCurrentLogin()) {
this.refreshSessionState()
return Promise.resolve({
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
})
}
const storeUser = this.getStoreUser()
let userCode = storeUser.userCode || storeUser.code || ''
if (!userCode) {
const session = getSessionSnapshot()
const fallbackUser = session.resolvedUser || session.currentUser || session.registeredUser || {}
userCode = fallbackUser.userCode || fallbackUser.code || ''
}
if (!userCode) {
this.refreshSessionState()
return Promise.resolve({
hasCheckedIn: false,
hasPendingApplication: false,
hasActiveReservationFlow: false,
flowList: []
})
}
return this.fetchReservationFlowWithUserCode(userCode, { silent: false })
}, },
async handleMyApplicationRefreshState() { handleStayServiceRefreshState(event = {}) {
await this.refreshShellState(true) const { pendingContracts } = event || {}
this.syncCurrentTab() this.refreshReservationFlowOnly()
this.$nextTick(() => { .then((flowState = {}) => {
this.refreshTabPageState(this.currentPageId) if (pendingContracts && Array.isArray(pendingContracts)) {
this.getTabbarItemInfo() if (this.$store && this.$store.commit) {
}) this.$store.commit('$tStore', { name: 'vuex_pending_contracts', value: pendingContracts })
}
savePendingContracts(pendingContracts)
}
this.sessionState.hasPendingApplication = !!flowState.hasPendingApplication
this.sessionState.hasActiveReservationFlow = !!flowState.hasActiveReservationFlow
this.refreshSessionState()
this.$nextTick(() => {
const stayServiceTab = this.getPageRef('stayServiceTab')
if (stayServiceTab && typeof stayServiceTab.onFlowRefreshed === 'function') {
stayServiceTab.onFlowRefreshed({ ...flowState, success: true })
}
})
})
.catch((error) => {
console.error('refresh reservation flow from stay service failed', error)
this.$nextTick(() => {
const stayServiceTab = this.getPageRef('stayServiceTab')
if (stayServiceTab && typeof stayServiceTab.onFlowRefreshed === 'function') {
stayServiceTab.onFlowRefreshed({ success: false })
}
})
})
}, },
async handleReservationFlowForceRefresh() { async handleReservationFlowForceRefresh() {
await this.refreshShellState(true) await this.refreshShellState(true)
this.ensurePageLoaded('my-application') this.ensurePageLoaded('stay-service')
this.syncCurrentTab()
this.$nextTick(() => { this.$nextTick(() => {
const myApplicationTab = this.getPageRef('myApplicationTab') this.refreshTabPageState('stay-service')
if (myApplicationTab && typeof myApplicationTab.load === 'function') { if (this.currentPageId && this.currentPageId !== 'stay-service') {
myApplicationTab.load(true)
} else {
this.refreshTabPageState('my-application')
}
if (this.currentPageId) {
this.refreshTabPageState(this.currentPageId) this.refreshTabPageState(this.currentPageId)
} }
this.getTabbarItemInfo() this.getTabbarItemInfo()

View File

@@ -75,7 +75,7 @@
<script> <script>
import { getRegisteredUser, saveRegisteredUser, startAuthorizedLoginSession } from '../../utils/app-state' import { getRegisteredUser, saveRegisteredUser, startAuthorizedLoginSession } from '../../utils/app-state'
import { mergeBackendUserInfo, normalizeUser, resolveRegistrationApplicationCode } from '../../utils/flow' import { mergeBackendUserInfo, normalizeUser } from '../../utils/flow'
import { import {
extractLoginSession, extractLoginSession,
matchRegisteredUser, matchRegisteredUser,
@@ -83,8 +83,8 @@ import {
resolveLoginPreparation resolveLoginPreparation
} from '../../utils/login-helper' } from '../../utils/login-helper'
import { resolveBookingRedirectAfterLogin } from '../../utils/booking-entry' import { resolveBookingRedirectAfterLogin } from '../../utils/booking-entry'
import { syncLoginStayState } from '../../utils/session-sync' import { parseReservationFlowResponse, getReservationCurrentNode, hasEffectiveReservationFlow } from '../../utils/reservation-flow'
import { registrationMyorder, userMe, usersLogin } from '@/api/index.js' import { registrationMyorder, orderSearchedReservationFlow, userMe, usersLogin } from '@/api/index.js'
export default { export default {
data() { data() {
@@ -235,14 +235,42 @@ export default {
return extractLoginSession(response, this.$getLanguage()) return extractLoginSession(response, this.$getLanguage())
}, },
fetchUserInfoAfterLogin() { fetchReservationFlowAfterLogin(userCode = '') {
return userMe({}, this.$getLanguage()) const resolvedUserCode = String(userCode || '').trim()
.then(userInfoRes => { if (!resolvedUserCode) {
return userInfoRes && userInfoRes.data ? userInfoRes.data : userInfoRes return Promise.resolve({
flowList: [],
currentFlow: null,
currentNode: null
})
}
return orderSearchedReservationFlow({
userCode: resolvedUserCode
}, this.$getLanguage())
.then(response => {
const flowList = parseReservationFlowResponse(response)
const currentFlow = flowList.find(item => {
const node = getReservationCurrentNode(item)
return node && node.isCurrent
}) || flowList[0] || null
const currentNode = currentFlow ? getReservationCurrentNode(currentFlow) : null
const hasActiveReservationFlow = flowList.some(item => hasEffectiveReservationFlow(item))
return {
flowList,
currentFlow,
currentNode,
hasActiveReservationFlow
}
}) })
.catch(error => { .catch(error => {
console.error('fetch userinfo failed', error) console.error('fetch reservation flow after login failed', error)
return null return {
flowList: [],
currentFlow: null,
currentNode: null,
hasActiveReservationFlow: false
}
}) })
}, },
rejectHandledLogin(options = {}) { rejectHandledLogin(options = {}) {
@@ -274,14 +302,15 @@ export default {
async resolveLoginState() { async resolveLoginState() {
const localRegisteredUser = normalizeUser(getRegisteredUser()) const localRegisteredUser = normalizeUser(getRegisteredUser())
const matchedLocalUser = matchRegisteredUser(localRegisteredUser, this.form.account) const matchedLocalUser = matchRegisteredUser(localRegisteredUser, this.form.account)
const { token, expires } = await this.loginByApi() const { token, expires, userCode } = await this.loginByApi()
const latestRegistration = await this.fetchLatestRegistration() const latestRegistration = await this.fetchLatestRegistration()
const loginPreparation = resolveLoginPreparation({ const loginPreparation = resolveLoginPreparation({
language: this.$getLanguage(), language: this.$getLanguage(),
matchedLocalUser, matchedLocalUser,
latestRegistration, latestRegistration,
account: this.form.account, account: this.form.account,
password: this.form.password password: this.form.password,
userCode
}) })
if (!loginPreparation.ok) { if (!loginPreparation.ok) {
@@ -301,21 +330,47 @@ export default {
} = loginState } = loginState
startAuthorizedLoginSession(sessionUser, token, expires) startAuthorizedLoginSession(sessionUser, token, expires)
const backendUserInfo = await this.fetchUserInfoAfterLogin()
const finalUser = mergeBackendUserInfo(sessionUser, backendUserInfo) // 先获取用户信息(含 userCode),再获取流程
let finalSessionUser = sessionUser
try {
const userInfoRes = await userMe({}, this.$getLanguage())
finalSessionUser = mergeBackendUserInfo(sessionUser, userInfoRes)
startAuthorizedLoginSession(finalSessionUser, token, expires)
} catch (error) {
console.error('fetch user info after login failed', error)
}
const userCode = finalSessionUser.userCode || finalSessionUser.code || ''
const reservationFlow = await this.fetchReservationFlowAfterLogin(userCode)
const currentStep = reservationFlow.currentNode ? reservationFlow.currentNode.step : null
const finalUser = {
...finalSessionUser,
reservationFlowList: reservationFlow.flowList,
currentReservationStep: currentStep,
hasActiveReservationFlow: reservationFlow.hasActiveReservationFlow
}
saveRegisteredUser(finalUser) saveRegisteredUser(finalUser)
startAuthorizedLoginSession(finalUser, token, expires) startAuthorizedLoginSession(finalUser, token, expires)
let nextRedirectUrl = this.redirectUrl let nextRedirectUrl = this.redirectUrl
try { try {
const stayState = await syncLoginStayState({ const stayState = {
language: this.$getLanguage(), hasCheckedIn: currentStep === 5,
force: true currentOrder: null,
}) currentBooking: reservationFlow.hasActiveReservationFlow && reservationFlow.currentFlow
? {
orderNo: reservationFlow.currentFlow.orderNo || '',
refNo: reservationFlow.currentNode ? String(reservationFlow.currentNode.refNo || '').trim() : '',
step: currentStep
}
: null
}
nextRedirectUrl = resolveBookingRedirectAfterLogin(this.redirectUrl, stayState) nextRedirectUrl = resolveBookingRedirectAfterLogin(this.redirectUrl, stayState)
} catch (error) { } catch (error) {
console.error('sync stay state after login failed', error) console.error('resolve booking redirect after login failed', error)
} }
uni.hideLoading() uni.hideLoading()

File diff suppressed because it is too large Load Diff

View File

@@ -79,24 +79,189 @@
<view class="tn-text-bold tn-text-xl" style="color: $brand-primary;">$500.00</view> <view class="tn-text-bold tn-text-xl" style="color: $brand-primary;">$500.00</view>
</view> </view>
<view class="custom-btn btn-dark" style="width: 60%; height: 100rpx;" @click="submitBooking" :class="{ 'btn-disabled': isSubmitting }"> <view class="custom-btn btn-dark" style="width: 60%; height: 100rpx;" @click="submitBooking" :class="{ 'btn-disabled': isSubmitting }">
<text class="tn-text-bold tn-text-lg">{{ isSubmitting ? (language === 'zh' ? '处理中...' : 'Processing...') : $tr('进入支付页') }}</text> <text class="tn-text-bold tn-text-lg">{{ isSubmitting ? (language === 'zh' ? '处理中...' : 'Processing...') : $tr('提交并支付') }}</text>
</view> </view>
</view> </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-title">{{ $tr('租房定金须知') }}</view>
<view v-if="pendingDepositPaymentContext && pendingDepositPaymentContext.paymentNo" class="popup-subtitle">
{{ $tr('支付号') }}:{{ pendingDepositPaymentContext.paymentNo }}
</view>
</view>
<scroll-view scroll-y class="popup-body" @scrolltolower="onNoticeScrollToBottom">
<view class="notice-content">
<view class="notice-item">
<view class="notice-item-zh">1、缴纳线上定金可锁定房源并预约专属看房,平台收取定金后该房源临时下架,不再接待其他客户看房。</view>
<view class="notice-item-en">Payment of the online deposit reserves the housing unit and arranges exclusive on-site viewing for you. After receiving the deposit, we will temporarily remove the property from the rental list and stop arranging viewings for other clients.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">2、请于约定时间完成实地看房,并在看房结束后 3 个工作日内签署租赁合同;若逾期未看房或未完成签约,视作您主动放弃房源,定金不予退还。</view>
<view class="notice-item-en">You shall complete the on-site viewing at the agreed time and sign the rental contract within 3 working days after the viewing. Failure to complete the viewing or sign the contract within the time limit shall be deemed your voluntary waiver of the property, and the deposit will not be refunded.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">3、如因租客个人原因取消看房、看房后不愿承租房屋或拒绝签署租赁合同,定金不予退还。</view>
<view class="notice-item-en">The deposit is non-refundable if you cancel the viewing, refuse to rent the property or decline to sign the contract due to personal reasons.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">4、若因我方房源信息虚假、房屋无法正常交付等我方过错,造成无法看房或签约,定金将全额按原支付路径退还。</view>
<view class="notice-item-en">If the viewing or contract signing cannot be conducted due to our liabilities such as false property information or failure to deliver the housing normally, the full deposit will be refunded via your original payment channel.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">5、待双方正式签署租赁合同后,该定金可直接抵扣首期租金。</view>
<view class="notice-item-en">After both parties formally sign the rental contract, this deposit may be directly deducted from your initial rent.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">6、您支付定金,即代表已完整知晓房源全部信息,并自愿接受本须知全部条款约束。</view>
<view class="notice-item-en">By making the deposit payment, you confirm that you have fully reviewed all property information and voluntarily accept all terms and conditions set forth in this notice.</view>
</view>
</view>
</scroll-view>
<view class="popup-footer">
<view
class="notice-action-btn"
:class="{ 'notice-action-btn--disabled': !hasReadNotice }"
@click="confirmAndPayDeposit"
>
<text class="tn-text-bold">{{ $tr('同意并支付') }}</text>
<text v-if="!hasReadNotice" class="tn-margin-left-xs tn-text-sm">({{ $tr('请滑动阅读至底部') }})</text>
</view>
</view>
</view>
</tn-popup>
</view> </view>
</template> </template>
<script> <script>
import { resolveExistingBookingNavigationTarget } from '../../utils/booking-entry' import { accountQueryBill, accountQueryBills, applicationFormDetail, paymentBillPay, paymentPayingBills, submitApplicationForm } from '../../api/index.js'
import { getSessionSnapshot } from '../../utils/app-state' import { saveActiveApplicationCode, saveHomePageTabId } from '../../utils/app-state'
import { syncLoginStayState } from '../../utils/session-sync'
import { compactEnglishText } from '../../utils/select-room-normalizers' import { compactEnglishText } from '../../utils/select-room-normalizers'
function isPlainObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function normalizeCodeValue(value) {
if (value === undefined || value === null) {
return ''
}
if (typeof value === 'string' || typeof value === 'number') {
return String(value).trim()
}
return ''
}
function extractSubmitApplicationCode(response) {
const payload = response && response.data !== undefined ? response.data : response
const directValues = [
payload,
payload && payload.data,
payload && payload.result,
payload && payload.record,
payload && payload.application
]
for (let i = 0; i < directValues.length; i += 1) {
const directCode = normalizeCodeValue(directValues[i])
if (directCode) {
return directCode
}
}
const sources = directValues.filter(isPlainObject)
const codeFields = ['detailCode', 'code', 'applicationCode', 'formCode', 'referenceTypeNo', 'no']
for (let i = 0; i < sources.length; i += 1) {
const current = sources[i]
for (let j = 0; j < codeFields.length; j += 1) {
const nextCode = normalizeCodeValue(current[codeFields[j]])
if (nextCode) {
return nextCode
}
}
}
return ''
}
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 || 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 formatDateTime(date = '', endOfDay = false) {
if (!date) {
return null
}
return `${date}T${endOfDay ? '23:59:59' : '00:00:00'}`
}
function parseResponseObject(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 {}
}
function parseResponseList(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 []
}
export default { export default {
data() { data() {
return { return {
isSubmitting: false, isSubmitting: false,
applyData: null, applyData: null,
showDepositNotice: false,
hasReadNotice: false,
isClosingDepositNoticeForPayment: false,
depositNoticeReadyAt: 0,
pendingDepositPaymentContext: null,
form: { form: {
startDate: '', startDate: '',
endDate: '' endDate: ''
@@ -163,44 +328,339 @@ export default {
console.error('Failed to parse apply data', e); console.error('Failed to parse apply data', e);
}) })
} }
this.redirectIfBookingUnavailable()
}, },
methods: { methods: {
redirectIfBookingUnavailable() {
const session = getSessionSnapshot()
if (!session.isLogin) {
return Promise.resolve(false)
}
return syncLoginStayState({
language: this.$getLanguage(),
force: true
})
.then((stayState) => {
const targetUrl = resolveExistingBookingNavigationTarget(stayState)
if (!targetUrl) {
return false
}
if (/^\/pages\/index\/index(?:\?|$)/.test(targetUrl)) {
uni.reLaunch({ url: targetUrl })
return true
}
uni.redirectTo({ url: targetUrl })
return true
})
.catch((error) => {
console.error('check booking entry failed', error)
return false
})
},
onStartDateChange(e) { onStartDateChange(e) {
this.form.startDate = e.detail.value; this.form.startDate = e.detail.value;
}, },
onEndDateChange(e) { onEndDateChange(e) {
this.form.endDate = e.detail.value; this.form.endDate = e.detail.value;
}, },
goStayServiceFlow() {
saveHomePageTabId('stay-service')
uni.reLaunch({
url: '/pages/index/index?pageId=stay-service'
})
},
buildSubmitPayload() {
return {
intendedAreaCode: this.applyData && this.applyData.areaCode ? this.applyData.areaCode : null,
intendedRoomTypeCode: this.applyData && this.applyData.roomTypeCode ? this.applyData.roomTypeCode : null,
roomSlotHeight: 1,
hobbiesAndInterestsTagKeys: null,
lifestyleHabitsTagKeys: null,
remark: null,
start: formatDateTime(this.form.startDate, false),
end: formatDateTime(this.form.endDate, true)
}
},
normalizeApplicationDetail(response) {
return parseResponseObject(response)
},
normalizeBillDetail(response) {
return parseResponseObject(response)
},
normalizeBillList(response) {
return parseResponseList(response)
},
normalizePayingBills(response) {
return parseResponseList(response)
},
getBillNo(bill = {}) {
return String(bill.no || bill.billNo || '').trim()
},
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 => !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()
},
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 getPendingPaymentOrder(billNo = '') {
const normalizedBillNo = String(billNo || '').trim()
if (!normalizedBillNo) {
return null
}
const response = await paymentPayingBills({
BillNo: normalizedBillNo
}, this.language)
const orderList = this.normalizePayingBills(response)
return orderList.length ? orderList[0] : 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(),
paymentNo: String(detail.paymentNo || detail.payNo || detail.payRequestNo || '').trim(),
detail
}
},
async resolvePaymentPreview(referenceTypeNo = '') {
const context = await this.resolveApplicationDetailPaymentContext(referenceTypeNo).catch(() => null)
const billNo = context && context.billNo ? context.billNo : ''
const pendingPaymentOrder = billNo ? await this.getPendingPaymentOrder(billNo).catch(() => null) : null
return {
referenceTypeNo: context && context.referenceTypeNo ? context.referenceTypeNo : String(referenceTypeNo || '').trim(),
billNo,
paymentNo: this.getPendingPaymentOrderNo(pendingPaymentOrder) || (context && context.paymentNo ? context.paymentNo : ''),
pendingPaymentOrder
}
},
async payByPaymentContext(context = {}) {
let referenceTypeNo = String(context.referenceTypeNo || '').trim()
let billNo = String(context.billNo || '').trim()
const description = String(context.description || '').trim() || this.$tr('支付定金')
if (!referenceTypeNo && !billNo) {
throw new Error(this.$tr('申请单编号缺失'))
}
if (referenceTypeNo) {
saveActiveApplicationCode(referenceTypeNo)
}
uni.showLoading({ title: this.$tr('正在获取账单...') })
let bill = null
let pendingPaymentOrder = context.pendingPaymentOrder || null
if (referenceTypeNo && !billNo) {
const detailPaymentContext = await this.resolveApplicationDetailPaymentContext(referenceTypeNo).catch(() => null)
if (detailPaymentContext) {
referenceTypeNo = detailPaymentContext.referenceTypeNo || referenceTypeNo
billNo = detailPaymentContext.billNo || billNo
}
}
if (!pendingPaymentOrder && billNo) {
pendingPaymentOrder = await this.getPendingPaymentOrder(billNo).catch(() => null)
}
if (billNo) {
const billDetail = this.normalizeBillDetail(await accountQueryBill({ billNo }, this.language))
if (billDetail && Object.keys(billDetail).length) {
bill = billDetail
billNo = this.getBillNo(bill) || billNo
referenceTypeNo = referenceTypeNo || String(bill.referenceTypeNo || '').trim()
if (!pendingPaymentOrder) {
pendingPaymentOrder = this.buildPendingPaymentOrderFromBillDetail(bill)
}
}
}
if (referenceTypeNo) {
const billRes = await accountQueryBills({
referenceTypeNo,
status: 10,
paymentStatus: 0,
ProviderType: 'WechatMiniProgram'
}, this.language)
const billList = this.normalizeBillList(billRes)
if (Array.isArray(billList) && billList.length) {
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) {
throw new Error(this.$tr('暂无待支付定金账单'))
}
uni.showLoading({ title: this.$tr('正在拉起支付...') })
const expireTimestamp = parseExpireTimeToTimestamp(
pendingPaymentOrder && (pendingPaymentOrder.expireTime || (pendingPaymentOrder.cashier && pendingPaymentOrder.cashier.expireTime))
)
const reusablePendingPaymentOrder = pendingPaymentOrder && (!expireTimestamp || expireTimestamp > Date.now())
? pendingPaymentOrder
: null
if (reusablePendingPaymentOrder && reusablePendingPaymentOrder.cashier && reusablePendingPaymentOrder.cashier.paymentInfo) {
await this.requestWechatPayment(reusablePendingPaymentOrder.cashier.paymentInfo)
return
}
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
}, this.language)
const payload = payRes && payRes.data ? payRes.data : payRes
const cashier = payload && payload.cashier ? payload.cashier : {}
await this.requestWechatPayment(cashier.paymentInfo)
},
openDepositNotice(paymentContext = {}) {
this.pendingDepositPaymentContext = {
referenceTypeNo: String(paymentContext.referenceTypeNo || '').trim(),
billNo: String(paymentContext.billNo || '').trim(),
paymentNo: String(paymentContext.paymentNo || '').trim(),
pendingPaymentOrder: paymentContext.pendingPaymentOrder || null
}
this.hasReadNotice = false
this.depositNoticeReadyAt = Date.now() + 600
setTimeout(() => {
this.showDepositNotice = true
}, 80)
},
closeDepositNotice() {
if (this.isClosingDepositNoticeForPayment) {
this.isClosingDepositNoticeForPayment = false
return
}
this.showDepositNotice = false
this.hasReadNotice = false
this.depositNoticeReadyAt = 0
this.pendingDepositPaymentContext = null
},
onNoticeScrollToBottom() {
this.hasReadNotice = true
},
async confirmAndPayDeposit() {
if (!this.hasReadNotice) {
return
}
if (Date.now() < this.depositNoticeReadyAt) {
return
}
const paymentContext = this.pendingDepositPaymentContext || {}
this.isClosingDepositNoticeForPayment = true
this.showDepositNotice = false
try {
await this.payByPaymentContext({
referenceTypeNo: paymentContext.referenceTypeNo,
billNo: paymentContext.billNo,
pendingPaymentOrder: paymentContext.pendingPaymentOrder,
description: this.$tr('支付定金')
})
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()
this.hasReadNotice = false
this.isClosingDepositNoticeForPayment = false
this.depositNoticeReadyAt = 0
this.pendingDepositPaymentContext = null
setTimeout(() => {
this.goStayServiceFlow()
}, 300)
}
},
submitBooking() { submitBooking() {
if (this.isSubmitting) return; if (this.isSubmitting) return;
if (!this.form.startDate || !this.form.endDate) { if (!this.form.startDate || !this.form.endDate) {
@@ -213,24 +673,39 @@ export default {
} }
this.isSubmitting = true; this.isSubmitting = true;
const orderData = { uni.showLoading({ title: this.language === 'zh' ? '提交中...' : 'Submitting...' });
...this.applyData,
startDate: this.form.startDate, const payload = this.buildSubmitPayload()
endDate: this.form.endDate, return submitApplicationForm(payload, this.language)
stayNights: this.stayNights, .then(async (response) => {
sourceType: 'booking-intent' const applicationCode = extractSubmitApplicationCode(response)
}; if (!applicationCode) {
throw new Error(this.$tr('申请单编号缺失'))
let roomDataStr = encodeURIComponent(JSON.stringify(orderData)); }
saveActiveApplicationCode(applicationCode)
uni.showLoading({ title: this.language === 'zh' ? '正在生成订单...' : 'Creating Order...' }); const paymentContext = await this.resolvePaymentPreview(applicationCode).catch(() => ({
setTimeout(() => { referenceTypeNo: applicationCode,
uni.hideLoading(); billNo: '',
this.isSubmitting = false; paymentNo: '',
uni.redirectTo({ pendingPaymentOrder: null
url: `/pkgBooking/pay/pay?roomData=${roomDataStr}` }))
uni.hideLoading()
this.openDepositNotice({
...paymentContext,
referenceTypeNo: paymentContext.referenceTypeNo || applicationCode
})
})
.catch((error) => {
console.error('submit booking application failed', error)
uni.hideLoading()
uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('申请提交失败'),
icon: 'none'
})
})
.finally(() => {
this.isSubmitting = false;
}); });
}, 1000);
} }
} }
} }
@@ -347,4 +822,92 @@ export default {
opacity: 0.6; opacity: 0.6;
pointer-events: none; pointer-events: none;
} }
.deposit-notice-popup {
background: #FFFFFF;
border-radius: 32rpx 32rpx 0 0;
display: flex;
flex-direction: column;
height: 80vh;
}
.popup-header {
padding: 32rpx;
text-align: center;
border-bottom: 1px solid #F1F5F9;
}
.popup-title {
font-size: 36rpx;
font-weight: bold;
color: #1A2233;
}
.popup-subtitle {
margin-top: 12rpx;
font-size: 24rpx;
color: #64748B;
}
.popup-body {
flex: 1;
padding: 32rpx;
overflow-y: auto;
}
.notice-content {
padding-bottom: 40rpx;
}
.notice-item {
margin-bottom: 32rpx;
}
.notice-item-zh {
font-size: 28rpx;
color: #334155;
line-height: 1.6;
margin-bottom: 8rpx;
font-weight: 500;
}
.notice-item-en {
font-size: 24rpx;
color: #64748B;
line-height: 1.5;
}
.popup-footer {
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
border-top: 1px solid #F1F5F9;
background: #FFFFFF;
display: flex;
justify-content: center;
}
.notice-action-btn {
min-width: 420rpx;
max-width: 100%;
height: 92rpx;
padding: 0 40rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
background: linear-gradient(135deg, $brand-primary 0%, #0099CC 100%);
color: #ffffff;
box-shadow: 0 16rpx 34rpx rgba($brand-primary, 0.24);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.notice-action-btn:active {
transform: scale(0.98);
}
.notice-action-btn--disabled {
opacity: 0.6;
background: #94A3B8;
box-shadow: none;
}
</style> </style>

View File

@@ -1,31 +1,51 @@
<template> <template>
<view class="book-viewing-page tn-safe-area-inset-bottom"> <view class="book-viewing-page tn-safe-area-inset-bottom">
<tn-nav-bar :isBack="true" :backTitle="$tr('返回')" backgroundColor="#F0F6FF" :bottomShadow="false" :fixed="true"> <tn-nav-bar
:isBack="true"
:backTitle="$tr('返回')"
:backgroundColor="navBackgroundColor"
fontColor="#1A2233"
:bottomShadow="false"
:fixed="true"
>
<view class="nav-title">{{ $tr('预约现场带看') }}</view> <view class="nav-title">{{ $tr('预约现场带看') }}</view>
</tn-nav-bar> </tn-nav-bar>
<view class="content-wrapper" :style="{ paddingTop: vuex_custom_bar_height + 'px' }"> <view class="content-wrapper" :style="{ paddingTop: vuex_custom_bar_height + 'px' }">
<!-- 顶部信息卡片 -->
<!-- 房型/区域信息 --> <view class="hero-card" v-if="displayInfoTitle">
<view class="info-card tn-flex tn-flex-row-between tn-flex-col-center" v-if="displayInfoTitle"> <view class="hero-glow"></view>
<view class="info-left"> <view class="hero-inner tn-flex tn-flex-row-between tn-flex-col-center">
<view class="info-title">{{ displayInfoTitle }}</view> <view class="hero-content">
<view class="info-desc">{{ displayInfoDesc }}</view> <view class="hero-tag">
</view> <text class="tn-icon-company hero-tag-icon"></text>
<view class="info-icon"> <text class="hero-tag-text">{{ $tr('专属管家') }}</text>
<text class="tn-icon-company"></text> </view>
<view class="hero-title">{{ displayInfoTitle }}</view>
<view class="hero-desc">{{ displayInfoDesc }}</view>
</view>
<view class="hero-figure">
<view class="figure-ring figure-ring--outer"></view>
<view class="figure-ring figure-ring--inner"></view>
<text class="tn-icon-company figure-icon"></text>
</view>
</view> </view>
<view class="hero-accent"></view>
</view> </view>
<!-- 看房时间卡片 --> <!-- 看房时间卡片 -->
<view class="form-card"> <view class="form-card">
<view class="card-header tn-flex tn-flex-row-between tn-flex-col-bottom"> <view class="card-header tn-flex tn-flex-col-bottom">
<view class="header-left tn-flex tn-flex-col-bottom"> <view class="header-icon header-icon--primary">
<text class="card-title">{{ $tr('选择看房时间') }}</text> <text class="tn-icon-time"></text>
<text class="card-subtitle">{{ $tr('请选择您方便的时间段') }}</text> </view>
<view class="header-titles">
<view class="card-title">{{ $tr('选择看房时间') }}</view>
<view class="card-subtitle">{{ $tr('请选择您方便的时间段') }}</view>
</view> </view>
</view> </view>
<view class="section-label">{{ $tr('预约日期') }}</view>
<scroll-view class="date-scroll" scroll-x :show-scrollbar="false"> <scroll-view class="date-scroll" scroll-x :show-scrollbar="false">
<view class="date-flex"> <view class="date-flex">
<view <view
@@ -37,10 +57,12 @@
> >
<text class="date-week">{{ item.week }}</text> <text class="date-week">{{ item.week }}</text>
<text class="date-day">{{ item.label }}</text> <text class="date-day">{{ item.label }}</text>
<view class="date-indicator"></view>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
<view class="section-label section-label--time">{{ $tr('可约时段') }}</view>
<scroll-view class="time-scroll" scroll-x :show-scrollbar="false"> <scroll-view class="time-scroll" scroll-x :show-scrollbar="false">
<view class="time-flex"> <view class="time-flex">
<view <view
@@ -50,23 +72,43 @@
:class="{ 'time-slot--active': currentTimeIndex === index }" :class="{ 'time-slot--active': currentTimeIndex === index }"
@click="selectTimeSlot(index)" @click="selectTimeSlot(index)"
> >
{{ item }} <text class="time-slot-text">{{ item }}</text>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
<!-- 已选时间摘要 -->
<view v-if="currentTimeIndex !== -1" class="selected-summary tn-flex tn-flex-col-center">
<view class="summary-line"></view>
<view class="summary-body tn-flex-1 tn-flex tn-flex-row-between tn-flex-col-center">
<view class="summary-info">
<text class="summary-label">{{ $tr('已选时间') }}</text>
<text class="summary-value">{{ selectedDateTimeText }}</text>
</view>
<view class="summary-badge">
<text class="tn-icon-check"></text>
</view>
</view>
</view>
</view> </view>
<!-- 租住需求卡片 --> <!-- 租住需求卡片 -->
<view class="form-card"> <view class="form-card">
<view class="card-header"> <view class="card-header tn-flex tn-flex-col-bottom">
<view class="header-left tn-flex tn-flex-col-bottom"> <view class="header-icon header-icon--luxury">
<text class="card-title">{{ $tr('您的租住需求') }}</text> <text class="tn-icon-like"></text>
<text class="card-subtitle">{{ $tr('帮助管家更好地为您推荐房源') }}</text> </view>
<view class="header-titles">
<view class="card-title">{{ $tr('您的租住需求') }}</view>
<view class="card-subtitle">{{ $tr('帮助管家更好地为您推荐房源') }}</view>
</view> </view>
</view> </view>
<view class="field-item"> <view class="field-item">
<view class="field-label">{{ $tr('意向房型') }}</view> <view class="field-label">
<text class="field-dot field-dot--primary"></text>
<text>{{ $tr('意向房型') }}</text>
</view>
<room-type-selector <room-type-selector
v-model="formData.intendedRoomTypeCode" v-model="formData.intendedRoomTypeCode"
:initial-area-code="formData.intendedAreaCode" :initial-area-code="formData.intendedAreaCode"
@@ -75,7 +117,10 @@
</view> </view>
<view class="field-item"> <view class="field-item">
<view class="field-label">{{ $tr('预计租住时长') }}</view> <view class="field-label">
<text class="field-dot field-dot--luxury"></text>
<text>{{ $tr('预计租住时长') }}</text>
</view>
<view class="tag-grid"> <view class="tag-grid">
<view <view
v-for="(item, index) in durationOptions" v-for="(item, index) in durationOptions"
@@ -90,9 +135,14 @@
</view> </view>
<view class="field-item"> <view class="field-item">
<view class="field-label">{{ $tr('租金预算 (元/月)') }}</view> <view class="field-label">
<text class="field-dot field-dot--primary"></text>
<text>{{ $tr('租金预算 (元/月)') }}</text>
</view>
<view class="input-box tn-flex tn-flex-col-center"> <view class="input-box tn-flex tn-flex-col-center">
<text class="tn-icon-money input-icon"></text> <view class="input-icon-wrap">
<text class="tn-icon-money input-icon"></text>
</view>
<input <input
class="custom-input" class="custom-input"
v-model="formData.budget" v-model="formData.budget"
@@ -103,10 +153,15 @@
</view> </view>
</view> </view>
<view class="field-item"> <view class="field-item field-item--last">
<view class="field-label">{{ $tr('工作/通勤地点') }}</view> <view class="field-label">
<text class="field-dot field-dot--muted"></text>
<text>{{ $tr('工作/通勤地点') }}</text>
</view>
<view class="input-box tn-flex tn-flex-col-center"> <view class="input-box tn-flex tn-flex-col-center">
<text class="tn-icon-location input-icon"></text> <view class="input-icon-wrap">
<text class="tn-icon-location input-icon"></text>
</view>
<input <input
class="custom-input" class="custom-input"
v-model="formData.workplace" v-model="formData.workplace"
@@ -114,16 +169,26 @@
:placeholder="$tr('请输入主要通勤地点')" :placeholder="$tr('请输入主要通勤地点')"
placeholder-style="color: #A1B0C4;" placeholder-style="color: #A1B0C4;"
/> />
<view class="input-divider"></view>
<view class="map-btn" @click="chooseWorkplaceLocation">
<text class="tn-icon-map map-btn-icon"></text>
</view>
</view> </view>
</view> </view>
</view> </view>
<!-- 底部占位,避免被 fixed 按钮遮挡 -->
<view class="footer-spacer"></view>
</view> </view>
<!-- 底部操作区 --> <!-- 底部操作区 -->
<view class="footer-bar tn-safe-area-inset-bottom"> <view class="footer-bar tn-safe-area-inset-bottom">
<view class="submit-btn" @click="handleSubmit"> <view class="footer-inner">
<text>{{ $tr('提交预约') }}</text> <view class="submit-btn" :class="{ 'submit-btn--active': canSubmit }" @click="handleSubmit">
<text class="submit-btn-icon tn-icon-send"></text>
<text class="submit-btn-text">{{ $tr('提交预约') }}</text>
</view>
<view class="footer-hint">{{ $tr('提交后专属管家将尽快与您联系') }}</view>
</view> </view>
</view> </view>
</view> </view>
@@ -191,6 +256,9 @@ export default {
language() { language() {
return this.$getLanguage() return this.$getLanguage()
}, },
navBackgroundColor() {
return this.scrollTop > 20 ? '#F8FAFF' : 'transparent'
},
displayInfoTitle() { displayInfoTitle() {
if (!this.sourceData) { if (!this.sourceData) {
return '' return ''
@@ -214,6 +282,20 @@ export default {
: (this.sourceData.areaLabel || this.sourceData.areaName || this.sourceData.areaLabelEn || this.sourceData.areaNameEn || '') : (this.sourceData.areaLabel || this.sourceData.areaName || this.sourceData.areaLabelEn || this.sourceData.areaNameEn || '')
const meta = [bannerCopy, areaLabel, localizePayMethodText(this.sourceData.payMethod, this.language)].filter(Boolean).join(' · ') const meta = [bannerCopy, areaLabel, localizePayMethodText(this.sourceData.payMethod, this.language)].filter(Boolean).join(' · ')
return meta || this.$tr('专属管家为您提供一对一带看服务') return meta || this.$tr('专属管家为您提供一对一带看服务')
},
selectedDateTimeText() {
if (this.currentDateIndex === -1 || this.currentTimeIndex === -1) {
return ''
}
const dateItem = this.dateList[this.currentDateIndex]
const timeItem = this.timeSlots[this.currentTimeIndex]
if (!dateItem || !timeItem) {
return ''
}
return `${dateItem.week} ${dateItem.label} ${timeItem}`
},
canSubmit() {
return this.currentTimeIndex !== -1 && this.formData.duration
} }
}, },
data() { data() {
@@ -236,9 +318,13 @@ export default {
workplace: '' workplace: ''
}, },
sourceData: null, sourceData: null,
isSubmitting: false isSubmitting: false,
scrollTop: 0
} }
}, },
onPageScroll(event) {
this.scrollTop = (event && event.scrollTop) || 0
},
onLoad(options) { onLoad(options) {
this.syncDurationOptions() this.syncDurationOptions()
this.generateDates() this.generateDates()
@@ -304,13 +390,13 @@ export default {
this.$tr('周五'), this.$tr('周五'),
this.$tr('周六') this.$tr('周六')
] ]
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
const d = new Date(today) const d = new Date(today)
d.setDate(today.getDate() + i) d.setDate(today.getDate() + i)
const month = String(d.getMonth() + 1).padStart(2, '0') const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0') const day = String(d.getDate()).padStart(2, '0')
let weekStr = weeks[d.getDay()] let weekStr = weeks[d.getDay()]
if (i === 0) weekStr = this.$tr('今天'); if (i === 0) weekStr = this.$tr('今天');
else if (i === 1) weekStr = this.$tr('明天'); else if (i === 1) weekStr = this.$tr('明天');
@@ -350,6 +436,26 @@ export default {
selectDuration(item) { selectDuration(item) {
this.formData.duration = item this.formData.duration = item
}, },
chooseWorkplaceLocation() {
uni.chooseLocation({
success: (res) => {
const locationText = res.address || res.name || ''
if (locationText) {
this.formData.workplace = locationText
}
},
fail: (error) => {
const errMsg = (error && error.errMsg) || ''
if (errMsg.includes('cancel') || errMsg.includes('Cancel')) {
return
}
uni.showToast({
title: this.$tr('地图选择失败,请手动输入'),
icon: 'none'
})
}
})
},
handleSubmit() { handleSubmit() {
if (this.isSubmitting) { if (this.isSubmitting) {
return return
@@ -391,8 +497,8 @@ export default {
showCancel: false, showCancel: false,
confirmColor: '#01BEFF', confirmColor: '#01BEFF',
success: () => { success: () => {
uni.redirectTo({ uni.reLaunch({
url: '/pkgService/my_application/my_application' url: '/pages/index/index?pageId=stay-service'
}) })
} }
}) })
@@ -414,174 +520,387 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* 设计令牌 */
$hero-bg-start: #0A1628;
$hero-bg-end: #1E3A5F;
$luxury-gold: #D8B56A;
$luxury-gold-soft: rgba(216, 181, 106, 0.12);
$surface: #FFFFFF;
$bg-page: #F5F9FF;
$text-primary: #1A2233;
$text-secondary: #6A7A99;
$text-tertiary: #A1B0C4;
$border-soft: rgba($brand-primary, 0.08);
$shadow-card: 0 16rpx 48rpx rgba(26, 34, 51, 0.06);
$shadow-active: 0 8rpx 24rpx rgba($brand-primary, 0.22);
.book-viewing-page { .book-viewing-page {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%); background: linear-gradient(180deg, #EAF4FF 0%, $bg-page 30%, #FFFFFF 100%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.nav-title { .nav-title {
font-size: 34rpx; font-size: 34rpx;
font-weight: bold; font-weight: 700;
color: #1D2129; color: $text-primary;
letter-spacing: 2rpx;
} }
.content-wrapper { .content-wrapper {
flex: 1; flex: 1;
padding-bottom: calc(140rpx + env(safe-area-inset-bottom)); padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
} }
/* 顶部信息卡片 */ /* 顶部信息卡片(Hero) */
.info-card { .hero-card {
margin: 30rpx; position: relative;
padding: 40rpx; margin: 24rpx 30rpx 30rpx;
border-radius: 24rpx; border-radius: 36rpx;
background: linear-gradient(135deg, #01BEFF 0%, #0099FF 100%); background: linear-gradient(135deg, $hero-bg-start 0%, $hero-bg-end 100%);
box-shadow: 0 16rpx 32rpx rgba(1, 190, 255, 0.2); box-shadow: 0 24rpx 60rpx rgba(10, 22, 40, 0.22);
color: #ffffff; overflow: hidden;
color: #FFFFFF;
} }
.info-left { .hero-glow {
position: absolute;
top: -80rpx;
right: -60rpx;
width: 280rpx;
height: 280rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba($brand-primary, 0.35) 0%, transparent 70%);
filter: blur(20rpx);
}
.hero-inner {
position: relative;
z-index: 2;
padding: 44rpx 36rpx;
}
.hero-content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.info-title { .hero-tag {
font-size: 36rpx; display: inline-flex;
font-weight: bold; align-items: center;
margin-bottom: 12rpx; padding: 8rpx 18rpx;
letter-spacing: 2rpx; background: rgba(255, 255, 255, 0.12);
line-height: 1.3; border: 1rpx solid rgba(255, 255, 255, 0.18);
display: -webkit-box; border-radius: 24rpx;
-webkit-line-clamp: 2; margin-bottom: 20rpx;
-webkit-box-orient: vertical; backdrop-filter: blur(8rpx);
overflow: hidden;
} }
.info-desc { .hero-tag-icon {
font-size: 24rpx; font-size: 22rpx;
opacity: 0.9; color: $luxury-gold;
margin-right: 8rpx;
}
.hero-tag-text {
font-size: 22rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.95);
}
.hero-title {
font-size: 38rpx;
font-weight: 700;
line-height: 1.35;
margin-bottom: 16rpx;
letter-spacing: 1rpx; letter-spacing: 1rpx;
line-height: 1.5;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.info-icon { .hero-desc {
font-size: 80rpx; font-size: 24rpx;
opacity: 0.2; color: rgba(255, 255, 255, 0.72);
margin-left: 20rpx; line-height: 1.55;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.hero-figure {
position: relative;
width: 136rpx;
height: 136rpx;
margin-left: 24rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.figure-ring {
position: absolute;
border-radius: 50%;
border: 2rpx solid rgba(255, 255, 255, 0.1);
&--outer {
width: 136rpx;
height: 136rpx;
}
&--inner {
width: 96rpx;
height: 96rpx;
border-color: rgba($luxury-gold, 0.35);
}
}
.figure-icon {
position: relative;
z-index: 2;
font-size: 56rpx;
color: rgba(255, 255, 255, 0.9);
}
.hero-accent {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4rpx;
background: linear-gradient(90deg, transparent 0%, $luxury-gold 50%, transparent 100%);
opacity: 0.7;
} }
/* 通用表单卡片 */ /* 通用表单卡片 */
.form-card { .form-card {
margin: 0 30rpx 30rpx; margin: 0 30rpx 30rpx;
padding: 40rpx 30rpx; padding: 36rpx 32rpx;
border-radius: 24rpx; border-radius: 32rpx;
background: #ffffff; background: $surface;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.03); box-shadow: $shadow-card;
border: 1rpx solid $border-soft;
} }
.card-header { .card-header {
margin-bottom: 30rpx; margin-bottom: 32rpx;
}
.header-icon {
width: 64rpx;
height: 64rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
flex-shrink: 0;
&--primary {
background: rgba($brand-primary, 0.1);
color: $brand-primary;
}
&--luxury {
background: $luxury-gold-soft;
color: $luxury-gold;
}
text {
font-size: 32rpx;
}
}
.header-titles {
flex: 1;
min-width: 0;
} }
.card-title { .card-title {
font-size: 34rpx; font-size: 32rpx;
font-weight: bold; font-weight: 700;
color: #1D2129; color: $text-primary;
margin-right: 16rpx; margin-bottom: 6rpx;
letter-spacing: 1rpx;
} }
.card-subtitle { .card-subtitle {
font-size: 24rpx; font-size: 24rpx;
color: #86909C; color: $text-secondary;
}
.section-label {
font-size: 26rpx;
font-weight: 600;
color: $text-primary;
margin-bottom: 20rpx;
&--time {
margin-top: 36rpx;
}
} }
/* 日期选择 */ /* 日期选择 */
.date-scroll { .date-scroll {
width: 100%; width: 100%;
white-space: nowrap; white-space: nowrap;
margin-bottom: 30rpx;
} }
.date-flex { .date-flex {
display: inline-flex; display: inline-flex;
gap: 20rpx; gap: 18rpx;
padding-bottom: 10rpx; padding: 6rpx 4rpx 16rpx;
} }
.date-item { .date-item {
width: 110rpx; width: 120rpx;
height: 130rpx; height: 144rpx;
background: #F4F8FF; background: #F4F8FF;
border-radius: 16rpx; border-radius: 24rpx;
flex-shrink: 0; flex-shrink: 0;
border: 2rpx solid transparent; border: 2rpx solid transparent;
transition: all 0.3s ease; transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
position: relative;
} }
.date-item--active { .date-item--active {
background: #EAF0FF; background: linear-gradient(135deg, $brand-primary 0%, #0099FF 100%);
border-color: #01BEFF; border-color: rgba($brand-primary, 0.35);
box-shadow: $shadow-active;
transform: translateY(-4rpx);
} }
.date-week { .date-week {
font-size: 24rpx; font-size: 24rpx;
color: #86909C; color: $text-secondary;
margin-bottom: 8rpx; margin-bottom: 10rpx;
transition: all 0.3s ease; transition: color 0.25s ease;
} }
.date-item--active .date-week { .date-item--active .date-week {
color: #01BEFF; color: rgba(255, 255, 255, 0.9);
font-weight: bold;
} }
.date-day { .date-day {
font-size: 28rpx; font-size: 30rpx;
color: #1D2129; color: $text-primary;
font-weight: 500; font-weight: 700;
transition: all 0.3s ease; transition: color 0.25s ease;
} }
.date-item--active .date-day { .date-item--active .date-day {
color: #01BEFF; color: #FFFFFF;
}
.date-indicator {
position: absolute;
bottom: 14rpx;
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: $brand-primary;
opacity: 0;
transform: scale(0);
transition: all 0.25s ease;
}
.date-item--active .date-indicator {
background: #FFFFFF;
opacity: 1;
transform: scale(1);
} }
/* 时间段选择 */ /* 时间段选择 */
.time-scroll { .time-scroll {
width: 100%; width: 100%;
white-space: nowrap;
} }
.time-flex { .time-flex {
display: inline-flex; display: inline-flex;
gap: 20rpx; gap: 18rpx;
padding-bottom: 8rpx; padding: 6rpx 4rpx 16rpx;
} }
.time-slot { .time-slot {
width: 220rpx; width: 200rpx;
height: 72rpx; height: 80rpx;
line-height: 72rpx; display: flex;
text-align: center; align-items: center;
justify-content: center;
background: #F4F8FF; background: #F4F8FF;
border-radius: 12rpx; border-radius: 40rpx;
font-size: 26rpx; font-size: 26rpx;
color: #4E5969; color: $text-secondary;
border: 2rpx solid transparent; border: 2rpx solid transparent;
transition: all 0.3s ease; transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
flex-shrink: 0; flex-shrink: 0;
} }
.time-slot--active { .time-slot--active {
background: #01BEFF; background: linear-gradient(135deg, $brand-primary 0%, #0099FF 100%);
color: #ffffff; color: #FFFFFF;
font-weight: bold; font-weight: 600;
box-shadow: 0 8rpx 16rpx rgba(1, 190, 255, 0.2); border-color: rgba($brand-primary, 0.3);
box-shadow: $shadow-active;
}
.time-slot-text {
letter-spacing: 1rpx;
}
/* 已选时间摘要 */
.selected-summary {
margin-top: 10rpx;
padding: 24rpx;
background: linear-gradient(135deg, rgba($brand-primary, 0.06) 0%, rgba($brand-primary, 0.02) 100%);
border-radius: 20rpx;
border: 1rpx solid rgba($brand-primary, 0.1);
}
.summary-line {
width: 4rpx;
height: 40rpx;
border-radius: 2rpx;
background: $brand-primary;
margin-right: 20rpx;
}
.summary-info {
display: flex;
flex-direction: column;
}
.summary-label {
font-size: 22rpx;
color: $text-secondary;
margin-bottom: 6rpx;
}
.summary-value {
font-size: 28rpx;
font-weight: 700;
color: $text-primary;
}
.summary-badge {
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: $brand-primary;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.25);
text {
font-size: 22rpx;
color: #FFFFFF;
}
} }
/* 需求字段 */ /* 需求字段 */
@@ -589,15 +908,38 @@ export default {
margin-bottom: 40rpx; margin-bottom: 40rpx;
} }
.field-item:last-child { .field-item--last {
margin-bottom: 0; margin-bottom: 0;
} }
.field-label { .field-label {
display: flex;
align-items: center;
font-size: 28rpx; font-size: 28rpx;
color: #1D2129; font-weight: 700;
font-weight: bold; color: $text-primary;
margin-bottom: 24rpx; margin-bottom: 22rpx;
}
.field-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
margin-right: 12rpx;
&--primary {
background: $brand-primary;
box-shadow: 0 0 10rpx rgba($brand-primary, 0.45);
}
&--luxury {
background: $luxury-gold;
box-shadow: 0 0 10rpx rgba($luxury-gold, 0.45);
}
&--muted {
background: $text-tertiary;
}
} }
/* 标签选择 */ /* 标签选择 */
@@ -608,42 +950,96 @@ export default {
} }
.tag-item { .tag-item {
padding: 0 32rpx; padding: 0 34rpx;
height: 68rpx; height: 72rpx;
line-height: 68rpx; line-height: 72rpx;
background: #F4F8FF; background: #F4F8FF;
border-radius: 34rpx; border-radius: 36rpx;
font-size: 26rpx; font-size: 26rpx;
color: #4E5969; color: $text-secondary;
border: 2rpx solid transparent; border: 2rpx solid transparent;
transition: all 0.3s ease; transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
.tag-item--active { .tag-item--active {
background: #EAF0FF; background: linear-gradient(135deg, rgba($brand-primary, 0.1) 0%, rgba($brand-primary, 0.06) 100%);
border-color: #01BEFF; border-color: rgba($brand-primary, 0.35);
color: #01BEFF; color: $brand-primary;
font-weight: bold; font-weight: 600;
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.12);
} }
/* 输入框 */ /* 输入框 */
.input-box { .input-box {
background: #F4F8FF; background: #F4F8FF;
border-radius: 20rpx;
height: 96rpx;
padding: 0 26rpx;
border: 2rpx solid transparent;
transition: all 0.25s ease;
}
.input-box:focus-within {
background: #FFFFFF;
border-color: rgba($brand-primary, 0.25);
box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.08);
}
.input-icon-wrap {
width: 52rpx;
height: 52rpx;
border-radius: 16rpx; border-radius: 16rpx;
height: 88rpx; background: #FFFFFF;
padding: 0 30rpx; display: flex;
align-items: center;
justify-content: center;
margin-right: 18rpx;
box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.04);
} }
.input-icon { .input-icon {
font-size: 36rpx; font-size: 28rpx;
color: #A1B0C4; color: $brand-primary;
margin-right: 16rpx; }
.input-divider {
width: 2rpx;
height: 36rpx;
background: rgba($text-tertiary, 0.25);
margin: 0 18rpx;
}
.map-btn {
width: 52rpx;
height: 52rpx;
border-radius: 16rpx;
background: rgba($brand-primary, 0.1);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.25s ease;
}
.map-btn:active {
background: rgba($brand-primary, 0.2);
transform: scale(0.95);
}
.map-btn-icon {
font-size: 28rpx;
color: $brand-primary;
} }
.custom-input { .custom-input {
flex: 1; flex: 1;
font-size: 28rpx; font-size: 28rpx;
color: #1D2129; color: $text-primary;
font-weight: 500;
}
.footer-spacer {
height: 180rpx;
} }
/* 底部按钮 */ /* 底部按钮 */
@@ -652,30 +1048,61 @@ export default {
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 100%; width: 100%;
background: #ffffff; background: rgba(255, 255, 255, 0.96);
padding: 24rpx 40rpx; backdrop-filter: blur(20rpx);
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.04); padding: 24rpx 40rpx calc(24rpx + env(safe-area-inset-bottom));
box-shadow: 0 -8rpx 32rpx rgba(26, 34, 51, 0.06);
z-index: 100; z-index: 100;
} }
.footer-inner {
width: 100%;
}
.submit-btn { .submit-btn {
width: 100%; width: 100%;
height: 96rpx; height: 96rpx;
background: linear-gradient(135deg, #01BEFF 0%, #0099FF 100%);
border-radius: 48rpx; border-radius: 48rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #ffffff; background: linear-gradient(135deg, #A1B0C4 0%, #86909C 100%);
color: #FFFFFF;
font-size: 32rpx; font-size: 32rpx;
font-weight: bold; font-weight: 700;
letter-spacing: 2rpx; letter-spacing: 2rpx;
box-shadow: 0 12rpx 24rpx rgba(1, 190, 255, 0.25); box-shadow: 0 8rpx 24rpx rgba(134, 144, 156, 0.2);
transition: all 0.3s ease; transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
.submit-btn:active { .submit-btn--active {
background: linear-gradient(135deg, $brand-primary 0%, #0099FF 100%);
box-shadow: 0 16rpx 36rpx rgba($brand-primary, 0.32);
}
.submit-btn--active:active {
transform: scale(0.98); transform: scale(0.98);
opacity: 0.9; box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.25);
}
.submit-btn-icon {
font-size: 32rpx;
margin-right: 12rpx;
}
.submit-btn-text {
line-height: 1;
}
.footer-hint {
text-align: center;
font-size: 22rpx;
color: $text-tertiary;
margin-top: 16rpx;
}
/* 导航栏背景色滚动过渡 */
.book-viewing-page ::v-deep .tn-custom-nav-bar__bar {
transition: background-color 0.3s ease, box-shadow 0.3s ease;
} }
</style> </style>

View File

@@ -1,40 +1,53 @@
<template> <template>
<view class="room-type-selector"> <view class="room-type-selector">
<view class="selector-header tn-flex tn-flex-row-between tn-flex-col-center" @click="openSelector"> <view class="selector-card tn-flex tn-flex-row-between tn-flex-col-center" @click="openSelector">
<view class="selected-info"> <view class="selected-info">
<view class="selected-label">{{ $tr('意向房型') }}</view>
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }"> <view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }">
{{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : $tr('请选择房型') }} {{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : $tr('请选择意向房型') }}
</view> </view>
<view class="selected-area tn-text-ellipsis" v-if="selectedArea"> <view class="selected-area tn-text-ellipsis" v-if="selectedArea">
<text class="tn-icon-location area-icon"></text>
{{ getAreaDisplayName(selectedArea) }} {{ getAreaDisplayName(selectedArea) }}
</view> </view>
</view> </view>
<text class="tn-icon-right tn-color-gray"></text> <view class="selector-arrow">
<text class="tn-icon-right"></text>
</view>
</view> </view>
<tn-popup v-model="showPopup" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true"> <tn-popup v-model="showPopup" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true">
<view class="popup-content"> <view class="popup-content">
<view class="popup-header"> <view class="popup-header">
<text class="popup-title">{{ $tr('选择意向房型') }}</text> <view class="popup-title">{{ $tr('选择意向房型') }}</view>
<view class="popup-subtitle">{{ $tr('选择您心仪的房型与区域') }}</view>
</view> </view>
<scroll-view scroll-y class="area-list-scroll"> <scroll-view scroll-y class="area-list-scroll">
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center"> <view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
<tn-loading mode="circle"></tn-loading> <tn-loading mode="circle" :size="48"></tn-loading>
<view class="tn-margin-top-sm tn-color-gray">{{ $tr('房型加载中...') }}</view> <view class="tn-margin-top tn-color-gray">{{ $tr('房型加载中...') }}</view>
</view> </view>
<view v-else-if="!areaList.length" class="empty-state tn-padding-xl tn-text-center"> <view v-else-if="!areaList.length" class="empty-state tn-padding-xl tn-text-center">
<text class="tn-color-gray">{{ $tr('暂无可选房型') }}</text> <view class="empty-icon">
<text class="tn-icon-empty-data"></text>
</view>
<view class="empty-title">{{ $tr('暂无可选房型') }}</view>
<view class="empty-desc">{{ $tr('当前暂无可用房型,请稍后再试') }}</view>
</view> </view>
<view v-else class="area-group" v-for="(area, index) in areaList" :key="area.code"> <view v-else class="area-group" v-for="(area, index) in areaList" :key="area.code">
<view class="area-title tn-flex tn-flex-row-between tn-flex-col-center" @click="toggleArea(index)"> <view class="area-title tn-flex tn-flex-row-between tn-flex-col-center" @click="toggleArea(index)">
<view class="tn-flex tn-flex-col-center"> <view class="area-title-left tn-flex tn-flex-col-center">
<text class="area-dot"></text> <view class="area-icon-wrap">
<text>{{ getAreaDisplayName(area) }}</text> <text class="tn-icon-home"></text>
</view>
<text class="area-title-text">{{ getAreaDisplayName(area) }}</text>
</view>
<view class="area-toggle">
<text :class="collapsedAreas[index] ? 'tn-icon-down' : 'tn-icon-up'"></text>
</view> </view>
<text :class="collapsedAreas[index] ? 'tn-icon-down' : 'tn-icon-up'" class="tn-color-gray"></text>
</view> </view>
<view class="room-type-grid" v-show="!collapsedAreas[index]"> <view class="room-type-grid" v-show="!collapsedAreas[index]">
<view <view
@@ -45,7 +58,15 @@
@click="handleSelect(area, room)" @click="handleSelect(area, room)"
> >
<view class="room-name tn-text-ellipsis">{{ getRoomTypeDisplayName(room) }}</view> <view class="room-name tn-text-ellipsis">{{ getRoomTypeDisplayName(room) }}</view>
<view class="room-price" v-if="room.price">¥{{ getPriceValue(room.price) }}{{ $tr('/月') }}</view> <view class="room-price" v-if="room.price">
<text class="price-symbol">¥</text>
<text class="price-value">{{ getPriceValue(room.price) }}</text>
<text class="price-unit">{{ $tr('/月') }}</text>
</view>
<view v-else class="room-price room-price--empty">{{ $tr('价格面议') }}</view>
<view class="active-mark">
<text class="tn-icon-check"></text>
</view>
</view> </view>
</view> </view>
</view> </view>
@@ -59,25 +80,6 @@
import { areaPagination } from '@/api/index.js' import { areaPagination } from '@/api/index.js'
import { normalizeAreaItem } from '../../../utils/select-room-normalizers' import { normalizeAreaItem } from '../../../utils/select-room-normalizers'
const I18N = {
zh: {
emptyRoomTypes: '暂无可选房型',
loadFailed: '房型加载失败',
loadingRoomTypes: '房型加载中...',
popupTitle: '选择意向房型',
priceUnit: '/月',
selectRoomType: '请选择房型'
},
en: {
emptyRoomTypes: 'No room types available',
loadFailed: 'Failed to load room types',
loadingRoomTypes: 'Loading room types...',
popupTitle: 'Select Room Type',
priceUnit: '/month',
selectRoomType: 'Select room type'
}
}
export default { export default {
name: 'BookViewingRoomTypeSelector', name: 'BookViewingRoomTypeSelector',
props: { props: {
@@ -230,17 +232,30 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
$luxury-gold: #D8B56A;
$text-primary: #1A2233;
$text-secondary: #6A7A99;
$text-tertiary: #A1B0C4;
$surface: #FFFFFF;
$bg-popup: #F8FAFF;
.room-type-selector { .room-type-selector {
width: 100%; width: 100%;
} }
.selector-header { .selector-card {
background: #FFFFFF; background: $surface;
border-radius: 20rpx; border-radius: 20rpx;
padding: 26rpx 32rpx; padding: 26rpx 30rpx;
border: 1rpx solid rgba($brand-primary, 0.15); border: 2rpx solid rgba($brand-primary, 0.12);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05); box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.05);
min-height: 100rpx; min-height: 120rpx;
transition: all 0.25s ease;
}
.selector-card:active {
background: #FAFCFF;
border-color: rgba($brand-primary, 0.25);
} }
.selected-info { .selected-info {
@@ -248,72 +263,175 @@ export default {
overflow: hidden; overflow: hidden;
} }
.selected-label {
font-size: 22rpx;
color: $text-tertiary;
margin-bottom: 8rpx;
}
.selected-name { .selected-name {
font-size: 28rpx; font-size: 30rpx;
color: #1A2233; color: $text-primary;
font-weight: 600; font-weight: 700;
&.is-empty { &.is-empty {
color: #A0B0C0; color: $text-tertiary;
font-weight: normal; font-weight: normal;
} }
} }
.selected-area { .selected-area {
font-size: 24rpx;
color: $text-secondary;
margin-top: 10rpx;
display: flex;
align-items: center;
}
.area-icon {
font-size: 22rpx; font-size: 22rpx;
color: #6A7A99; margin-right: 6rpx;
margin-top: 6rpx; color: $brand-primary;
}
.selector-arrow {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: #F4F8FF;
display: flex;
align-items: center;
justify-content: center;
margin-left: 20rpx;
flex-shrink: 0;
text {
font-size: 28rpx;
color: $text-secondary;
}
} }
.popup-content { .popup-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
background: #F8FAFF; background: $bg-popup;
} }
.popup-header { .popup-header {
padding: 40rpx 30rpx 20rpx; padding: 40rpx 30rpx 28rpx;
text-align: center; text-align: center;
border-bottom: 1px dashed rgba($brand-primary, 0.15); background: $surface;
background: #FFFFFF; border-bottom: 1rpx solid rgba($brand-primary, 0.08);
} }
.popup-title { .popup-title {
font-size: 32rpx; font-size: 34rpx;
font-weight: 700; font-weight: 700;
color: #1A2233; color: $text-primary;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
}
.popup-subtitle {
font-size: 24rpx;
color: $text-secondary;
} }
.area-list-scroll { .area-list-scroll {
flex: 1; flex: 1;
height: 0; height: 0;
padding: 20rpx 30rpx 60rpx; padding: 24rpx 30rpx 60rpx;
}
.loading-state,
.empty-state {
padding-top: 120rpx;
}
.empty-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba($brand-primary, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24rpx;
text {
font-size: 56rpx;
color: $brand-primary;
}
}
.empty-title {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 24rpx;
color: $text-secondary;
} }
.area-group { .area-group {
margin-bottom: 30rpx; margin-bottom: 28rpx;
background: #FFFFFF; background: $surface;
border-radius: 20rpx; border-radius: 24rpx;
padding: 24rpx; padding: 28rpx;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.04); box-shadow: 0 8rpx 28rpx rgba(26, 34, 51, 0.04);
border: 1rpx solid rgba($brand-primary, 0.05); border: 1rpx solid rgba($brand-primary, 0.06);
} }
.area-title { .area-title {
font-size: 28rpx; margin-bottom: 24rpx;
font-weight: 600;
color: #1A2233;
margin-bottom: 20rpx;
} }
.area-dot { .area-title-left {
width: 12rpx; flex: 1;
height: 12rpx; min-width: 0;
}
.area-icon-wrap {
width: 48rpx;
height: 48rpx;
border-radius: 14rpx;
background: rgba($brand-primary, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
flex-shrink: 0;
text {
font-size: 26rpx;
color: $brand-primary;
}
}
.area-title-text {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
}
.area-toggle {
width: 48rpx;
height: 48rpx;
border-radius: 50%; border-radius: 50%;
background: $brand-primary; background: #F4F8FF;
margin-right: 12rpx; display: flex;
box-shadow: 0 0 8rpx rgba($brand-primary, 0.4); align-items: center;
justify-content: center;
flex-shrink: 0;
text {
font-size: 24rpx;
color: $text-secondary;
}
} }
.room-type-grid { .room-type-grid {
@@ -323,17 +441,18 @@ export default {
} }
.room-type-item { .room-type-item {
padding: 20rpx; position: relative;
border-radius: 16rpx; padding: 24rpx 20rpx;
background: #FFFFFF; border-radius: 20rpx;
border: 1rpx solid rgba($brand-primary, 0.15); background: #FAFCFF;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05); border: 2rpx solid rgba($brand-primary, 0.1);
transition: all 0.3s ease; transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
&.is-active { &.is-active {
background: #FFFFFF; background: #FFFFFF;
border-color: $brand-primary; border-color: $brand-primary;
box-shadow: 0 4rpx 16rpx rgba($brand-primary, 0.1); box-shadow: 0 8rpx 24rpx rgba($brand-primary, 0.14);
.room-name { .room-name {
color: $brand-primary; color: $brand-primary;
@@ -342,18 +461,66 @@ export default {
.room-price { .room-price {
color: $brand-primary; color: $brand-primary;
} }
.active-mark {
transform: scale(1);
opacity: 1;
}
} }
} }
.room-name { .room-name {
font-size: 26rpx; font-size: 28rpx;
font-weight: 600; font-weight: 700;
color: #1A2233; color: $text-primary;
margin-bottom: 8rpx; margin-bottom: 10rpx;
} }
.room-price { .room-price {
font-size: 24rpx;
color: $text-secondary;
display: flex;
align-items: baseline;
&--empty {
font-size: 22rpx;
color: $text-tertiary;
}
}
.price-symbol {
font-size: 22rpx; font-size: 22rpx;
color: #6A7A99; margin-right: 2rpx;
}
.price-value {
font-size: 28rpx;
font-weight: 700;
margin-right: 4rpx;
}
.price-unit {
font-size: 22rpx;
}
.active-mark {
position: absolute;
top: 12rpx;
right: 12rpx;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: $brand-primary;
display: flex;
align-items: center;
justify-content: center;
transform: scale(0);
opacity: 0;
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
text {
font-size: 20rpx;
color: #FFFFFF;
}
} }
</style> </style>

View File

@@ -1,11 +1,14 @@
<template> <template>
<view class="section-panel" v-if="facilityList && facilityList.length"> <view class="section-panel" v-if="facilityList && facilityList.length">
<view class="section-header"> <view class="section-header">
<view class="title-bar"></view>
<text class="section-title">{{ $tr('配套设施') }}</text> <text class="section-title">{{ $tr('配套设施') }}</text>
</view> </view>
<view class="facility-grid tn-flex tn-flex-wrap"> <view class="facility-grid tn-flex tn-flex-wrap">
<view class="facility-item tn-flex tn-flex-direction-column tn-flex-col-center" v-for="(item, index) in facilityList" :key="index"> <view class="facility-item tn-flex tn-flex-direction-column tn-flex-col-center" v-for="(item, index) in facilityList" :key="index">
<text :class="item.icon" class="facility-icon"></text> <view class="icon-wrap tn-flex tn-flex-row-center tn-flex-col-center">
<text :class="item.icon" class="facility-icon"></text>
</view>
<text class="facility-name">{{ item.name }}</text> <text class="facility-name">{{ item.name }}</text>
</view> </view>
</view> </view>
@@ -29,25 +32,39 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
$navy-deep: #1E3A5F;
$gold: #C9A96E;
$gold-light: #D4BC8C;
$text-primary: #0F172A;
$text-secondary: #64748B;
.section-panel { .section-panel {
background: rgba(255, 255, 255, 0.08); background: #FFFFFF;
backdrop-filter: blur(20px); border: 1rpx solid rgba($navy-deep, 0.08);
-webkit-backdrop-filter: blur(20px); box-shadow: 0 8rpx 28rpx rgba(30, 58, 95, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.05);
margin: 24rpx 32rpx 0; margin: 24rpx 32rpx 0;
padding: 32rpx; padding: 32rpx;
border-radius: 24rpx; border-radius: 28rpx;
} }
.section-header { .section-header {
margin-bottom: 24rpx; margin-bottom: 28rpx;
display: flex;
align-items: center;
}
.title-bar {
width: 6rpx;
height: 32rpx;
background: linear-gradient(180deg, $gold 0%, $gold-light 100%);
border-radius: 999rpx;
margin-right: 16rpx;
} }
.section-title { .section-title {
font-size: 32rpx; font-size: 32rpx;
font-weight: bold; font-weight: 800;
color: #fff; color: $navy-deep;
} }
.facility-grid { .facility-grid {
@@ -58,14 +75,23 @@ export default {
width: 25%; width: 25%;
} }
.icon-wrap {
width: 88rpx;
height: 88rpx;
background: linear-gradient(135deg, rgba($navy-deep, 0.08) 0%, rgba($navy-deep, 0.03) 100%);
border: 1rpx solid rgba($navy-deep, 0.08);
border-radius: 24rpx;
margin-bottom: 16rpx;
}
.facility-icon { .facility-icon {
font-size: 52rpx; font-size: 44rpx;
color: rgba(255, 255, 255, 0.9); color: $navy-deep;
margin-bottom: 12rpx;
} }
.facility-name { .facility-name {
font-size: 24rpx; font-size: 24rpx;
color: rgba(255, 255, 255, 0.7); color: $text-secondary;
font-weight: 500;
} }
</style> </style>

View File

@@ -1,7 +1,10 @@
<template> <template>
<view class="section-panel room-section"> <view class="section-panel room-section">
<view class="section-header tn-flex tn-flex-row-between tn-flex-col-center"> <view class="section-header tn-flex tn-flex-row-between tn-flex-col-center">
<text class="section-title">{{ $tr('在租房型({count})', { count: roomTypes.length }) }}</text> <view class="title-wrap tn-flex tn-flex-col-center">
<view class="title-bar"></view>
<text class="section-title">{{ $tr('在租房型({count})', { count: roomTypes.length }) }}</text>
</view>
</view> </view>
<view class="room-list-vertical"> <view class="room-list-vertical">
<view <view
@@ -99,25 +102,43 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
$navy-deep: #1E3A5F;
$navy-light: #2E4A73;
$gold: #C9A96E;
$gold-light: #D4BC8C;
$text-primary: #0F172A;
$text-secondary: #64748B;
$text-tertiary: #94A3B8;
.section-panel { .section-panel {
background: rgba(255, 255, 255, 0.08); background: #FFFFFF;
backdrop-filter: blur(20px); border: 1rpx solid rgba($navy-deep, 0.08);
-webkit-backdrop-filter: blur(20px); box-shadow: 0 8rpx 28rpx rgba(30, 58, 95, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.05);
margin: 24rpx 32rpx 0; margin: 24rpx 32rpx 0;
padding: 32rpx; padding: 32rpx;
border-radius: 24rpx; border-radius: 28rpx;
} }
.section-header { .section-header {
margin-bottom: 24rpx; margin-bottom: 28rpx;
}
.title-wrap {
flex: 1;
}
.title-bar {
width: 6rpx;
height: 32rpx;
background: linear-gradient(180deg, $gold 0%, $gold-light 100%);
border-radius: 999rpx;
margin-right: 16rpx;
} }
.section-title { .section-title {
font-size: 32rpx; font-size: 32rpx;
font-weight: bold; font-weight: 800;
color: #fff; color: $navy-deep;
} }
.room-list-vertical { .room-list-vertical {
@@ -129,11 +150,17 @@ export default {
.room-card-v { .room-card-v {
width: 100%; width: 100%;
display: flex; display: flex;
border-radius: 16rpx; border-radius: 24rpx;
overflow: hidden; overflow: hidden;
background: rgba(255, 255, 255, 0.05); background: #FFFFFF;
border: 1rpx solid rgba(255, 255, 255, 0.1); border: 1rpx solid rgba($navy-deep, 0.08);
box-shadow: none; box-shadow: 0 8rpx 24rpx rgba(30, 58, 95, 0.05);
transition: all 0.2s;
}
.room-card-v:active {
transform: translateY(-4rpx);
box-shadow: 0 16rpx 40rpx rgba(30, 58, 95, 0.1);
} }
.room-img-v { .room-img-v {
@@ -146,38 +173,38 @@ export default {
.img { .img {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: rgba(255, 255, 255, 0.1); background: linear-gradient(135deg, #E2E8F0 0%, #CBD5E1 100%);
} }
.pay-method-tag { .pay-method-tag {
position: absolute; position: absolute;
top: 12rpx; top: 12rpx;
left: 12rpx; left: 12rpx;
background: rgba(0, 0, 0, 0.6); background: rgba($navy-deep, 0.85);
backdrop-filter: blur(8rpx); backdrop-filter: blur(8rpx);
color: #fff; color: $gold-light;
font-size: 18rpx; font-size: 18rpx;
padding: 4rpx 10rpx; padding: 4rpx 12rpx;
border-radius: 6rpx; border-radius: 8rpx;
font-weight: 500; font-weight: 500;
} }
.room-info-v { .room-info-v {
flex: 1; flex: 1;
padding: 16rpx 20rpx; padding: 20rpx 24rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
} }
.room-header-v { .room-header-v {
margin-bottom: 8rpx; margin-bottom: 10rpx;
} }
.room-name-v { .room-name-v {
font-size: 30rpx; font-size: 30rpx;
font-weight: bold; font-weight: 800;
color: #fff; color: $navy-deep;
flex: 1; flex: 1;
} }
@@ -188,16 +215,18 @@ export default {
.tag-v { .tag-v {
font-size: 20rpx; font-size: 20rpx;
color: rgba(255, 255, 255, 0.85); color: $navy-deep;
background: rgba(255, 255, 255, 0.15); background: rgba($navy-deep, 0.06);
padding: 4rpx 12rpx; padding: 4rpx 12rpx;
border-radius: 6rpx; border-radius: 8rpx;
font-weight: 500;
} }
.room-desc-v { .room-desc-v {
font-size: 22rpx; font-size: 22rpx;
color: rgba(255, 255, 255, 0.6); color: $text-tertiary;
margin-bottom: auto; /* 将底部价格推下去 */ margin-bottom: auto;
line-height: 1.5;
} }
.room-bottom-v { .room-bottom-v {
@@ -205,41 +234,45 @@ export default {
} }
.room-price-v { .room-price-v {
color: #ffb8b8; color: $navy-deep;
display: flex; display: flex;
align-items: baseline; align-items: baseline;
} }
.price-symbol { .price-symbol {
font-size: 20rpx; font-size: 20rpx;
font-weight: bold; font-weight: 800;
margin-right: 2rpx; margin-right: 2rpx;
color: $gold;
} }
.price-val { .price-val {
font-size: 36rpx; font-size: 36rpx;
font-weight: 800; font-weight: 800;
line-height: 1; line-height: 1;
color: $navy-deep;
} }
.price-unit { .price-unit {
font-size: 20rpx; font-size: 20rpx;
color: rgba(255, 255, 255, 0.6); color: $text-tertiary;
margin-left: 4rpx; margin-left: 4rpx;
font-weight: 500; font-weight: 500;
} }
.go-btn-v { .go-btn-v {
background: rgba(255, 255, 255, 0.15); background: linear-gradient(135deg, $navy-light 0%, $navy-deep 100%);
color: #fff; color: #FFFFFF;
font-size: 22rpx; font-size: 22rpx;
padding: 8rpx 20rpx; padding: 10rpx 22rpx;
border-radius: 30rpx; border-radius: 30rpx;
border: 1rpx solid rgba(255, 255, 255, 0.2); font-weight: 600;
box-shadow: 0 6rpx 16rpx rgba($navy-deep, 0.2);
} }
.go-btn-v .tn-icon-right { .go-btn-v .tn-icon-right {
font-size: 20rpx; font-size: 20rpx;
margin-left: 4rpx; margin-left: 4rpx;
color: $gold-light;
} }
</style> </style>

View File

@@ -1,12 +1,15 @@
<template> <template>
<view class="store-info-panel"> <view class="store-info-panel">
<!-- 核心信息卡片 --> <!-- 核心信息卡片:地址 -->
<view class="info-card tn-flex tn-flex-row-between tn-flex-col-center" @click="openMap"> <view class="info-card tn-flex tn-flex-row-between tn-flex-col-center" @click="openMap">
<view class="address-left tn-flex tn-flex-col-center"> <view class="address-left tn-flex tn-flex-col-center">
<view class="icon-wrap tn-flex tn-flex-row-center tn-flex-col-center"> <view class="icon-wrap tn-flex tn-flex-row-center tn-flex-col-center">
<text class="tn-icon-location-fill location-icon"></text> <text class="tn-icon-location-fill location-icon"></text>
</view> </view>
<text class="address-title tn-text-ellipsis">{{ shortAddress }}</text> <view class="address-content">
<text class="address-label">{{ $tr('公寓地址') }}</text>
<text class="address-title tn-text-ellipsis">{{ shortAddress }}</text>
</view>
</view> </view>
<view class="address-right tn-flex tn-flex-col-center"> <view class="address-right tn-flex tn-flex-col-center">
<text class="nav-text">{{ $tr('导航') }}</text> <text class="nav-text">{{ $tr('导航') }}</text>
@@ -17,6 +20,7 @@
<!-- 小区简介 --> <!-- 小区简介 -->
<view class="section-panel" v-if="displayDescription"> <view class="section-panel" v-if="displayDescription">
<view class="section-header"> <view class="section-header">
<view class="title-bar"></view>
<text class="section-title">{{ $tr('小区简介') }}</text> <text class="section-title">{{ $tr('小区简介') }}</text>
</view> </view>
<view <view
@@ -53,14 +57,14 @@ function normalizeRichDescription(value = '') {
html, html,
'display:block;width:100%;max-width:100%;height:auto;box-sizing:border-box;border-radius:16rpx;margin:16rpx 0;vertical-align:top;' 'display:block;width:100%;max-width:100%;height:auto;box-sizing:border-box;border-radius:16rpx;margin:16rpx 0;vertical-align:top;'
) )
html = html.replace(/<p\b([^>]*)>/gi, '<p$1 style="line-height:1.9;color:rgba(255,255,255,0.78);margin:0 0 20rpx;word-break:break-word;">') html = html.replace(/<p\b([^>]*)>/gi, '<p$1 style="line-height:1.9;color:#475569;margin:0 0 20rpx;word-break:break-word;">')
html = html.replace(/<div\b([^>]*)>/gi, '<div$1 style="line-height:1.9;color:rgba(255,255,255,0.78);margin:0 0 20rpx;word-break:break-word;">') html = html.replace(/<div\b([^>]*)>/gi, '<div$1 style="line-height:1.9;color:#475569;margin:0 0 20rpx;word-break:break-word;">')
html = html.replace(/<ul\b([^>]*)>/gi, '<ul$1 style="padding-left:28rpx;margin:0 0 20rpx;color:rgba(255,255,255,0.78);line-height:1.9;">') html = html.replace(/<ul\b([^>]*)>/gi, '<ul$1 style="padding-left:28rpx;margin:0 0 20rpx;color:#475569;line-height:1.9;">')
html = html.replace(/<ol\b([^>]*)>/gi, '<ol$1 style="padding-left:32rpx;margin:0 0 20rpx;color:rgba(255,255,255,0.78);line-height:1.9;">') html = html.replace(/<ol\b([^>]*)>/gi, '<ol$1 style="padding-left:32rpx;margin:0 0 20rpx;color:#475569;line-height:1.9;">')
html = html.replace(/<li\b([^>]*)>/gi, '<li$1 style="margin-bottom:12rpx;">') html = html.replace(/<li\b([^>]*)>/gi, '<li$1 style="margin-bottom:12rpx;">')
html = html.replace(/<h([1-6])\b([^>]*)>/gi, '<h$1$2 style="color:#FFFFFF;line-height:1.5;margin:0 0 20rpx;font-weight:600;">') html = html.replace(/<h([1-6])\b([^>]*)>/gi, '<h$1$2 style="color:#1E3A5F;line-height:1.5;margin:0 0 20rpx;font-weight:600;">')
html = html.replace(/<strong\b([^>]*)>/gi, '<strong$1 style="color:#FFFFFF;font-weight:600;">') html = html.replace(/<strong\b([^>]*)>/gi, '<strong$1 style="color:#1E3A5F;font-weight:600;">')
html = html.replace(/<a\b([^>]*)>/gi, '<a$1 style="color:#9EC5FF;text-decoration:underline;">') html = html.replace(/<a\b([^>]*)>/gi, '<a$1 style="color:#2E4A73;text-decoration:underline;">')
return html return html
} }
@@ -125,6 +129,14 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
$navy-deep: #1E3A5F;
$navy-light: #2E4A73;
$gold: #C9A96E;
$gold-light: #D4BC8C;
$text-primary: #0F172A;
$text-secondary: #64748B;
$text-tertiary: #94A3B8;
.store-info-panel { .store-info-panel {
padding: 0 32rpx; padding: 0 32rpx;
margin-top: 24rpx; margin-top: 24rpx;
@@ -133,18 +145,16 @@ export default {
} }
.info-card { .info-card {
background: rgba(255, 255, 255, 0.08); background: #FFFFFF;
backdrop-filter: blur(20px); border: 1rpx solid rgba($navy-deep, 0.08);
-webkit-backdrop-filter: blur(20px); box-shadow: 0 8rpx 28rpx rgba(30, 58, 95, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.15); border-radius: 28rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.05);
border-radius: 24rpx;
padding: 32rpx; padding: 32rpx;
transition: all 0.2s; transition: all 0.2s;
&:active { &:active {
background: rgba(255, 255, 255, 0.12);
transform: scale(0.98); transform: scale(0.98);
box-shadow: 0 12rpx 32rpx rgba(30, 58, 95, 0.1);
} }
} }
@@ -155,69 +165,91 @@ export default {
} }
.icon-wrap { .icon-wrap {
width: 56rpx; width: 72rpx;
height: 56rpx; height: 72rpx;
background: rgba(255, 255, 255, 0.2); background: linear-gradient(135deg, rgba($navy-deep, 0.08) 0%, rgba($navy-deep, 0.04) 100%);
border-radius: 16rpx; border-radius: 20rpx;
margin-right: 16rpx; margin-right: 20rpx;
flex-shrink: 0; flex-shrink: 0;
border: 1rpx solid rgba($navy-deep, 0.08);
} }
.location-icon { .location-icon {
color: #fff; color: $navy-deep;
font-size: 32rpx; font-size: 36rpx;
}
.address-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.address-label {
font-size: 24rpx;
color: $text-tertiary;
margin-bottom: 6rpx;
} }
.address-title { .address-title {
font-size: 32rpx; font-size: 30rpx;
font-weight: bold; font-weight: 700;
color: #fff; color: $text-primary;
flex: 1; flex: 1;
} }
.address-right { .address-right {
flex-shrink: 0; flex-shrink: 0;
padding-left: 20rpx; padding-left: 24rpx;
border-left: 1rpx solid rgba(255, 255, 255, 0.2); border-left: 1rpx solid rgba($navy-deep, 0.08);
} }
.nav-text { .nav-text {
font-size: 26rpx; font-size: 26rpx;
color: #fff; color: $navy-deep;
font-weight: 500; font-weight: 600;
} }
.nav-arrow { .nav-arrow {
font-size: 24rpx; font-size: 24rpx;
color: rgba(255, 255, 255, 0.8); color: $gold;
margin-left: 6rpx; margin-left: 6rpx;
} }
.section-panel { .section-panel {
background: rgba(255, 255, 255, 0.08); background: #FFFFFF;
backdrop-filter: blur(20px); border: 1rpx solid rgba($navy-deep, 0.08);
-webkit-backdrop-filter: blur(20px); box-shadow: 0 8rpx 28rpx rgba(30, 58, 95, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.05);
margin-top: 24rpx; margin-top: 24rpx;
padding: 32rpx; padding: 32rpx;
border-radius: 24rpx; border-radius: 28rpx;
} }
.section-header { .section-header {
margin-bottom: 20rpx; margin-bottom: 24rpx;
display: flex;
align-items: center;
}
.title-bar {
width: 6rpx;
height: 32rpx;
background: linear-gradient(180deg, $gold 0%, $gold-light 100%);
border-radius: 999rpx;
margin-right: 16rpx;
} }
.section-title { .section-title {
font-size: 32rpx; font-size: 32rpx;
font-weight: bold; font-weight: 800;
color: #fff; color: $navy-deep;
} }
.store-desc { .store-desc {
font-size: 28rpx; font-size: 28rpx;
color: rgba(255, 255, 255, 0.7); color: $text-secondary;
line-height: 1.6; line-height: 1.7;
word-break: break-word; word-break: break-word;
} }
@@ -233,14 +265,16 @@ export default {
} }
.expand-btn { .expand-btn {
margin-top: 16rpx; margin-top: 20rpx;
font-size: 24rpx; font-size: 26rpx;
color: #fff; color: $navy-deep;
font-weight: 600;
} }
.expand-icon { .expand-icon {
margin-left: 8rpx; margin-left: 8rpx;
font-size: 28rpx; font-size: 28rpx;
line-height: 1; line-height: 1;
color: $gold;
} }
</style> </style>

View File

@@ -1,71 +1,66 @@
<template> <template>
<view class="store-detail-page"> <view class="store-detail-page">
<!-- 底层:全屏轮播背景 --> <!-- 顶部导航栏:滚动前透明/白色元素,滚动后白底深海蓝标题 -->
<view class="fullscreen-bg">
<swiper
v-if="storeGalleryList.length"
class="bg-swiper"
circular
autoplay
:interval="5000"
:duration="1000"
@change="onHeroSwiperChange"
>
<swiper-item v-for="(image, index) in storeGalleryList" :key="`${image}-${index}`">
<image class="bg-image" :src="image" mode="aspectFill"></image>
</swiper-item>
</swiper>
<view v-else class="bg-empty tn-flex tn-flex-col-center tn-flex-row-center">{{ $tr('暂无图片') }}</view>
<!-- 渐变暗化遮罩,确保上层文字卡片清晰 -->
<view class="bg-mask"></view>
</view>
<!-- 顶部导航栏 -->
<tn-nav-bar <tn-nav-bar
:isBack="true" :isBack="true"
:bottomShadow="false" :bottomShadow="false"
backgroundColor="transparent" :backgroundColor="navBarBackgroundColor"
:customBackStyle="navBarBackStyle" :customBackStyle="navBarBackStyle"
backIconColor="#FFFFFF" :backIconColor="navBarIconColor"
:fixed="true" :fixed="true"
class="transparent-nav-bar" class="glass-nav-bar"
> >
<view class="nav-title" :style="{ opacity: navTitleOpacity }">{{ displayStoreName }}</view> <view class="nav-title" :style="{ opacity: navTitleOpacity, color: navTitleColor }">{{ displayStoreName }}</view>
</tn-nav-bar> </tn-nav-bar>
<!-- 上层:透明可滚动内容区 --> <!-- 可滚动内容区 -->
<scroll-view <scroll-view
class="content-scroll" class="content-scroll"
scroll-y scroll-y
:show-scrollbar="false" :show-scrollbar="false"
@scroll="handleScroll" @scroll="handleScroll"
enable-back-to-top enable-back-to-top
> >
<view class="scroll-inner"> <view class="scroll-inner">
<!-- 留出足够的顶部空间,让背景图展示出来 --> <!-- Hero 大图区 -->
<view class="hero-space tn-flex tn-flex-direction-column tn-flex-col-bottom"> <view class="hero-panel">
<!-- 照片计数器移到店名上方,避免与导航栏重叠 --> <swiper
<view v-if="storeGalleryList.length > 1" class="hero-counter tn-flex tn-flex-col-center"> v-if="storeGalleryList.length"
<text class="tn-icon-image counter-icon"></text> class="hero-swiper"
<text>{{ currentImageIndex + 1 }} / {{ storeGalleryList.length }}</text> circular
</view> autoplay
:interval="5000"
<view class="store-title-wrap"> :duration="1000"
<view class="store-title">{{ displayStoreName }}</view> @change="onHeroSwiperChange"
<view v-if="storeTags.length" class="store-tags tn-flex tn-flex-wrap"> >
<view class="tag-item" v-for="(tag, index) in storeTags" :key="index">{{ tag }}</view> <swiper-item v-for="(image, index) in storeGalleryList" :key="`${image}-${index}`">
</view> <image class="hero-image" :src="image" mode="aspectFill"></image>
</view> </swiper-item>
</swiper>
<view v-else class="hero-empty tn-flex tn-flex-col-center tn-flex-row-center">{{ $tr('暂无图片') }}</view>
<view v-if="storeGalleryList.length > 1" class="hero-counter">
<text class="tn-icon-image counter-icon"></text>
<text>{{ currentImageIndex + 1 }} / {{ storeGalleryList.length }}</text>
</view> </view>
</view>
<!-- 悬浮毛玻璃信息流 --> <!-- 店名主卡片 -->
<view class="floating-content-wrap"> <view class="store-title-card">
<view class="store-title">{{ displayStoreName }}</view>
<view class="title-divider"></view>
<view v-if="storeTags.length" class="store-tags tn-flex tn-flex-wrap">
<view class="tag-item" v-for="(tag, index) in storeTags" :key="index">{{ tag }}</view>
</view>
</view>
<!-- 信息流 -->
<view class="content-body">
<store-info <store-info
:storeData="storeData" :storeData="storeData"
:language="language" :language="language"
@open-map="openMap" @open-map="openMap"
/> />
<facility-list <facility-list
:facilityList="facilityList" :facilityList="facilityList"
:language="language" :language="language"
@@ -77,20 +72,20 @@
:language="language" :language="language"
@go-detail="goToRoomDetail" @go-detail="goToRoomDetail"
/> />
<view class="bottom-spacer"></view> <view class="bottom-spacer"></view>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
<!-- 悬浮操作栏 (固定在底部,透明风格) --> <!-- 底部操作栏 -->
<view class="action-bar-wrap tn-safe-area-inset-bottom"> <view class="action-bar-wrap tn-safe-area-inset-bottom">
<view class="action-bar"> <view class="action-bar">
<button class="action-btn action-btn--glass" open-type="share"> <button class="action-btn action-btn--secondary" open-type="share">
<text class="tn-icon-share action-btn__icon"></text> <text class="tn-icon-share action-btn__icon"></text>
<text>{{ $tr('分享') }}</text> <text>{{ $tr('分享') }}</text>
</button> </button>
<button class="action-btn action-btn--primary-glass" @click="handleCall"> <button class="action-btn action-btn--primary" @click="handleCall">
<text class="tn-icon-phone action-btn__icon"></text> <text class="tn-icon-phone action-btn__icon"></text>
<text>{{ contactButtonText }}</text> <text>{{ contactButtonText }}</text>
</button> </button>
@@ -224,24 +219,37 @@ export default {
} }
return this.$tr('电话咨询') return this.$tr('电话咨询')
}, },
navBarBackgroundColor() {
const opacity = Math.min(Math.max((this.scrollTop - 20) / 80, 0), 1);
return `rgba(255, 255, 255, ${opacity})`;
},
navBarBackStyle() { navBarBackStyle() {
const isDarkBg = this.scrollTop < 100; const isTop = this.scrollTop < 60;
return { return {
transition: 'all 0.3s', transition: 'all 0.3s',
background: isDarkBg ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.15)', background: isTop ? 'rgba(255, 255, 255, 0.92)' : '#FFFFFF',
backdropFilter: 'blur(10px)', backdropFilter: 'blur(12rpx)',
WebkitBackdropFilter: 'blur(12rpx)',
borderRadius: '50%', borderRadius: '50%',
width: '64rpx', width: '68rpx',
height: '64rpx', height: '68rpx',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginLeft: '12rpx', marginLeft: '12rpx',
border: '1rpx solid rgba(255, 255, 255, 0.25)' boxShadow: isTop
? '0 8rpx 24rpx rgba(30, 58, 95, 0.12), inset 0 1rpx 0 rgba(255, 255, 255, 0.6)'
: '0 8rpx 20rpx rgba(15, 23, 42, 0.08)'
}; };
}, },
navBarIconColor() {
return '#1E3A5F';
},
navTitleColor() {
return '#1E3A5F';
},
navTitleOpacity() { navTitleOpacity() {
return Math.min(Math.max((this.scrollTop - 80) / 100, 0), 1); return Math.min(Math.max((this.scrollTop - 60) / 80, 0), 1);
} }
}, },
onLoad(options) { onLoad(options) {
@@ -412,9 +420,20 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* 品牌色变量本地化(深海蓝 + 香槟金) */
$navy-deep: #1E3A5F;
$navy-light: #2E4A73;
$gold: #C9A96E;
$gold-light: #D4BC8C;
$bg-blue: #F0F5FF;
$bg-page: #F8FAFF;
$text-primary: #0F172A;
$text-secondary: #64748B;
$text-tertiary: #94A3B8;
.store-detail-page { .store-detail-page {
height: 100vh; height: 100vh;
background-color: #000; /* 全屏黑色底色,防止滑动露白 */ background: linear-gradient(180deg, $bg-blue 0%, $bg-page 25%, #FFFFFF 100%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
@@ -424,7 +443,6 @@ export default {
.nav-title { .nav-title {
font-size: 34rpx; font-size: 34rpx;
font-weight: 700; font-weight: 700;
color: #fff;
transition: opacity 0.3s; transition: opacity 0.3s;
max-width: 420rpx; max-width: 420rpx;
overflow: hidden; overflow: hidden;
@@ -432,40 +450,14 @@ export default {
white-space: nowrap; white-space: nowrap;
} }
.transparent-nav-bar ::v-deep .tn-nav-bar { .glass-nav-bar ::v-deep .tn-nav-bar {
background: transparent !important; backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
box-shadow: none !important; box-shadow: none !important;
border: none !important; border: none !important;
} }
/* 底层背景 */ /* 可滚动内容区 */
.fullscreen-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
}
.bg-swiper, .bg-image, .bg-empty {
width: 100%;
height: 100%;
}
.bg-empty {
background: #111;
color: rgba(255, 255, 255, 0.4);
}
.bg-mask {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.15) 0%, rgba(0,0,0,0.4) 60%, rgba(0,0,0,0.85) 100%);
pointer-events: none;
}
/* 上层滚动内容区 */
.content-scroll { .content-scroll {
position: relative; position: relative;
z-index: 10; z-index: 10;
@@ -482,28 +474,48 @@ export default {
.scroll-inner { .scroll-inner {
min-height: 101%; min-height: 101%;
padding-top: calc(100rpx + env(safe-area-inset-top));
} }
/* 留出背景展示区 */ /* Hero 大图区 */
.hero-space { .hero-panel {
position: relative;
height: 460rpx;
margin: 0 32rpx;
border-radius: 32rpx;
overflow: hidden;
box-shadow: 0 20rpx 60rpx rgba(30, 58, 95, 0.16);
background: linear-gradient(135deg, #E2E8F0 0%, #CBD5E1 100%);
}
.hero-swiper,
.hero-image,
.hero-empty {
width: 100%; width: 100%;
height: 35vh; /* 进一步降低留白高度,确保底部房型列表至少透出一条数据 */ height: 100%;
padding: 0 32rpx 40rpx; }
justify-content: flex-end; /* 内容沉底 */
.hero-empty {
color: $text-tertiary;
font-size: 28rpx;
} }
.hero-counter { .hero-counter {
position: absolute;
right: 24rpx;
bottom: 24rpx;
height: 52rpx; height: 52rpx;
padding: 0 20rpx; padding: 0 20rpx;
border-radius: 999rpx; border-radius: 999rpx;
background: rgba(0, 0, 0, 0.3); background: rgba($navy-deep, 0.85);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
color: #ffffff; color: $gold-light;
font-size: 24rpx; font-size: 24rpx;
font-weight: 500; font-weight: 600;
border: 1rpx solid rgba(255, 255, 255, 0.2); display: inline-flex;
margin-bottom: 24rpx; align-items: center;
align-self: flex-end; /* 靠右对齐 */ justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(30, 58, 95, 0.2);
} }
.counter-icon { .counter-icon {
@@ -511,59 +523,72 @@ export default {
font-size: 26rpx; font-size: 26rpx;
} }
.store-title-wrap { /* 店名主卡片 */
width: 100%; .store-title-card {
color: #fff; position: relative;
margin: -60rpx 32rpx 0;
padding: 40rpx;
background: #FFFFFF;
border-radius: 32rpx;
box-shadow: 0 12rpx 40rpx rgba(30, 58, 95, 0.08);
z-index: 20;
} }
.store-title { .store-title {
font-size: 64rpx; font-size: 48rpx;
font-weight: bold; font-weight: 800;
margin-bottom: 24rpx; color: $navy-deep;
text-shadow: 0 4rpx 16rpx rgba(0,0,0,0.6); line-height: 1.3;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.title-divider {
width: 48rpx;
height: 4rpx;
margin: 24rpx 0;
background: linear-gradient(90deg, $gold 0%, $gold-light 100%);
border-radius: 2rpx;
}
.store-tags { .store-tags {
gap: 16rpx; gap: 16rpx;
} }
.tag-item { .tag-item {
font-size: 24rpx; font-size: 24rpx;
padding: 8rpx 20rpx; padding: 8rpx 18rpx;
border-radius: 8rpx; border-radius: 999rpx;
background: rgba(255, 255, 255, 0.15); background: rgba($navy-deep, 0.06);
backdrop-filter: blur(10px); border: 1rpx solid rgba($navy-deep, 0.12);
border: 1rpx solid rgba(255, 255, 255, 0.2); color: $navy-deep;
color: #fff; font-weight: 500;
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.3);
} }
/* 悬浮毛玻璃信息流 */ /* 信息流 */
.floating-content-wrap { .content-body {
position: relative; padding: 24rpx 0 0;
padding-bottom: 180rpx;
} }
/* 底部操作栏 - 毛玻璃悬浮 */ /* 底部操作栏 */
.action-bar-wrap { .action-bar-wrap {
position: fixed; position: fixed;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
padding: 18rpx 32rpx calc(18rpx + env(safe-area-inset-bottom)); padding: 18rpx 32rpx calc(18rpx + env(safe-area-inset-bottom));
background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 100%); background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(24rpx);
-webkit-backdrop-filter: blur(24rpx);
box-shadow: 0 -8rpx 30rpx rgba(30, 58, 95, 0.06);
z-index: 100; z-index: 100;
pointer-events: none; /* 让渐变层不阻挡点击 */
} }
.action-bar { .action-bar {
display: flex; display: flex;
gap: 24rpx; gap: 24rpx;
pointer-events: auto; /* 恢复按钮区域点击 */
} }
.action-btn { .action-btn {
@@ -573,35 +598,41 @@ export default {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 30rpx; font-size: 30rpx;
font-weight: bold; font-weight: 700;
padding: 0; padding: 0;
border: 1rpx solid rgba(255, 255, 255, 0.3);
line-height: 1; line-height: 1;
backdrop-filter: blur(16px); transition: all 0.2s;
-webkit-backdrop-filter: blur(16px);
} }
button.action-btn::after { button.action-btn::after {
border: none; border: none;
} }
.action-btn:active {
transform: scale(0.97);
}
.action-btn__icon { .action-btn__icon {
margin-right: 12rpx; margin-right: 12rpx;
font-size: 34rpx; font-size: 34rpx;
} }
.action-btn--glass { .action-btn--secondary {
flex: 0 0 200rpx; flex: 0 0 200rpx;
background: rgba(255, 255, 255, 0.15); background: rgba($navy-deep, 0.06);
color: #fff; color: $navy-deep;
border: 1rpx solid rgba($navy-deep, 0.12);
} }
.action-btn--primary-glass { .action-btn--primary {
flex: 1; flex: 1;
background: rgba($brand-primary, 0.85); background: linear-gradient(135deg, $navy-light 0%, $navy-deep 100%);
color: #fff; color: #FFFFFF;
border-color: rgba($brand-primary, 0.5); box-shadow: 0 12rpx 32rpx rgba($navy-deep, 0.25);
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.3); }
.action-btn--primary .action-btn__icon {
color: $gold-light;
} }
.bottom-spacer { .bottom-spacer {

View File

@@ -116,7 +116,7 @@
:class="{ 'danger-btn--loading': canceling }" :class="{ 'danger-btn--loading': canceling }"
@click.stop="handleCancelOrder" @click.stop="handleCancelOrder"
> >
<text class="tn-text-bold">{{ canceling ? $tr('取消中...') : $tr('取消订单') }}</text> <text class="tn-text-bold">{{ canceling ? $tr('取消中...') : $tr('取消申请') }}</text>
</view> </view>
<view <view
v-if="currentStepActionText" v-if="currentStepActionText"
@@ -344,12 +344,14 @@ export default {
&& this.detail.detailCode && this.detail.detailCode
&& this.statusType === 'pending' && this.statusType === 'pending'
}, },
canCancelOrder() { canCancelApplication() {
return this.shouldPayDeposit(this.detail) return !!(this.detail
&& !this.isViewingApplication
&& this.detail
&& this.detail.detailCode && this.detail.detailCode
&& !this.canceling && Number(this.detail.status) === 0
&& (this.isViewingApplication || Number(this.detail.payStatus) !== 10))
},
canCancelOrder() {
return this.canCancelApplication && !this.canceling
} }
}, },
onLoad(options = {}) { onLoad(options = {}) {
@@ -601,11 +603,16 @@ export default {
} }
}) })
}, },
getCancelOrderContent() {
return this.isViewingApplication
? this.$tr('取消后将关闭当前预约看房申请,是否继续?')
: this.$tr('取消后将关闭当前申请单,是否继续?')
},
confirmCancelOrder() { confirmCancelOrder() {
return new Promise((resolve) => { return new Promise((resolve) => {
uni.showModal({ uni.showModal({
title: this.$tr('取消订单'), title: this.$tr('取消申请'),
content: this.$tr('取消后将关闭当前未支付的定金订单,是否继续?'), content: this.getCancelOrderContent(),
confirmColor: '#ef4444', confirmColor: '#ef4444',
success: (res) => resolve(!!(res && res.confirm)), success: (res) => resolve(!!(res && res.confirm)),
fail: () => resolve(false) fail: () => resolve(false)
@@ -640,9 +647,7 @@ export default {
} }
this.canceling = true this.canceling = true
return userDetailCancel({ return userDetailCancel({
detailCode: this.detail.detailCode, detailCode: this.detail.detailCode
code: this.detail.detailCode,
referenceTypeNo: this.detail.detailCode
}, this.language) }, this.language)
.then(() => { .then(() => {
this.detail = { this.detail = {
@@ -652,14 +657,14 @@ export default {
} }
this.syncCanceledDetailToCache() this.syncCanceledDetailToCache()
uni.showToast({ uni.showToast({
title: this.$tr('订单已取消'), title: this.$tr('申请已取消'),
icon: 'success' icon: 'success'
}) })
}) })
}) })
.catch((error) => { .catch((error) => {
uni.showToast({ uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('取消订单失败'), title: (error && (error.message || error.msg)) || this.$tr('取消申请失败'),
icon: 'none' icon: 'none'
}) })
}) })

View File

@@ -241,7 +241,7 @@ import {
getAccountBillRefundStatusLabel, getAccountBillRefundStatusLabel,
getAccountReferenceTypeLabel getAccountReferenceTypeLabel
} from '@/utils/enum-store.js' } from '@/utils/enum-store.js'
import { getActiveApplicationCode, getMyApplicationList, saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state' import { getActiveApplicationCode, getMyApplicationList, getSessionSnapshot, saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state'
const VIEWING_APPLICATION_PATTERN = /预约看房申请|看房时间:|意向房型:|意向区域:|viewing request|viewing time|preferred room type|preferred area/i const VIEWING_APPLICATION_PATTERN = /预约看房申请|看房时间:|意向房型:|意向区域:|viewing request|viewing time|preferred room type|preferred area/i
@@ -730,7 +730,7 @@ export default {
getCancelButtonText(item = {}) { getCancelButtonText(item = {}) {
return this.cancelingBillNo === this.getBillNo(item) return this.cancelingBillNo === this.getBillNo(item)
? this.$tr('取消中...') ? this.$tr('取消中...')
: this.$tr('取消订单') : this.$tr('取消申请')
}, },
resolveReferenceTypeNo(item = {}) { resolveReferenceTypeNo(item = {}) {
return item.code || item.detailCode || '' return item.code || item.detailCode || ''
@@ -755,6 +755,19 @@ export default {
return Promise.resolve('') return Promise.resolve('')
} }
const session = getSessionSnapshot()
const sessionUser = session && session.currentUser ? session.currentUser : {}
const currentFlowRefNo = String(
sessionUser.currentReservationRefNo
|| (session && session.resolvedUser && session.resolvedUser.currentReservationRefNo)
|| ''
).trim()
if (currentFlowRefNo) {
this.currentReferenceTypeNo = currentFlowRefNo
saveActiveApplicationCode(currentFlowRefNo)
return Promise.resolve(this.currentReferenceTypeNo)
}
const cachedActiveCode = getActiveApplicationCode() const cachedActiveCode = getActiveApplicationCode()
if (cachedActiveCode) { if (cachedActiveCode) {
this.currentReferenceTypeNo = cachedActiveCode this.currentReferenceTypeNo = cachedActiveCode
@@ -1011,8 +1024,8 @@ export default {
confirmCancelOrder() { confirmCancelOrder() {
return new Promise((resolve) => { return new Promise((resolve) => {
uni.showModal({ uni.showModal({
title: this.$tr('取消订单'), title: this.$tr('取消申请'),
content: this.$tr('取消后将关闭当前未支付订单,是否继续?'), content: this.$tr('取消后将关闭当前申请单,是否继续?'),
confirmColor: '#ef4444', confirmColor: '#ef4444',
success: (res) => resolve(!!(res && res.confirm)), success: (res) => resolve(!!(res && res.confirm)),
fail: () => resolve(false) fail: () => resolve(false)
@@ -1020,18 +1033,8 @@ export default {
}) })
}, },
buildCancelOrderPayload(item = {}) { buildCancelOrderPayload(item = {}) {
const pendingOrder = this.getPendingPaymentOrder(item) || {}
const billNo = this.getBillNo(item)
const paymentNo = pendingOrder.paymentNo || pendingOrder.payNo || pendingOrder.payRequestNo || ''
return { return {
detailCode: this.currentReferenceTypeNo, detailCode: this.currentReferenceTypeNo
code: this.currentReferenceTypeNo,
referenceTypeNo: this.currentReferenceTypeNo,
billNo,
BillNo: billNo,
paymentNo,
payNo: paymentNo,
payRequestNo: pendingOrder.payRequestNo || ''
} }
}, },
createPaymentSession(item = {}) { createPaymentSession(item = {}) {
@@ -1274,7 +1277,7 @@ export default {
return userDetailCancel(this.buildCancelOrderPayload(item), this.language) return userDetailCancel(this.buildCancelOrderPayload(item), this.language)
.then(() => { .then(() => {
uni.showToast({ uni.showToast({
title: this.$tr('订单已取消'), title: this.$tr('申请已取消'),
icon: 'success' icon: 'success'
}) })
return this.refreshBillList() return this.refreshBillList()
@@ -1282,7 +1285,7 @@ export default {
}) })
.catch((error) => { .catch((error) => {
uni.showToast({ uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('取消订单失败'), title: (error && (error.message || error.msg)) || this.$tr('取消申请失败'),
icon: 'none' icon: 'none'
}) })
}) })

View File

@@ -93,7 +93,8 @@
:formData="uploadFormData" :formData="uploadFormData"
:max-count="6" :max-count="6"
:max-size="5 * 1024 * 1024" :max-size="5 * 1024 * 1024"
uploadText="" :uploadText="''"
:customBtn="true"
@on-success="onUploadSuccess" @on-success="onUploadSuccess"
@on-list-change="onPhotoListChange" @on-list-change="onPhotoListChange"
@on-remove="onRemoveImage" @on-remove="onRemoveImage"

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,40 @@
<template> <template>
<view class="building-selector"> <view class="building-selector">
<view <view
class="selector-header tn-flex tn-flex-row-between tn-flex-col-center" class="selector-card tn-flex tn-flex-row-between tn-flex-col-center"
:class="{ 'selector-header--disabled': !areaCode }" :class="{ 'selector-card--disabled': !areaCode }"
@click="handleOpen" @click="handleOpen"
> >
<view class="selected-info"> <view class="selected-info">
<view class="selected-label">{{ $tr('楼栋') }}</view>
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedBuilding }"> <view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedBuilding }">
{{ selectedBuilding ? getBuildingDisplayName(selectedBuilding) : placeholderText }} {{ selectedBuilding ? getBuildingDisplayName(selectedBuilding) : placeholderText }}
</view> </view>
</view> </view>
<text class="tn-icon-right" :class="areaCode ? 'tn-color-gray' : 'tn-color-disabled'"></text> <view class="selector-arrow">
<text class="tn-icon-right"></text>
</view>
</view> </view>
<tn-popup v-model="showPopup" mode="bottom" length="72%" :borderRadius="32" :closeBtn="true"> <tn-popup v-model="showPopup" mode="bottom" length="72%" :borderRadius="32" :closeBtn="true">
<view class="popup-content"> <view class="popup-content">
<view class="popup-header"> <view class="popup-header">
<text class="popup-title">{{ $tr('选择意向楼栋') }}</text> <view class="popup-title">{{ $tr('选择意向楼栋') }}</view>
<view class="popup-subtitle">{{ $tr('选择您期望入住的楼栋') }}</view>
</view> </view>
<scroll-view scroll-y class="building-list-scroll"> <scroll-view scroll-y class="building-list-scroll">
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center"> <view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
<tn-loading mode="circle"></tn-loading> <tn-loading mode="circle" :size="48"></tn-loading>
<view class="tn-margin-top-sm tn-color-gray">{{ $tr('楼栋加载中...') }}</view> <view class="tn-margin-top tn-color-gray">{{ $tr('楼栋加载中...') }}</view>
</view> </view>
<view v-else-if="!buildingList.length" class="empty-state tn-padding-xl tn-text-center"> <view v-else-if="!buildingList.length" class="empty-state tn-padding-xl tn-text-center">
<text class="tn-color-gray">{{ $tr('暂无可选楼栋') }}</text> <view class="empty-icon">
<text class="tn-icon-empty-data"></text>
</view>
<view class="empty-title">{{ $tr('暂无可选楼栋') }}</view>
<view class="empty-desc">{{ $tr('当前暂无可用楼栋,请稍后再试') }}</view>
</view> </view>
<view v-else class="building-list"> <view v-else class="building-list">
@@ -37,8 +45,13 @@
:class="{ 'is-active': selectedBuilding && selectedBuilding.code === item.code }" :class="{ 'is-active': selectedBuilding && selectedBuilding.code === item.code }"
@click="handleSelect(item)" @click="handleSelect(item)"
> >
<text class="building-name">{{ getBuildingDisplayName(item) }}</text> <view class="building-name-left tn-flex tn-flex-col-center">
<text v-if="selectedBuilding && selectedBuilding.code === item.code" class="tn-icon-success-circle-fill building-check"></text> <view class="building-icon-wrap">
<text class="tn-icon-building"></text>
</view>
<text class="building-name">{{ getBuildingDisplayName(item) }}</text>
</view>
<text v-if="selectedBuilding && selectedBuilding.code === item.code" class="tn-icon-check building-check"></text>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
@@ -186,7 +199,7 @@ export default {
this.buildingList = [] this.buildingList = []
this.selectedBuilding = null this.selectedBuilding = null
uni.showToast({ uni.showToast({
title: this.$tr('楼栋列表加载失败'), title: this.$tr('楼栋加载失败'),
icon: 'none' icon: 'none'
}) })
}) })
@@ -218,20 +231,32 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
$text-primary: #1A2233;
$text-secondary: #6A7A99;
$text-tertiary: #A1B0C4;
$surface: #FFFFFF;
$bg-popup: #F8FAFF;
.building-selector { .building-selector {
width: 100%; width: 100%;
} }
.selector-header { .selector-card {
background: #FFFFFF; background: $surface;
border-radius: 20rpx; border-radius: 20rpx;
padding: 26rpx 32rpx; padding: 26rpx 30rpx;
border: 1rpx solid rgba($brand-primary, 0.15); border: 2rpx solid rgba($brand-primary, 0.12);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05); box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.05);
min-height: 100rpx; min-height: 120rpx;
transition: all 0.25s ease;
} }
.selector-header--disabled { .selector-card:active {
background: #FAFCFF;
border-color: rgba($brand-primary, 0.25);
}
.selector-card--disabled {
opacity: 0.72; opacity: 0.72;
} }
@@ -240,41 +265,104 @@ export default {
overflow: hidden; overflow: hidden;
} }
.selected-label {
font-size: 22rpx;
color: $text-tertiary;
margin-bottom: 8rpx;
}
.selected-name { .selected-name {
font-size: 28rpx; font-size: 30rpx;
color: #1A2233; color: $text-primary;
font-weight: 600; font-weight: 700;
&.is-empty { &.is-empty {
color: #A0B0C0; color: $text-tertiary;
font-weight: normal; font-weight: normal;
} }
} }
.selector-arrow {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: #F4F8FF;
display: flex;
align-items: center;
justify-content: center;
margin-left: 20rpx;
flex-shrink: 0;
text {
font-size: 28rpx;
color: $text-secondary;
}
}
.popup-content { .popup-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
background: #F8FAFF; background: $bg-popup;
} }
.popup-header { .popup-header {
padding: 40rpx 30rpx 20rpx; padding: 40rpx 30rpx 28rpx;
text-align: center; text-align: center;
border-bottom: 1px dashed rgba($brand-primary, 0.15); background: $surface;
background: #FFFFFF; border-bottom: 1rpx solid rgba($brand-primary, 0.08);
} }
.popup-title { .popup-title {
font-size: 32rpx; font-size: 34rpx;
font-weight: 700; font-weight: 700;
color: #1A2233; color: $text-primary;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
}
.popup-subtitle {
font-size: 24rpx;
color: $text-secondary;
} }
.building-list-scroll { .building-list-scroll {
flex: 1; flex: 1;
height: 0; height: 0;
padding: 20rpx 30rpx 60rpx; padding: 24rpx 30rpx 60rpx;
}
.loading-state,
.empty-state {
padding-top: 120rpx;
}
.empty-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba($brand-primary, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24rpx;
text {
font-size: 56rpx;
color: $brand-primary;
}
}
.empty-title {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 24rpx;
color: $text-secondary;
} }
.building-list { .building-list {
@@ -289,23 +377,55 @@ export default {
justify-content: space-between; justify-content: space-between;
background: #FFFFFF; background: #FFFFFF;
border-radius: 20rpx; border-radius: 20rpx;
padding: 28rpx 30rpx; padding: 24rpx 28rpx;
border: 1rpx solid rgba($brand-primary, 0.12); border: 2rpx solid rgba($brand-primary, 0.1);
transition: all 0.2s ease; transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
&.is-active { &.is-active {
border-color: rgba($brand-primary, 0.5); background: #FFFFFF;
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.08); border-color: $brand-primary;
box-shadow: 0 8rpx 24rpx rgba($brand-primary, 0.14);
.building-name {
color: $brand-primary;
font-weight: 700;
}
.building-icon-wrap {
background: rgba($brand-primary, 0.15);
}
}
}
.building-name-left {
flex: 1;
min-width: 0;
}
.building-icon-wrap {
width: 44rpx;
height: 44rpx;
border-radius: 12rpx;
background: rgba($brand-primary, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
transition: all 0.25s ease;
text {
font-size: 24rpx;
color: $brand-primary;
} }
} }
.building-name { .building-name {
font-size: 28rpx; font-size: 28rpx;
color: #1A2233; color: $text-primary;
} }
.building-check { .building-check {
color: $brand-primary; color: $brand-primary;
font-size: 30rpx; font-size: 32rpx;
} }
</style> </style>

View File

@@ -1,22 +1,34 @@
<template> <template>
<view class="date-range-selector"> <view class="date-range-selector tn-flex tn-flex-col-center">
<view class="date-item"> <picker mode="date" @change="onStartChange" class="date-picker date-picker--start">
<view class="date-label">{{ resolvedStartLabel }}</view> <view class="date-card" :class="{ 'is-empty': !start, 'is-active': start }">
<picker mode="date" @change="onStartChange"> <view class="date-card-header tn-flex tn-flex-row-between tn-flex-col-center">
<view class="date-input" :class="{ 'is-empty': !start }"> <text class="date-label">{{ resolvedStartLabel }}</text>
{{ start || resolvedStartPlaceholder }} <view class="date-icon-wrap">
<text class="tn-icon-calendar"></text>
</view>
</view> </view>
</picker> <view class="date-value">{{ start || resolvedStartPlaceholder }}</view>
</view>
</picker>
<view class="range-connector">
<view class="connector-line"></view>
<text class="connector-text">{{ $tr('至') }}</text>
<view class="connector-line"></view>
</view> </view>
<view class="date-divider">{{ $tr('至') }}</view>
<view class="date-item"> <picker mode="date" @change="onEndChange" class="date-picker date-picker--end">
<view class="date-label">{{ resolvedEndLabel }}</view> <view class="date-card" :class="{ 'is-empty': !end, 'is-active': end }">
<picker mode="date" @change="onEndChange"> <view class="date-card-header tn-flex tn-flex-row-between tn-flex-col-center">
<view class="date-input" :class="{ 'is-empty': !end }"> <text class="date-label">{{ resolvedEndLabel }}</text>
{{ end || resolvedEndPlaceholder }} <view class="date-icon-wrap">
<text class="tn-icon-calendar"></text>
</view>
</view> </view>
</picker> <view class="date-value">{{ end || resolvedEndPlaceholder }}</view>
</view> </view>
</picker>
</view> </view>
</template> </template>
@@ -75,44 +87,92 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
$text-primary: #1A2233;
$text-secondary: #6A7A99;
$text-tertiary: #A1B0C4;
.date-range-selector { .date-range-selector {
display: flex; width: 100%;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.6);
border-radius: 24rpx;
padding: 24rpx 32rpx;
border: 2rpx solid rgba(226, 232, 240, 0.8);
backdrop-filter: blur(8px);
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.02);
} }
.date-item { .date-picker {
flex: 1; flex: 1;
text-align: center; min-width: 0;
} }
.date-divider { .date-card {
margin: 0 24rpx; background: #F4F8FF;
color: #94A3B8; border-radius: 20rpx;
font-size: 24rpx; padding: 24rpx;
font-weight: 500; border: 2rpx solid transparent;
transition: all 0.25s ease;
&.is-active {
background: #FFFFFF;
border-color: rgba($brand-primary, 0.25);
box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.08);
.date-value {
color: $text-primary;
font-weight: 700;
}
}
&.is-empty .date-value {
color: $text-tertiary;
font-weight: normal;
}
}
.date-card-header {
margin-bottom: 16rpx;
} }
.date-label { .date-label {
font-size: 22rpx; font-size: 22rpx;
color: #64748B; color: $text-secondary;
margin-bottom: 12rpx;
} }
.date-input { .date-icon-wrap {
font-size: 28rpx; width: 40rpx;
color: #1A2B4C; height: 40rpx;
font-weight: 600; border-radius: 12rpx;
background: rgba($brand-primary, 0.1);
display: flex;
align-items: center;
justify-content: center;
&.is-empty { text {
color: #CBD5E1; font-size: 22rpx;
font-weight: normal; color: $brand-primary;
} }
} }
.date-value {
font-size: 28rpx;
color: $text-primary;
line-height: 1.4;
}
.range-connector {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 18rpx;
flex-shrink: 0;
}
.connector-line {
width: 2rpx;
height: 24rpx;
background: rgba($brand-primary, 0.25);
}
.connector-text {
font-size: 22rpx;
color: $brand-primary;
font-weight: 700;
margin: 8rpx 0;
}
</style> </style>

View File

@@ -1,31 +1,39 @@
<template> <template>
<view class="floor-selector"> <view class="floor-selector">
<view <view
class="selector-header tn-flex tn-flex-row-between tn-flex-col-center" class="selector-card tn-flex tn-flex-row-between tn-flex-col-center"
@click="handleOpen" @click="handleOpen"
> >
<view class="selected-info"> <view class="selected-info">
<view class="selected-label">{{ $tr('楼层') }}</view>
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedFloor }"> <view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedFloor }">
{{ selectedFloor ? getFloorDisplayName(selectedFloor) : placeholderText }} {{ selectedFloor ? getFloorDisplayName(selectedFloor) : placeholderText }}
</view> </view>
</view> </view>
<text class="tn-icon-right tn-color-gray"></text> <view class="selector-arrow">
<text class="tn-icon-right"></text>
</view>
</view> </view>
<tn-popup v-model="showPopup" mode="bottom" length="72%" :borderRadius="32" :closeBtn="true"> <tn-popup v-model="showPopup" mode="bottom" length="72%" :borderRadius="32" :closeBtn="true">
<view class="popup-content"> <view class="popup-content">
<view class="popup-header"> <view class="popup-header">
<text class="popup-title">{{ $tr('选择意向楼层') }}</text> <view class="popup-title">{{ $tr('选择意向楼层') }}</view>
<view class="popup-subtitle">{{ $tr('选择您期望入住的楼层') }}</view>
</view> </view>
<scroll-view scroll-y class="floor-list-scroll"> <scroll-view scroll-y class="floor-list-scroll">
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center"> <view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
<tn-loading mode="circle"></tn-loading> <tn-loading mode="circle" :size="48"></tn-loading>
<view class="tn-margin-top-sm tn-color-gray">{{ $tr('楼层加载中...') }}</view> <view class="tn-margin-top tn-color-gray">{{ $tr('楼层加载中...') }}</view>
</view> </view>
<view v-else-if="!floorList.length" class="empty-state tn-padding-xl tn-text-center"> <view v-else-if="!floorList.length" class="empty-state tn-padding-xl tn-text-center">
<text class="tn-color-gray">{{ $tr('暂无可选楼层') }}</text> <view class="empty-icon">
<text class="tn-icon-empty-data"></text>
</view>
<view class="empty-title">{{ $tr('暂无可选楼层') }}</view>
<view class="empty-desc">{{ $tr('当前暂无可用楼层,请稍后再试') }}</view>
</view> </view>
<view v-else class="floor-list"> <view v-else class="floor-list">
@@ -34,8 +42,13 @@
:class="{ 'is-active': !selectedFloor }" :class="{ 'is-active': !selectedFloor }"
@click="handleClear" @click="handleClear"
> >
<text class="floor-name">{{ $tr('不选择') }}</text> <view class="floor-name-left tn-flex tn-flex-col-center">
<text v-if="!selectedFloor" class="tn-icon-success-circle-fill floor-check"></text> <view class="floor-icon-wrap">
<text class="tn-icon-close" style="font-size: 22rpx;"></text>
</view>
<text class="floor-name">{{ $tr('不选择') }}</text>
</view>
<text v-if="!selectedFloor" class="tn-icon-check floor-check"></text>
</view> </view>
<view <view
v-for="item in floorList" v-for="item in floorList"
@@ -44,8 +57,13 @@
:class="{ 'is-active': selectedFloor && selectedFloor.value === item.value }" :class="{ 'is-active': selectedFloor && selectedFloor.value === item.value }"
@click="handleSelect(item)" @click="handleSelect(item)"
> >
<text class="floor-name">{{ getFloorDisplayName(item) }}</text> <view class="floor-name-left tn-flex tn-flex-col-center">
<text v-if="selectedFloor && selectedFloor.value === item.value" class="tn-icon-success-circle-fill floor-check"></text> <view class="floor-icon-wrap">
<text class="tn-icon-building"></text>
</view>
<text class="floor-name">{{ getFloorDisplayName(item) }}</text>
</view>
<text v-if="selectedFloor && selectedFloor.value === item.value" class="tn-icon-check floor-check"></text>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
@@ -173,7 +191,7 @@ export default {
this.floorList = [] this.floorList = []
this.selectedFloor = null this.selectedFloor = null
uni.showToast({ uni.showToast({
title: this.$tr('楼层列表加载失败'), title: this.$tr('楼层加载失败'),
icon: 'none' icon: 'none'
}) })
}) })
@@ -212,17 +230,29 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
$text-primary: #1A2233;
$text-secondary: #6A7A99;
$text-tertiary: #A1B0C4;
$surface: #FFFFFF;
$bg-popup: #F8FAFF;
.floor-selector { .floor-selector {
width: 100%; width: 100%;
} }
.selector-header { .selector-card {
background: #FFFFFF; background: $surface;
border-radius: 20rpx; border-radius: 20rpx;
padding: 26rpx 32rpx; padding: 26rpx 30rpx;
border: 1rpx solid rgba($brand-primary, 0.15); border: 2rpx solid rgba($brand-primary, 0.12);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05); box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.05);
min-height: 100rpx; min-height: 120rpx;
transition: all 0.25s ease;
}
.selector-card:active {
background: #FAFCFF;
border-color: rgba($brand-primary, 0.25);
} }
.selected-info { .selected-info {
@@ -230,41 +260,104 @@ export default {
overflow: hidden; overflow: hidden;
} }
.selected-label {
font-size: 22rpx;
color: $text-tertiary;
margin-bottom: 8rpx;
}
.selected-name { .selected-name {
font-size: 28rpx; font-size: 30rpx;
color: #1A2233; color: $text-primary;
font-weight: 600; font-weight: 700;
&.is-empty { &.is-empty {
color: #A0B0C0; color: $text-tertiary;
font-weight: normal; font-weight: normal;
} }
} }
.selector-arrow {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: #F4F8FF;
display: flex;
align-items: center;
justify-content: center;
margin-left: 20rpx;
flex-shrink: 0;
text {
font-size: 28rpx;
color: $text-secondary;
}
}
.popup-content { .popup-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
background: #F8FAFF; background: $bg-popup;
} }
.popup-header { .popup-header {
padding: 40rpx 30rpx 20rpx; padding: 40rpx 30rpx 28rpx;
text-align: center; text-align: center;
border-bottom: 1px dashed rgba($brand-primary, 0.15); background: $surface;
background: #FFFFFF; border-bottom: 1rpx solid rgba($brand-primary, 0.08);
} }
.popup-title { .popup-title {
font-size: 32rpx; font-size: 34rpx;
font-weight: 700; font-weight: 700;
color: #1A2233; color: $text-primary;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
}
.popup-subtitle {
font-size: 24rpx;
color: $text-secondary;
} }
.floor-list-scroll { .floor-list-scroll {
flex: 1; flex: 1;
height: 0; height: 0;
padding: 20rpx 30rpx 60rpx; padding: 24rpx 30rpx 60rpx;
}
.loading-state,
.empty-state {
padding-top: 120rpx;
}
.empty-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba($brand-primary, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24rpx;
text {
font-size: 56rpx;
color: $brand-primary;
}
}
.empty-title {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 24rpx;
color: $text-secondary;
} }
.floor-list { .floor-list {
@@ -279,13 +372,23 @@ export default {
justify-content: space-between; justify-content: space-between;
background: #FFFFFF; background: #FFFFFF;
border-radius: 20rpx; border-radius: 20rpx;
padding: 28rpx 30rpx; padding: 24rpx 28rpx;
border: 1rpx solid rgba($brand-primary, 0.12); border: 2rpx solid rgba($brand-primary, 0.1);
transition: all 0.2s ease; transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
&.is-active { &.is-active {
border-color: rgba($brand-primary, 0.5); background: #FFFFFF;
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.08); border-color: $brand-primary;
box-shadow: 0 8rpx 24rpx rgba($brand-primary, 0.14);
.floor-name {
color: $brand-primary;
font-weight: 700;
}
.floor-icon-wrap {
background: rgba($brand-primary, 0.15);
}
} }
} }
@@ -293,13 +396,35 @@ export default {
border-style: dashed; border-style: dashed;
} }
.floor-name-left {
flex: 1;
min-width: 0;
}
.floor-icon-wrap {
width: 44rpx;
height: 44rpx;
border-radius: 12rpx;
background: rgba($brand-primary, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
transition: all 0.25s ease;
text {
font-size: 24rpx;
color: $brand-primary;
}
}
.floor-name { .floor-name {
font-size: 28rpx; font-size: 28rpx;
color: #1A2233; color: $text-primary;
} }
.floor-check { .floor-check {
color: $brand-primary; color: $brand-primary;
font-size: 30rpx; font-size: 32rpx;
} }
</style> </style>

View File

@@ -1,51 +1,72 @@
<template> <template>
<view class="room-type-selector"> <view class="room-type-selector">
<view class="selector-header tn-flex tn-flex-row-between tn-flex-col-center" @click="openSelector"> <view class="selector-card tn-flex tn-flex-row-between tn-flex-col-center" @click="openSelector">
<view class="selected-info"> <view class="selected-info">
<view class="selected-label">{{ $tr('意向房型') }}</view>
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }"> <view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }">
{{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : $tr('请选择意向房型') }} {{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : $tr('请选择意向房型') }}
</view> </view>
<view class="selected-area tn-text-ellipsis" v-if="selectedArea"> <view class="selected-area tn-text-ellipsis" v-if="selectedArea">
<text class="tn-icon-location area-icon"></text>
{{ getAreaDisplayName(selectedArea) }} {{ getAreaDisplayName(selectedArea) }}
</view> </view>
</view> </view>
<text class="tn-icon-right tn-color-gray"></text> <view class="selector-arrow">
<text class="tn-icon-right"></text>
</view>
</view> </view>
<tn-popup v-model="showPopup" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true"> <tn-popup v-model="showPopup" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true">
<view class="popup-content"> <view class="popup-content">
<view class="popup-header"> <view class="popup-header">
<text class="popup-title">{{ $tr('选择意向房型') }}</text> <view class="popup-title">{{ $tr('选择意向房型') }}</view>
<view class="popup-subtitle">{{ $tr('选择您心仪的房型与区域') }}</view>
</view> </view>
<scroll-view scroll-y class="area-list-scroll"> <scroll-view scroll-y class="area-list-scroll">
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center"> <view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
<tn-loading mode="circle"></tn-loading> <tn-loading mode="circle" :size="48"></tn-loading>
<view class="tn-margin-top-sm tn-color-gray">{{ $tr('房型加载中...') }}</view> <view class="tn-margin-top tn-color-gray">{{ $tr('房型加载中...') }}</view>
</view> </view>
<view v-else-if="!displayAreaList.length" class="empty-state tn-padding-xl tn-text-center"> <view v-else-if="!displayAreaList.length" class="empty-state tn-padding-xl tn-text-center">
<text class="tn-color-gray">{{ $tr('暂无可选房型') }}</text> <view class="empty-icon">
<text class="tn-icon-empty-data"></text>
</view>
<view class="empty-title">{{ $tr('暂无可选房型') }}</view>
<view class="empty-desc">{{ $tr('当前暂无可用房型,请稍后再试') }}</view>
</view> </view>
<view v-else class="area-group" v-for="(area, index) in displayAreaList" :key="area.code"> <view v-else class="area-group" v-for="(area, index) in displayAreaList" :key="area.code">
<view class="area-title tn-flex tn-flex-row-between tn-flex-col-center" @click="toggleArea(index)"> <view class="area-title tn-flex tn-flex-row-between tn-flex-col-center" @click="toggleArea(index)">
<view class="tn-flex tn-flex-col-center"> <view class="area-title-left tn-flex tn-flex-col-center">
<text class="area-dot"></text> <view class="area-icon-wrap">
<text>{{ getAreaDisplayName(area) }}</text> <text class="tn-icon-home"></text>
</view>
<text class="area-title-text">{{ getAreaDisplayName(area) }}</text>
</view>
<view class="area-toggle">
<text :class="collapsedAreas[index] ? 'tn-icon-down' : 'tn-icon-up'"></text>
</view> </view>
<text :class="collapsedAreas[index] ? 'tn-icon-down' : 'tn-icon-up'" class="tn-color-gray"></text>
</view> </view>
<view class="room-type-grid" v-show="!collapsedAreas[index]"> <view class="room-type-grid" v-show="!collapsedAreas[index]">
<view <view
class="room-type-item" class="room-type-item"
v-for="room in area.roomTypes" v-for="room in area.roomTypes"
:key="room.code" :key="room.code"
:class="{ 'is-active': selectedRoomType && selectedRoomType.code === room.code }" :class="{ 'is-active': selectedRoomType && selectedRoomType.code === room.code }"
@click="handleSelect(area, room)" @click="handleSelect(area, room)"
> >
<view class="room-name tn-text-ellipsis">{{ getRoomTypeDisplayName(room) }}</view> <view class="room-name tn-text-ellipsis">{{ getRoomTypeDisplayName(room) }}</view>
<view class="room-price" v-if="room.price">¥{{ getPriceValue(room.price) }}{{ $tr('/月') }}</view> <view class="room-price" v-if="room.price">
<text class="price-symbol">¥</text>
<text class="price-value">{{ getPriceValue(room.price) }}</text>
<text class="price-unit">{{ $tr('/月') }}</text>
</view>
<view v-else class="room-price room-price--empty">{{ $tr('价格面议') }}</view>
<view class="active-mark">
<text class="tn-icon-check"></text>
</view>
</view> </view>
</view> </view>
</view> </view>
@@ -124,28 +145,28 @@ export default {
}, },
loadData(force = false) { loadData(force = false) {
if (!force && this.areaList.length > 0) return if (!force && this.areaList.length > 0) return
this.isLoading = true this.isLoading = true
return areaPagination({ PageIndex: 1, PageSize: 100 }, this.language) return areaPagination({ PageIndex: 1, PageSize: 100 }, this.language)
.then((res) => { .then((res) => {
const rawList = Array.isArray(res?.data?.data) ? res.data.data : (Array.isArray(res?.data) ? res.data : []) const rawList = Array.isArray(res?.data?.data) ? res.data.data : (Array.isArray(res?.data) ? res.data : [])
this.areaList = rawList this.areaList = rawList
.map(item => normalizeAreaItem(item)) .map(item => normalizeAreaItem(item))
.filter(area => area.roomTypes && area.roomTypes.length > 0) .filter(area => area.roomTypes && area.roomTypes.length > 0)
// 初始化全部不折叠 // 初始化全部不折叠
const collapsed = {} const collapsed = {}
this.areaList.forEach((_, index) => { this.areaList.forEach((_, index) => {
collapsed[index] = false collapsed[index] = false
}) })
this.collapsedAreas = collapsed this.collapsedAreas = collapsed
this.syncSelection() this.syncSelection()
}) })
.catch((e) => { .catch((e) => {
console.error('load room types error', e) console.error('load room types error', e)
uni.showToast({ title: this.$tr('房型列表加载失败'), icon: 'none' }) uni.showToast({ title: this.$tr('房型加载失败'), icon: 'none' })
}) })
.finally(() => { .finally(() => {
this.isLoading = false this.isLoading = false
@@ -153,12 +174,12 @@ export default {
}, },
syncSelection() { syncSelection() {
if (!this.displayAreaList.length) return if (!this.displayAreaList.length) return
let foundArea = null let foundArea = null
let foundRoom = null let foundRoom = null
const previousAreaCode = this.selectedArea && this.selectedArea.code ? this.selectedArea.code : '' const previousAreaCode = this.selectedArea && this.selectedArea.code ? this.selectedArea.code : ''
const previousRoomCode = this.selectedRoomType && this.selectedRoomType.code ? this.selectedRoomType.code : '' const previousRoomCode = this.selectedRoomType && this.selectedRoomType.code ? this.selectedRoomType.code : ''
// Try to find by room type code // Try to find by room type code
if (this.value) { if (this.value) {
for (const area of this.displayAreaList) { for (const area of this.displayAreaList) {
@@ -170,18 +191,18 @@ export default {
} }
} }
} }
// If not found by room, try to find by initial area code // If not found by room, try to find by initial area code
if (!foundRoom && this.initialAreaCode) { if (!foundRoom && this.initialAreaCode) {
foundArea = this.displayAreaList.find(a => a.code === this.initialAreaCode) foundArea = this.displayAreaList.find(a => a.code === this.initialAreaCode)
} }
if (foundArea) { if (foundArea) {
this.selectedArea = foundArea this.selectedArea = foundArea
if (foundRoom) { if (foundRoom) {
this.selectedRoomType = foundRoom this.selectedRoomType = foundRoom
} }
if (foundRoom) { if (foundRoom) {
if (this.value !== foundRoom.code) { if (this.value !== foundRoom.code) {
this.$emit('input', foundRoom.code) this.$emit('input', foundRoom.code)
@@ -227,17 +248,30 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
$luxury-gold: #D8B56A;
$text-primary: #1A2233;
$text-secondary: #6A7A99;
$text-tertiary: #A1B0C4;
$surface: #FFFFFF;
$bg-popup: #F8FAFF;
.room-type-selector { .room-type-selector {
width: 100%; width: 100%;
} }
.selector-header { .selector-card {
background: #FFFFFF; background: $surface;
border-radius: 20rpx; border-radius: 20rpx;
padding: 26rpx 32rpx; padding: 26rpx 30rpx;
border: 1rpx solid rgba($brand-primary, 0.15); border: 2rpx solid rgba($brand-primary, 0.12);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05); box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.05);
min-height: 100rpx; min-height: 120rpx;
transition: all 0.25s ease;
}
.selector-card:active {
background: #FAFCFF;
border-color: rgba($brand-primary, 0.25);
} }
.selected-info { .selected-info {
@@ -245,72 +279,175 @@ export default {
overflow: hidden; overflow: hidden;
} }
.selected-label {
font-size: 22rpx;
color: $text-tertiary;
margin-bottom: 8rpx;
}
.selected-name { .selected-name {
font-size: 28rpx; font-size: 30rpx;
color: #1A2233; color: $text-primary;
font-weight: 600; font-weight: 700;
&.is-empty { &.is-empty {
color: #A0B0C0; color: $text-tertiary;
font-weight: normal; font-weight: normal;
} }
} }
.selected-area { .selected-area {
font-size: 24rpx;
color: $text-secondary;
margin-top: 10rpx;
display: flex;
align-items: center;
}
.area-icon {
font-size: 22rpx; font-size: 22rpx;
color: #6A7A99; margin-right: 6rpx;
margin-top: 6rpx; color: $brand-primary;
}
.selector-arrow {
width: 52rpx;
height: 52rpx;
border-radius: 50%;
background: #F4F8FF;
display: flex;
align-items: center;
justify-content: center;
margin-left: 20rpx;
flex-shrink: 0;
text {
font-size: 28rpx;
color: $text-secondary;
}
} }
.popup-content { .popup-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
background: #F8FAFF; background: $bg-popup;
} }
.popup-header { .popup-header {
padding: 40rpx 30rpx 20rpx; padding: 40rpx 30rpx 28rpx;
text-align: center; text-align: center;
border-bottom: 1px dashed rgba($brand-primary, 0.15); background: $surface;
background: #FFFFFF; border-bottom: 1rpx solid rgba($brand-primary, 0.08);
} }
.popup-title { .popup-title {
font-size: 32rpx; font-size: 34rpx;
font-weight: 700; font-weight: 700;
color: #1A2233; color: $text-primary;
margin-bottom: 8rpx;
letter-spacing: 1rpx;
}
.popup-subtitle {
font-size: 24rpx;
color: $text-secondary;
} }
.area-list-scroll { .area-list-scroll {
flex: 1; flex: 1;
height: 0; height: 0;
padding: 20rpx 30rpx 60rpx; padding: 24rpx 30rpx 60rpx;
}
.loading-state,
.empty-state {
padding-top: 120rpx;
}
.empty-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba($brand-primary, 0.08);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24rpx;
text {
font-size: 56rpx;
color: $brand-primary;
}
}
.empty-title {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
margin-bottom: 12rpx;
}
.empty-desc {
font-size: 24rpx;
color: $text-secondary;
} }
.area-group { .area-group {
margin-bottom: 30rpx; margin-bottom: 28rpx;
background: #FFFFFF; background: $surface;
border-radius: 20rpx; border-radius: 24rpx;
padding: 24rpx; padding: 28rpx;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.04); box-shadow: 0 8rpx 28rpx rgba(26, 34, 51, 0.04);
border: 1rpx solid rgba($brand-primary, 0.05); border: 1rpx solid rgba($brand-primary, 0.06);
} }
.area-title { .area-title {
font-size: 28rpx; margin-bottom: 24rpx;
font-weight: 600;
color: #1A2233;
margin-bottom: 20rpx;
} }
.area-dot { .area-title-left {
width: 12rpx; flex: 1;
height: 12rpx; min-width: 0;
}
.area-icon-wrap {
width: 48rpx;
height: 48rpx;
border-radius: 14rpx;
background: rgba($brand-primary, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-right: 16rpx;
flex-shrink: 0;
text {
font-size: 26rpx;
color: $brand-primary;
}
}
.area-title-text {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
}
.area-toggle {
width: 48rpx;
height: 48rpx;
border-radius: 50%; border-radius: 50%;
background: $brand-primary; background: #F4F8FF;
margin-right: 12rpx; display: flex;
box-shadow: 0 0 8rpx rgba($brand-primary, 0.4); align-items: center;
justify-content: center;
flex-shrink: 0;
text {
font-size: 24rpx;
color: $text-secondary;
}
} }
.room-type-grid { .room-type-grid {
@@ -320,36 +457,86 @@ export default {
} }
.room-type-item { .room-type-item {
padding: 20rpx; position: relative;
border-radius: 16rpx; padding: 24rpx 20rpx;
background: #FFFFFF; border-radius: 20rpx;
border: 1rpx solid rgba($brand-primary, 0.15); background: #FAFCFF;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05); border: 2rpx solid rgba($brand-primary, 0.1);
transition: all 0.3s ease; transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
&.is-active { &.is-active {
background: #FFFFFF; background: #FFFFFF;
border-color: $brand-primary; border-color: $brand-primary;
box-shadow: 0 4rpx 16rpx rgba($brand-primary, 0.1); box-shadow: 0 8rpx 24rpx rgba($brand-primary, 0.14);
.room-name { .room-name {
color: $brand-primary; color: $brand-primary;
} }
.room-price { .room-price {
color: $brand-primary; color: $brand-primary;
} }
.active-mark {
transform: scale(1);
opacity: 1;
}
} }
} }
.room-name { .room-name {
font-size: 26rpx; font-size: 28rpx;
font-weight: 600; font-weight: 700;
color: #1A2233; color: $text-primary;
margin-bottom: 8rpx; margin-bottom: 10rpx;
} }
.room-price { .room-price {
font-size: 24rpx;
color: $text-secondary;
display: flex;
align-items: baseline;
&--empty {
font-size: 22rpx;
color: $text-tertiary;
}
}
.price-symbol {
font-size: 22rpx; font-size: 22rpx;
color: #6A7A99; margin-right: 2rpx;
}
.price-value {
font-size: 28rpx;
font-weight: 700;
margin-right: 4rpx;
}
.price-unit {
font-size: 22rpx;
}
.active-mark {
position: absolute;
top: 12rpx;
right: 12rpx;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background: $brand-primary;
display: flex;
align-items: center;
justify-content: center;
transform: scale(0);
opacity: 0;
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
text {
font-size: 20rpx;
color: #FFFFFF;
}
} }
</style> </style>

View File

@@ -57,35 +57,38 @@ export default {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
$text-primary: #1A2233;
$text-secondary: #6A7A99;
$text-tertiary: #A1B0C4;
.option-row { .option-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 16rpx; gap: 20rpx;
} }
.option-chip { .option-chip {
padding: 16rpx 28rpx; padding: 0 34rpx;
border-radius: 999rpx; height: 72rpx;
background: rgba(255, 255, 255, 0.7); line-height: 72rpx;
color: #64748B; background: #F4F8FF;
font-size: 24rpx; border-radius: 36rpx;
line-height: 1; font-size: 26rpx;
border: 2rpx solid rgba(226, 232, 240, 0.8); color: $text-secondary;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.02); border: 2rpx solid transparent;
backdrop-filter: blur(8px); transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
} }
.option-chip--active { .option-chip--active {
background: #1A2B4C; background: linear-gradient(135deg, rgba($brand-primary, 0.1) 0%, rgba($brand-primary, 0.06) 100%);
border-color: #1A2B4C; border-color: rgba($brand-primary, 0.35);
color: #FFFFFF; color: $brand-primary;
font-weight: 500; font-weight: 600;
box-shadow: 0 8rpx 24rpx rgba(26, 43, 76, 0.2); box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.12);
} }
.empty-tips { .empty-tips {
color: #94A3B8; color: $text-tertiary;
font-size: 24rpx; font-size: 24rpx;
line-height: 1.7; line-height: 1.7;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -181,7 +181,8 @@
:formData="uploadFormData" :formData="uploadFormData"
:max-count="6" :max-count="6"
:max-size="5 * 1024 * 1024" :max-size="5 * 1024 * 1024"
uploadText="" :uploadText="''"
:customBtn="true"
@on-success="onUploadSuccess" @on-success="onUploadSuccess"
@on-list-change="onPhotoListChange" @on-list-change="onPhotoListChange"
@on-remove="onRemoveImage" @on-remove="onRemoveImage"
@@ -426,6 +427,25 @@ function pickReadableValue(...values) {
return '' return ''
} }
function isOpaqueCodeValue(value = '') {
const text = String(value || '').trim()
if (!text) {
return false
}
return /^[A-Z0-9-]{12,}$/i.test(text) && !/[\u4e00-\u9fa5\s]/.test(text)
}
function pickReadableLabel(...values) {
for (let index = 0; index < values.length; index += 1) {
const text = String(values[index] || '').trim()
if (!text || isOpaqueCodeValue(text)) {
continue
}
return text
}
return ''
}
function safeJsonParse(value, fallback = null) { function safeJsonParse(value, fallback = null) {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return value || fallback return value || fallback
@@ -532,18 +552,20 @@ export default {
return 'rgba(255, 255, 255, 0.96)' return 'rgba(255, 255, 255, 0.96)'
}, },
navBarBackStyle() { navBarBackStyle() {
const isDarkBg = this.scrollTop < 40
return { return {
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
background: isDarkBg ? 'rgba(0, 0, 0, 0.2)' : 'transparent', background: this.scrollTop < 40 ? 'rgba(255, 255, 255, 0.82)' : 'rgba(255, 255, 255, 0.96)',
borderRadius: '50%', border: '1rpx solid rgba(43, 142, 255, 0.08)',
width: '64rpx', boxShadow: '0 10rpx 28rpx rgba(31, 76, 145, 0.10)',
height: '64rpx', backdropFilter: 'blur(18rpx)',
borderRadius: '20rpx',
width: '72rpx',
height: '72rpx',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginLeft: '12rpx', marginLeft: '12rpx',
color: isDarkBg ? '#FFFFFF' : '#000000' color: '#1A2233'
} }
}, },
navTitleOpacity() { navTitleOpacity() {
@@ -573,7 +595,12 @@ export default {
) || '--' ) || '--'
}, },
currentAreaText() { currentAreaText() {
return pickReadableValue(this.currentOrderDetail.areaName, this.currentOrderDetail.campusName, this.currentOrderDetail.areaCode) return pickReadableLabel(
this.currentOrderDetail.areaText,
this.currentOrderDetail.areaName,
this.currentOrderDetail.areaLabel,
this.currentOrderDetail.campusName
)
}, },
currentLeaseText() { currentLeaseText() {
const start = String(this.currentOrderDetail.startDate || this.currentOrderDetail.start || '').slice(0, 10) const start = String(this.currentOrderDetail.startDate || this.currentOrderDetail.start || '').slice(0, 10)

View File

@@ -64,7 +64,8 @@ function logRequestError(name, error) {
} }
function resolveOrderNo(order = {}) { function resolveOrderNo(order = {}) {
const candidates = [order.orderNo, order.no, order.code, order.OrderNo] const safeOrder = order || {}
const candidates = [safeOrder.orderNo, safeOrder.no, safeOrder.code, safeOrder.OrderNo]
for (let index = 0; index < candidates.length; index += 1) { for (let index = 0; index < candidates.length; index += 1) {
const value = candidates[index] const value = candidates[index]
if (value === undefined || value === null) { if (value === undefined || value === null) {

View File

@@ -682,7 +682,7 @@
display: inline-block; display: inline-block;
&--custom { &--custom {
width: 100%; width: auto;
} }
} }
} }

View File

@@ -144,22 +144,23 @@ export function formatOrderDate(value) {
} }
function normalizeStatusKey(order = {}) { function normalizeStatusKey(order = {}) {
const rawStatus = String(order.status || order.orderStatus || '').trim() const safeOrder = order || {}
const rawStatus = String(safeOrder.status || safeOrder.orderStatus || '').trim()
if (['pending_review', 'approved_wait_assign', 'assigned_wait_room', 'rejected', 'contract_signed'].includes(rawStatus)) { if (['pending_review', 'approved_wait_assign', 'assigned_wait_room', 'rejected', 'contract_signed'].includes(rawStatus)) {
return rawStatus return rawStatus
} }
const contractStatus = Number(order.contractStatus) const contractStatus = Number(safeOrder.contractStatus)
if (contractStatus === 1) { if (contractStatus === 1) {
return 'contract_signed' return 'contract_signed'
} }
const approvalStatus = Number( const approvalStatus = Number(
order.approvalStatus !== undefined safeOrder.approvalStatus !== undefined
? order.approvalStatus ? safeOrder.approvalStatus
: order.auditStatus !== undefined : safeOrder.auditStatus !== undefined
? order.auditStatus ? safeOrder.auditStatus
: order.statusCode : safeOrder.statusCode
) )
if (approvalStatus === -1 || /reject|refuse|驳回/i.test(rawStatus)) { if (approvalStatus === -1 || /reject|refuse|驳回/i.test(rawStatus)) {
@@ -169,12 +170,12 @@ function normalizeStatusKey(order = {}) {
return 'pending_review' return 'pending_review'
} }
if ( if (
order.roomNo || safeOrder.roomNo ||
order.roomCode || safeOrder.roomCode ||
order.roomName || safeOrder.roomName ||
order.checkinDate || safeOrder.checkinDate ||
order.startDate || safeOrder.startDate ||
order.start safeOrder.start
) { ) {
return 'assigned_wait_room' return 'assigned_wait_room'
} }
@@ -182,17 +183,18 @@ function normalizeStatusKey(order = {}) {
} }
function resolveStatusText(order = {}, statusKey, language = getCurrentLanguage()) { function resolveStatusText(order = {}, statusKey, language = getCurrentLanguage()) {
const safeOrder = order || {}
const contractStatus = Number(order.contractStatus) const contractStatus = Number(safeOrder.contractStatus)
if (contractStatus === 1 && order.contractStatusDesc && language !== 'en') { if (contractStatus === 1 && safeOrder.contractStatusDesc && language !== 'en') {
return order.contractStatusDesc return safeOrder.contractStatusDesc
} }
if (language !== 'en' && order.approvalStatusDesc) { if (language !== 'en' && safeOrder.approvalStatusDesc) {
return order.approvalStatusDesc return safeOrder.approvalStatusDesc
} }
if (language !== 'en' && order.statusDesc) { if (language !== 'en' && safeOrder.statusDesc) {
return order.statusDesc return safeOrder.statusDesc
} }
const map = { const map = {
pending_review: getBookingText('pendingReview', language), pending_review: getBookingText('pendingReview', language),
@@ -218,35 +220,39 @@ function resolveTagType(statusKey) {
} }
function getBuildingFloorText(order = {}, language = getCurrentLanguage()) { function getBuildingFloorText(order = {}, language = getCurrentLanguage()) {
const safeOrder = order || {}
const isEn = language === 'en' const isEn = language === 'en'
const building = [ const building = [
isEn isEn
? (order.buildingNameEn || order.buildingEn || order.buildingSectionNameEn || order.buildingName || order.building || order.buildingSectionName || '') ? (safeOrder.buildingNameEn || safeOrder.buildingEn || safeOrder.buildingSectionNameEn || safeOrder.buildingName || safeOrder.building || safeOrder.buildingSectionName || '')
: (order.buildingName || order.building || order.buildingSectionName || ''), : (safeOrder.buildingName || safeOrder.building || safeOrder.buildingSectionName || ''),
isEn isEn
? (order.floorNameEn || order.floorEn || order.floorName || order.floor || '') ? (safeOrder.floorNameEn || safeOrder.floorEn || safeOrder.floorName || safeOrder.floor || '')
: (order.floorName || order.floor || '') : (safeOrder.floorName || safeOrder.floor || '')
].filter(Boolean).join(' ') ].filter(Boolean).join(' ')
return building || '--' return building || '--'
} }
function getRoomTypeText(order = {}, language = getCurrentLanguage()) { function getRoomTypeText(order = {}, language = getCurrentLanguage()) {
const safeOrder = order || {}
const isEn = language === 'en' const isEn = language === 'en'
if (isEn) { if (isEn) {
return order.roomTypeNameEn || order.styleNameEn || order.roomNameEn || order.roomTypeName || order.styleNameZh || order.roomType || order.roomName || '--' return safeOrder.roomTypeNameEn || safeOrder.styleNameEn || safeOrder.roomNameEn || safeOrder.roomTypeName || safeOrder.styleNameZh || safeOrder.roomType || safeOrder.roomName || '--'
} }
return order.roomTypeName || order.styleNameZh || order.roomType || order.roomName || '--' return safeOrder.roomTypeName || safeOrder.styleNameZh || safeOrder.roomType || safeOrder.roomName || '--'
} }
function getOrderNo(order = {}) { function getOrderNo(order = {}) {
return order.orderNo || order.no || order.code || '--' const safeOrder = order || {}
return safeOrder.orderNo || safeOrder.no || safeOrder.code || '--'
} }
export function getOrderStatusCode(order = {}) { export function getOrderStatusCode(order = {}) {
const safeOrder = order || {}
const candidates = [ const candidates = [
order.orderStatus, safeOrder.orderStatus,
order.statusCode, safeOrder.statusCode,
order.status safeOrder.status
] ]
for (let index = 0; index < candidates.length; index += 1) { for (let index = 0; index < candidates.length; index += 1) {
@@ -264,14 +270,15 @@ export function getOrderStatusCode(order = {}) {
} }
export function getOrderTime(order = {}) { export function getOrderTime(order = {}) {
const checkInPeriod = order.checkInPeriod || {} const safeOrder = order || {}
const checkInPeriod = safeOrder.checkInPeriod || {}
const candidates = [ const candidates = [
order.updateTime, safeOrder.updateTime,
order.createTime, safeOrder.createTime,
order.createdAt, safeOrder.createdAt,
order.startDate, safeOrder.startDate,
order.start, safeOrder.start,
order.checkinDate, safeOrder.checkinDate,
checkInPeriod.start checkInPeriod.start
] ]
@@ -287,13 +294,14 @@ export function sortOrdersByLatest(orderList = []) {
} }
function hasStayFields(order = {}) { function hasStayFields(order = {}) {
const safeOrder = order || {}
return !!( return !!(
order.roomNo safeOrder.roomNo
|| order.roomCode || safeOrder.roomCode
|| order.slotCode || safeOrder.slotCode
|| order.contractNo || safeOrder.contractNo
|| order.actualCheckInTime || safeOrder.actualCheckInTime
|| order.checkinTime || safeOrder.checkinTime
) )
} }
@@ -336,32 +344,33 @@ export function isCurrentBookingOrder(order = {}) {
} }
export function normalizeBookingOrder(order = {}, language = getCurrentLanguage()) { export function normalizeBookingOrder(order = {}, language = getCurrentLanguage()) {
const statusKey = normalizeStatusKey(order) const safeOrder = order || {}
const statusCode = getOrderStatusCode(order) const statusKey = normalizeStatusKey(safeOrder)
const checkInPeriod = order.checkInPeriod || {} const statusCode = getOrderStatusCode(safeOrder)
const startDate = order.startDate || order.start || order.checkinDate || checkInPeriod.start const checkInPeriod = safeOrder.checkInPeriod || {}
const endDate = order.endDate || order.end || order.checkoutDate || checkInPeriod.end const startDate = safeOrder.startDate || safeOrder.start || safeOrder.checkinDate || checkInPeriod.start
const endDate = safeOrder.endDate || safeOrder.end || safeOrder.checkoutDate || checkInPeriod.end
return { return {
...order, ...safeOrder,
orderNo: getOrderNo(order), orderNo: getOrderNo(safeOrder),
roomTypeText: getRoomTypeText(order, language), roomTypeText: getRoomTypeText(safeOrder, language),
buildingFloorText: getBuildingFloorText(order, language), buildingFloorText: getBuildingFloorText(safeOrder, language),
startDateText: formatOrderDate(startDate), startDateText: formatOrderDate(startDate),
endDateText: formatOrderDate(endDate), endDateText: formatOrderDate(endDate),
createdAtText: formatOrderDateTime(order.createTime || order.updateTime || order.createdAt), createdAtText: formatOrderDateTime(safeOrder.createTime || safeOrder.updateTime || safeOrder.createdAt),
roomNoText: language === 'en' roomNoText: language === 'en'
? (order.roomNoEn || order.roomCodeEn || order.roomNameEn || order.roomNo || order.roomCode || order.roomName || '--') ? (safeOrder.roomNoEn || safeOrder.roomCodeEn || safeOrder.roomNameEn || safeOrder.roomNo || safeOrder.roomCode || safeOrder.roomName || '--')
: (order.roomNo || order.roomCode || order.roomName || order.roomNoEn || order.roomCodeEn || order.roomNameEn || '--'), : (safeOrder.roomNo || safeOrder.roomCode || safeOrder.roomName || safeOrder.roomNoEn || safeOrder.roomCodeEn || safeOrder.roomNameEn || '--'),
areaText: language === 'en' areaText: language === 'en'
? (order.areaNameEn || order.campusNameEn || order.areaName || order.campusName || '--') ? (safeOrder.areaNameEn || safeOrder.campusNameEn || safeOrder.areaName || safeOrder.campusName || '--')
: (order.areaName || order.campusName || '--'), : (safeOrder.areaName || safeOrder.campusName || '--'),
rejectReasonText: order.rejectReason || order.approveReason || order.remark || '', rejectReasonText: safeOrder.rejectReason || safeOrder.approveReason || safeOrder.remark || '',
startDate, startDate,
endDate, endDate,
status: statusKey, status: statusKey,
statusCode, statusCode,
statusKey, statusKey,
statusText: resolveStatusText(order, statusKey, language), statusText: resolveStatusText(safeOrder, statusKey, language),
tagType: resolveTagType(statusKey) tagType: resolveTagType(statusKey)
} }
} }

View File

@@ -77,9 +77,18 @@ export function extractLoginSession(response = {}, language = 'zh') {
throw new Error(getText(language, '登录成功但未返回 accessToken', 'Login succeeded but accessToken is missing')) throw new Error(getText(language, '登录成功但未返回 accessToken', 'Login succeeded but accessToken is missing'))
} }
const userCode = responseData.userCode
|| responseData.code
|| nestedData.userCode
|| nestedData.code
|| response.userCode
|| response.code
|| ''
return { return {
token, token,
expires expires,
userCode
} }
} }
@@ -98,11 +107,13 @@ export function buildLoginSessionUser(options = {}) {
const account = source.account || '' const account = source.account || ''
const password = source.password || '' const password = source.password || ''
const language = source.language || 'zh' const language = source.language || 'zh'
const userCode = source.userCode || ''
if (latestRegistration) { if (latestRegistration) {
return buildUserFromRegistrationApplication(latestRegistration, { return buildUserFromRegistrationApplication(latestRegistration, {
password, password,
language language,
userCode
}) })
} }
@@ -113,7 +124,8 @@ export function buildLoginSessionUser(options = {}) {
return normalizeUser({ return normalizeUser({
account, account,
username: account, username: account,
password password,
userCode
}) })
} }
@@ -124,6 +136,7 @@ export function resolveLoginPreparation(options = {}) {
const latestRegistration = source.latestRegistration || null const latestRegistration = source.latestRegistration || null
const account = source.account || '' const account = source.account || ''
const password = source.password || '' const password = source.password || ''
const userCode = source.userCode || ''
if (!latestRegistration) { if (!latestRegistration) {
return { return {
@@ -132,7 +145,8 @@ export function resolveLoginPreparation(options = {}) {
matchedLocalUser, matchedLocalUser,
account, account,
password, password,
language language,
userCode
}) })
} }
} }
@@ -163,8 +177,9 @@ export function resolveLoginPreparation(options = {}) {
latestRegistration, latestRegistration,
matchedLocalUser, matchedLocalUser,
account, account,
password, password,
language language,
userCode
}) })
} }
} }

View File

@@ -1,8 +1,9 @@
const RESERVATION_FLOW_STEP = { const RESERVATION_FLOW_STEP = {
INSPECTION: 10, APPLICATION: 1,
BILL: 20, INSPECTION: 2,
CONTRACT: 30, BILL: 3,
CHECKIN: 40 CONTRACT: 4,
CHECKIN: 5
} }
function getLanguage(language) { function getLanguage(language) {
@@ -92,6 +93,23 @@ export function getReservationCurrentNode(flow = {}) {
return nodes.find(item => item.isCurrent) || nodes[nodes.length - 1] return nodes.find(item => item.isCurrent) || nodes[nodes.length - 1]
} }
export function hasEffectiveReservationFlow(flow = {}) {
const currentNode = getReservationCurrentNode(flow)
if (!currentNode) {
return false
}
const step = Number(currentNode.step || 0)
const refNo = String(currentNode.refNo || '').trim()
// 当前步骤为申请单(1)且没有 refNo 时,视为尚未提交预约单
if (step === 1 && !refNo) {
return false
}
return step > 0
}
export function buildReservationFlowMap(flowList = []) { export function buildReservationFlowMap(flowList = []) {
return normalizeArray(flowList) return normalizeArray(flowList)
.filter(item => item && typeof item === 'object') .filter(item => item && typeof item === 'object')

View File

@@ -113,7 +113,8 @@ function pickFirstValue(...values) {
} }
function buildPendingContractQuery(order = {}) { function buildPendingContractQuery(order = {}) {
const orderNo = pickFirstValue(order.orderNo, order.no, order.code, order.OrderNo) const safeOrder = order || {}
const orderNo = pickFirstValue(safeOrder.orderNo, safeOrder.no, safeOrder.code, safeOrder.OrderNo)
if (!orderNo) { if (!orderNo) {
return null return null
} }
@@ -124,7 +125,8 @@ function buildPendingContractQuery(order = {}) {
} }
function buildEffectiveContractQuery(order = {}) { function buildEffectiveContractQuery(order = {}) {
const orderNo = pickFirstValue(order.orderNo, order.no, order.code, order.OrderNo) const safeOrder = order || {}
const orderNo = pickFirstValue(safeOrder.orderNo, safeOrder.no, safeOrder.code, safeOrder.OrderNo)
if (!orderNo) { if (!orderNo) {
return null return null
} }