Compare commits

...

2 Commits

Author SHA1 Message Date
liangjian
9fee2c3a74 feat: 优化首页服务入口与租住业务流程
补齐首页待办跳转、押金支付引导和多类租住服务页面能力,统一租客端关键业务流程体验。
2026-08-25 13:53:12 +08:00
liangjian
1111ac148b feat: 重构核心租住流程页面与交互体验
统一注册、看房、申请与入住等核心页面的视觉和流程衔接,补齐支付、签约与状态流转闭环,提升移动端操作连贯性。
2026-07-29 09:05:38 +08:00
55 changed files with 15268 additions and 3283 deletions

View File

@@ -30,6 +30,10 @@ function buildIotGatewayUrl(path = '') {
return `${iotBaseUrl}${normalizedPath}` return `${iotBaseUrl}${normalizedPath}`
} }
function shouldIgnoreGatewayRoom404(error = {}) {
return Number(error && error.statusCode) === 404
}
function normalizeUploadResponse(data) { function normalizeUploadResponse(data) {
if (typeof data !== 'string') { if (typeof data !== 'string') {
return Promise.resolve(data) return Promise.resolve(data)
@@ -237,6 +241,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({
@@ -430,14 +449,15 @@ export function orderSearchedReservationFlow(data = {}, language) {
}) })
} }
export function roomInspectionMyPage(params = {}, language) { export function roomInspectionMyPage(params = {}, language, options = {}) {
return request({ return request({
url: '/roominspection/my/page', url: '/roominspection/my/page',
method: 'GET', method: 'GET',
params: params || {}, params: params || {},
header: { header: {
'X-Requested-Language': resolveLanguageHeader(language) 'X-Requested-Language': resolveLanguageHeader(language)
} },
...options
}) })
} }
@@ -526,6 +546,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 +614,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: {
@@ -696,6 +738,18 @@ export function notificationRead(code, language) {
}) })
} }
export function getMyTodoList(params = {}, language, options = {}) {
return request({
url: '/todo/my/list',
method: 'GET',
params: params || {},
header: {
'X-Requested-Language': resolveLanguageHeader(language)
},
...options
})
}
// --- 房源与区域 --- // --- 房源与区域 ---
export function areaPagination(params = {}, language) { export function areaPagination(params = {}, language) {
return request({ return request({
@@ -709,6 +763,18 @@ export function areaPagination(params = {}, language) {
}) })
} }
export function areaDetailPagination(params = {}, language) {
return request({
url: '/area/pagination',
method: 'GET',
params,
header: {
'X-Requested-Language': resolveLanguageHeader(language)
},
withAuth: false
})
}
export function facilityManagers(params = {}, language) { export function facilityManagers(params = {}, language) {
return request({ return request({
url: '/facility/managers', url: '/facility/managers',
@@ -818,8 +884,15 @@ export function gatewayRoomDeviceList(brandId = '', roomCode = '', language) {
method: 'POST', method: 'POST',
header: { header: {
'X-Requested-Language': resolveLanguageHeader(language) 'X-Requested-Language': resolveLanguageHeader(language)
} },
hideErrorToast: true
}) })
.catch((error) => {
if (shouldIgnoreGatewayRoom404(error)) {
return []
}
return Promise.reject(error)
})
} }
function stringifyGatewayExecuteValue(value = '') { function stringifyGatewayExecuteValue(value = '') {
@@ -922,6 +995,38 @@ export function gatewaySyncDeviceInfo(brandId = '', deviceNo = '', language) {
}) })
} }
// --- 门锁 ---
export function deviceGetDevicesByRoomAsync(params = {}, language) {
const roomCode = String((params && params.roomCode) || '').trim()
if (!roomCode) {
return Promise.reject(new Error('roomCode 不能为空'))
}
return request({
url: '/device/getdevicesbyroomasync',
method: 'GET',
params: {
roomCode
},
header: {
'X-Requested-Language': resolveLanguageHeader(language)
},
hideErrorToast: true
})
}
export function lockHxjOpenLock(data = {}, language) {
return createJsonPatchRequest('/LockHxj/OpenLock', data, language)
}
export function lockHxjGetKeysByUserCode(data = {}, language) {
return createJsonPatchRequest('/LockHxj/GetKeysByUserCode', data, language)
}
export function lockHxjAddFingerKey(data = {}, language) {
return createJsonPatchRequest('/LockHxj/AddFingerKey', data, language)
}
// --- 内容 --- // --- 内容 ---
export function bannerAll(language) { export function bannerAll(language) {
return request({ return request({

View File

@@ -0,0 +1,376 @@
<template>
<tn-popup :value="value" mode="bottom" length="84%" :borderRadius="32" :closeBtn="true" @close="close" @input="handleInput">
<view class="deposit-other-payment-popup">
<view class="popup-header">
<view class="popup-handle"></view>
<view class="popup-title">{{ getLocalizedText('其他支付方式', 'Other Payment Methods') }}</view>
</view>
<scroll-view scroll-y class="popup-body" :show-scrollbar="false">
<view class="popup-content">
<view v-if="hasConfiguredContent" class="hero-card">
<view class="hero-badge">{{ getLocalizedText('线下支付说明', 'Alternative Payment') }}</view>
<view class="hero-title">{{ getLocalizedText('定金支付备用方案', 'Deposit payment backup plan') }}</view>
<view class="hero-desc">
{{ getLocalizedText(
'当线上支付渠道暂不可用时,可通过下方联系电话、二维码或转账说明继续完成定金支付。',
'Use the configured contact, QR code, or transfer instructions below when the online payment channel is unavailable.'
) }}
</view>
</view>
<view v-if="loading" class="loading-card">
<view class="loading-chip">{{ getLocalizedText('正在同步其他支付配置,请稍候...', 'Syncing alternative payment details...') }}</view>
</view>
<template v-else>
<view v-if="phone" class="contact-card">
<view class="contact-main">
<view class="contact-label">{{ getLocalizedText('联系电话', 'Contact Phone') }}</view>
<view class="contact-value">{{ phone }}</view>
</view>
<view class="contact-action" @click="handleCallPhone">
<text class="tn-text-bold">{{ getLocalizedText('拨打电话', 'Call') }}</text>
</view>
</view>
<view v-if="qrCodeUrl" class="qr-card">
<view class="section-title">{{ getLocalizedText('支付二维码', 'Payment QR Code') }}</view>
<image class="qr-image" :src="qrCodeUrl" mode="widthFix" @click="previewQrCode"></image>
<view class="qr-hint">
{{ getLocalizedText('点击二维码图片可查看大图。', 'Tap the image to preview the code in full size.') }}
</view>
</view>
<view v-if="richTextNodes" class="rich-text-card">
<view class="section-title">{{ getLocalizedText('支付说明', 'Payment Instructions') }}</view>
<rich-text class="rich-text-content" :nodes="richTextNodes"></rich-text>
</view>
<view v-if="!phone && !qrCodeUrl && !richTextNodes" class="empty-card">
<view class="empty-title">{{ getLocalizedText('暂未配置展示内容', 'Configuration Pending') }}</view>
<view class="empty-desc">
{{ getLocalizedText(
'当前暂未同步到其他支付配置,请联系公寓工作人员处理。',
'Alternative payment details are not available yet. Please contact the property staff for manual payment assistance.'
) }}
</view>
</view>
</template>
</view>
</scroll-view>
<view class="popup-footer">
<view class="notice-action-btn" @click="close">
<text class="tn-text-bold">{{ getLocalizedText('我知道了', 'Got It') }}</text>
</view>
</view>
</view>
</tn-popup>
</template>
<script>
import { addFilePrefix } from '@/utils/common'
import { normalizeRichTextImages } from '@/utils/rich-text'
function escapeHtml(value = '') {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function normalizeContentToRichText(value = '') {
const text = String(value || '').trim()
if (!text) {
return ''
}
if (/<[a-z][\s\S]*>/i.test(text)) {
return normalizeRichTextImages(
text,
'display:block;width:100%;max-width:100%;height:auto;box-sizing:border-box;border-radius:20rpx;margin:18rpx 0;vertical-align:top;'
)
}
return `<div style="white-space:pre-wrap;line-height:1.9;color:#334155;font-size:28rpx;">${escapeHtml(text)}</div>`
}
export default {
name: 'DepositOtherPaymentPopup',
props: {
value: {
type: Boolean,
default: false
},
config: {
type: Object,
default: () => ({})
},
loading: {
type: Boolean,
default: false
}
},
computed: {
language() {
return this.$getLanguage()
},
phone() {
return String((this.config && this.config.phone) || '').trim()
},
qrCodeUrl() {
return addFilePrefix(String((this.config && this.config.qrCode) || '').trim())
},
localizedContent() {
if (this.language === 'en') {
return this.config.contentEn || this.config.contentZh || ''
}
return this.config.contentZh || this.config.contentEn || ''
},
hasConfiguredContent() {
return !!(this.phone || this.qrCodeUrl || this.richTextNodes)
},
richTextNodes() {
return normalizeContentToRichText(this.localizedContent)
}
},
methods: {
getLocalizedText(zh, en) {
return this.language === 'en' ? en : zh
},
handleInput(value) {
this.$emit('input', value)
this.$emit('update:modelValue', value)
},
close() {
this.handleInput(false)
},
handleCallPhone() {
if (!this.phone) {
uni.showToast({
title: this.getLocalizedText('暂无联系电话', 'No contact phone available'),
icon: 'none'
})
return
}
uni.makePhoneCall({
phoneNumber: this.phone
})
},
previewQrCode() {
if (!this.qrCodeUrl) {
return
}
uni.previewImage({
urls: [this.qrCodeUrl]
})
}
}
}
</script>
<style scoped>
.deposit-other-payment-popup {
background: #ffffff;
border-radius: 32rpx 32rpx 0 0;
display: flex;
flex-direction: column;
height: 80vh;
}
.popup-header {
padding: 18rpx 88rpx 20rpx;
text-align: center;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(251, 253, 255, 0.96) 100%);
}
.popup-handle {
width: 88rpx;
height: 8rpx;
margin: 0 auto 18rpx;
border-radius: 999rpx;
background: rgba(148, 163, 184, 0.35);
}
.popup-title {
font-size: 34rpx;
font-weight: 600;
line-height: 1.2;
letter-spacing: 1rpx;
color: #1a2233;
}
.popup-body {
flex: 1;
height: 0;
min-height: 0;
padding: 28rpx 28rpx 24rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #f7faff 0%, #f2f5fc 100%);
}
.popup-content {
display: flex;
flex-direction: column;
gap: 24rpx;
padding-bottom: 24rpx;
}
.hero-card {
padding: 28rpx;
border-radius: 32rpx;
background:
radial-gradient(circle at top right, rgba(43, 142, 255, 0.16) 0, rgba(43, 142, 255, 0) 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(246, 250, 255, 0.96) 100%);
border: 1rpx solid rgba(43, 142, 255, 0.1);
box-shadow: 0 24rpx 60rpx rgba(43, 142, 255, 0.1);
}
.hero-badge {
display: inline-flex;
align-items: center;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba(43, 142, 255, 0.1);
color: #2b6fd6;
font-size: 22rpx;
font-weight: 700;
}
.hero-title {
margin-top: 18rpx;
font-size: 36rpx;
line-height: 1.25;
font-weight: 700;
color: #183b73;
}
.hero-desc {
margin-top: 12rpx;
font-size: 25rpx;
line-height: 1.7;
color: #607089;
}
.loading-card,
.contact-card,
.qr-card,
.rich-text-card,
.empty-card {
padding: 24rpx 28rpx;
border-radius: 28rpx;
background: rgba(255, 255, 255, 0.88);
border: 1rpx solid rgba(43, 142, 255, 0.08);
box-shadow: inset 0 1rpx 0 rgba(255, 255, 255, 0.85);
}
.loading-chip {
display: inline-flex;
padding: 12rpx 20rpx;
border-radius: 999rpx;
background: rgba(43, 142, 255, 0.08);
color: #4c6a95;
font-size: 24rpx;
}
.contact-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.contact-main {
flex: 1;
min-width: 0;
}
.contact-label,
.section-title {
font-size: 24rpx;
color: #7c8ca5;
font-weight: 600;
}
.contact-value {
margin-top: 10rpx;
font-size: 32rpx;
line-height: 1.4;
font-weight: 700;
color: #22324d;
word-break: break-all;
}
.contact-action {
padding: 0 28rpx;
height: 76rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #2b8eff 0%, #1f6fe5 100%);
color: #ffffff;
box-shadow: 0 14rpx 28rpx rgba(43, 142, 255, 0.18);
flex-shrink: 0;
}
.qr-card {
text-align: center;
}
.qr-image {
width: 360rpx;
max-width: 100%;
margin: 18rpx auto 0;
border-radius: 24rpx;
overflow: hidden;
box-shadow: 0 18rpx 40rpx rgba(15, 23, 42, 0.1);
}
.qr-hint {
margin-top: 16rpx;
font-size: 22rpx;
line-height: 1.6;
color: #7c8ca5;
}
.rich-text-content {
margin-top: 18rpx;
}
.empty-title {
font-size: 30rpx;
font-weight: 700;
color: #22324d;
}
.empty-desc {
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.7;
color: #7c8ca5;
}
.popup-footer {
padding: 18rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.92) 26%, #ffffff 100%);
display: flex;
justify-content: center;
backdrop-filter: blur(8rpx);
}
.notice-action-btn {
width: 100%;
height: 88rpx;
padding: 0 40rpx;
border-radius: 999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
background: linear-gradient(135deg, #2b8eff 0%, #1f6fe5 100%);
color: #ffffff;
box-shadow: 0 14rpx 28rpx rgba(43, 142, 255, 0.22);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{ {
"name" : "测试", "name" : "绿居公寓手机端",
"appid" : "__UNI__056D753", "appid" : "__UNI__056D753",
"description" : "", "description" : "",
"versionName" : "1.0.0", "versionName" : "1.0.0",

View File

@@ -156,6 +156,12 @@
"navigationBarTitleText": "Utility Recharge" "navigationBarTitleText": "Utility Recharge"
} }
}, },
{
"path": "lock_control/lock_control",
"style": {
"navigationBarTitleText": "Smart Lock"
}
},
{ {
"path": "smart_control/smart_control", "path": "smart_control/smart_control",
"style": { "style": {

File diff suppressed because it is too large Load Diff

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">
<!-- 头部背景与用户信息 (悬浮质感设计) --> <!-- 头部背景与用户信息 (悬浮质感设计) -->
@@ -308,7 +308,7 @@ export default {
return '-' return '-'
} }
const currentOrder = this.getCurrentOrder() || {} const currentOrder = this.getCurrentOrder() || {}
const startText = currentOrder.startDate || currentOrder.start || currentOrder.checkinDate || '' const startText = currentOrder.contractStartTime || currentOrder.startDate || currentOrder.start || currentOrder.checkinDate || ''
if (!startText) { if (!startText) {
return '--' return '--'
} }
@@ -509,6 +509,12 @@ export default {
this.goToRegistrationRecords() this.goToRegistrationRecords()
return return
} }
if (item.key === 'lock') {
uni.navigateTo({
url: '/pkgService/lock_control/lock_control'
})
return
}
if (item.key === 'butler') { if (item.key === 'butler') {
this.goToSubmitRequest(itemTitle, itemDesc, { this.goToSubmitRequest(itemTitle, itemDesc, {
businessType: 'butler-contact', businessType: 'butler-contact',

View File

@@ -252,6 +252,10 @@ export default {
type: Number, type: Number,
default: 0 default: 0
}, },
isActive: {
type: Boolean,
default: false
},
language: { language: {
type: String, type: String,
default: 'zh' default: 'zh'
@@ -348,7 +352,14 @@ export default {
}) })
}, },
handleBannerChange(e) { handleBannerChange(e) {
this.$emit('banner-change', e.detail.current) if (!this.isActive) {
return
}
const nextIndex = Number(e && e.detail && e.detail.current)
if (!Number.isFinite(nextIndex)) {
return
}
this.$emit('banner-change', nextIndex)
}, },
openStoreDetail(area) { openStoreDetail(area) {
uni.navigateTo({ uni.navigateTo({

View File

@@ -91,6 +91,10 @@ export default {
type: Number, type: Number,
default: 0 default: 0
}, },
isActive: {
type: Boolean,
default: false
},
language: { language: {
type: String, type: String,
default: 'zh' default: 'zh'
@@ -144,9 +148,19 @@ export default {
.trim() .trim()
}, },
handleBannerChange(e) { handleBannerChange(e) {
this.$emit('banner-change', e.detail.current) if (!this.isActive) {
return
}
const nextIndex = Number(e && e.detail && e.detail.current)
if (!Number.isFinite(nextIndex)) {
return
}
this.$emit('banner-change', nextIndex)
}, },
switchBanner(index) { switchBanner(index) {
if (!this.isActive) {
return
}
this.$emit('banner-switch', index) this.$emit('banner-switch', index)
} }
} }

View File

@@ -28,6 +28,7 @@
> >
<swiper-item> <swiper-item>
<SelectRoomHero <SelectRoomHero
:is-active="currentScreen === 0"
:language="currentLang" :language="currentLang"
:banner-list="bannerList" :banner-list="bannerList"
:current-banner="currentBanner" :current-banner="currentBanner"
@@ -48,6 +49,7 @@
:show-scrollbar="false" :show-scrollbar="false"
> >
<SelectRoomAreaPanel <SelectRoomAreaPanel
:is-active="currentScreen === 1"
:language="currentLang" :language="currentLang"
:banner-list="bannerList" :banner-list="bannerList"
:current-banner-index="currentBannerIndex" :current-banner-index="currentBannerIndex"
@@ -196,12 +198,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 +238,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 = ''
@@ -408,10 +433,18 @@ export default {
}) })
}, },
handleBannerChange(index) { handleBannerChange(index) {
this.currentBannerIndex = index const nextIndex = Number(index)
if (!Number.isFinite(nextIndex) || nextIndex === this.currentBannerIndex) {
return
}
this.currentBannerIndex = nextIndex
}, },
switchBanner(index) { switchBanner(index) {
this.currentBannerIndex = index const nextIndex = Number(index)
if (!Number.isFinite(nextIndex) || nextIndex === this.currentBannerIndex) {
return
}
this.currentBannerIndex = nextIndex
}, },
collapseToTopBanner() { collapseToTopBanner() {
this.currentScreen = 1 this.currentScreen = 1

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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, getReservationCurrentFlow, 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,39 @@ 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 = getReservationCurrentFlow(flowList) || 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 +299,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 +327,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,193 @@
<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、请在 5 分钟内支付定金或者款项,否则系统将自动取消这个订单,释放锁定的房源。</view>
<view class="notice-item-en">Please pay the deposit or full payment within 5 minutes. Otherwise, the system will automatically cancel this order and release the reserved housing unit.</view>
</view>
<view class="notice-item">
<view class="notice-item-zh">2、缴纳线上定金可锁定房源并预约专属看房,平台收取定金后该房源临时下架,不再接待其他客户看房。</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">3、请于约定时间完成实地看房,并在看房结束后 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">4、如因租客个人原因取消看房、看房后不愿承租房屋或拒绝签署租赁合同,定金不予退还。</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">5、若因我方房源信息虚假、房屋无法正常交付等我方过错,造成无法看房或签约,定金将全额按原支付路径退还。</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">6、待双方正式签署租赁合同后,该定金可直接抵扣首期租金。</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">7、您支付定金,即代表已完整知晓房源全部信息,并自愿接受本须知全部条款约束。</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 +332,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 +677,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 +826,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

@@ -192,9 +192,10 @@ export default {
`${this.$tr('支付方式')}: ${this.payMethod}`, `${this.$tr('支付方式')}: ${this.payMethod}`,
`${this.$tr('支付金额')}: $500.00`, `${this.$tr('支付金额')}: $500.00`,
'', '',
`1. ${this.$tr('请确认支付方式和金额无误后再继续操作。')}`, `1. ${this.$tr('请在 5 分钟内支付定金或者款项,否则系统将自动取消这个订单,释放锁定的房源。')}`,
`2. ${this.$tr('当前页面为预支付引导页,真实支付接口暂未接入。')}`, `2. ${this.$tr('请确认支付方式和金额无误后再继续操作。')}`,
`3. ${this.$tr('确认后将进入后续提交流程,由工作人员继续跟进。')}` `3. ${this.$tr('当前页面为预支付引导页,真实支付接口暂未接入。')}`,
`4. ${this.$tr('确认后将进入后续提交流程,由工作人员继续跟进。')}`
].join('\n') ].join('\n')
return new Promise((resolve) => { return new Promise((resolve) => {

View File

@@ -33,7 +33,7 @@
</view> </view>
</view> </view>
<text class="asset-name tn-text-ellipsis">{{ item.assetName }}</text> <text class="asset-name tn-text-ellipsis">{{ getAssetDisplayName(item) }}</text>
<text v-if="item.brand" class="asset-brand tn-text-ellipsis">{{ item.brand }}</text> <text v-if="item.brand" class="asset-brand tn-text-ellipsis">{{ item.brand }}</text>
<text v-else class="asset-brand-placeholder"></text> <text v-else class="asset-brand-placeholder"></text>
</view> </view>
@@ -53,7 +53,14 @@ export default {
default: 'zh' default: 'zh'
} }
}, },
methods: {} methods: {
getAssetDisplayName(item = {}) {
if (this.language === 'en') {
return item.assetNameEn || item.assetName || ''
}
return item.assetName || item.assetNameEn || ''
}
}
} }
</script> </script>

View File

@@ -446,6 +446,7 @@ function normalizeRoomAssets(list = []) {
return null return null
} }
const assetName = normalizeText(item.assetName || item.name || item.label) const assetName = normalizeText(item.assetName || item.name || item.label)
const assetNameEn = normalizeText(item.assetNameEn || item.nameEn || item.labelEn)
if (!assetName) { if (!assetName) {
return null return null
} }
@@ -456,6 +457,7 @@ function normalizeRoomAssets(list = []) {
key: `${item.roomTypeCode || 'asset'}-${item.assetName || item.name || index}`, key: `${item.roomTypeCode || 'asset'}-${item.assetName || item.name || index}`,
roomTypeCode: item.roomTypeCode || '', roomTypeCode: item.roomTypeCode || '',
assetName, assetName,
assetNameEn,
brand: normalizeText(item.brand), brand: normalizeText(item.brand),
icon: iconClass ? '' : addFilePrefix(rawIcon), icon: iconClass ? '' : addFilePrefix(rawIcon),
iconClass, iconClass,

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>
@@ -103,8 +98,8 @@
import StoreInfo from './components/StoreInfo.vue'; import StoreInfo from './components/StoreInfo.vue';
import FacilityList from './components/FacilityList.vue'; import FacilityList from './components/FacilityList.vue';
import RoomList from './components/RoomList.vue'; import RoomList from './components/RoomList.vue';
import { facilityManagers } from '@/api/index.js' import { areaDetailPagination, facilityManagers } from '@/api/index.js'
import { localizeFacilityName, localizeHousingTag } from '../../utils/select-room-normalizers' import { localizeFacilityName, localizeHousingTag, normalizeAreaItem } from '../../utils/select-room-normalizers'
function parseCoordinate(...values) { function parseCoordinate(...values) {
for (let i = 0; i < values.length; i += 1) { for (let i = 0; i < values.length; i += 1) {
@@ -145,18 +140,12 @@ export default {
if (Array.isArray(this.storeData.images) && this.storeData.images.length) { if (Array.isArray(this.storeData.images) && this.storeData.images.length) {
return this.storeData.images.filter(Boolean) return this.storeData.images.filter(Boolean)
} }
if (this.heroImage) {
return [this.heroImage]
}
return [] return []
}, },
heroImage() { heroImage() {
if (this.storeData.images && this.storeData.images[0]) { if (this.storeData.images && this.storeData.images[0]) {
return this.storeData.images[0] return this.storeData.images[0]
} }
if (this.roomTypes.length > 0 && this.roomTypes[0].images && this.roomTypes[0].images[0]) {
return this.roomTypes[0].images[0]
}
return '' return ''
}, },
storeTags() { storeTags() {
@@ -224,24 +213,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) {
@@ -276,7 +278,74 @@ export default {
applyStoreData(data) { applyStoreData(data) {
this.storeData = data && typeof data === 'object' ? data : {} this.storeData = data && typeof data === 'object' ? data : {}
this.roomTypes = this.storeData.roomTypes || [] this.roomTypes = this.storeData.roomTypes || []
this.loadAreaManager() this.currentImageIndex = 0
return this.loadAreaDetail()
.catch((error) => {
console.warn('查询区域详情失败', error)
})
.finally(() => {
this.loadAreaManager()
})
},
pickAreaDetail(rawList = []) {
if (!Array.isArray(rawList) || !rawList.length) {
return null
}
const areaCode = String(this.storeData.code || this.storeData.areaCode || '').trim()
const areaName = String(this.storeData.name || '').trim()
const areaNameEn = String(this.storeData.nameEn || '').trim()
if (areaCode) {
const exactCodeItem = rawList.find((item) => String(item && item.code || '').trim() === areaCode)
if (exactCodeItem) {
return exactCodeItem
}
}
const exactNameItem = rawList.find((item) => {
const name = String(item && item.name || '').trim()
const nameEn = String(item && item.nameEn || '').trim()
return (areaName && name === areaName) || (areaNameEn && nameEn === areaNameEn)
})
if (exactNameItem) {
return exactNameItem
}
return rawList[0] || null
},
loadAreaDetail() {
const areaCode = String(this.storeData.code || this.storeData.areaCode || '').trim()
const searchKeyword = areaCode || String(this.storeData.name || '').trim()
if (!searchKeyword) {
return Promise.resolve()
}
return areaDetailPagination({
PageIndex: 1,
PageSize: areaCode ? 20 : 50,
Search: searchKeyword
}, this.language)
.then((res) => {
const rawList = Array.isArray(res && res.data && res.data.data)
? res.data.data
: Array.isArray(res && res.data)
? res.data
: []
const matchedArea = this.pickAreaDetail(rawList)
if (!matchedArea) {
return
}
const normalizedArea = normalizeAreaItem(matchedArea)
this.storeData = {
...this.storeData,
...normalizedArea,
roomTypes: (this.storeData.roomTypes || []).length ? (this.storeData.roomTypes || []) : normalizedArea.roomTypes,
tags: (this.storeData.tags || []).length ? this.storeData.tags : normalizedArea.tags,
facilities: (this.storeData.facilities || []).length ? this.storeData.facilities : normalizedArea.facilities
}
})
}, },
loadAreaManager() { loadAreaManager() {
const areaCode = this.storeData.code || this.storeData.areaCode || '' const areaCode = this.storeData.code || this.storeData.areaCode || ''
@@ -412,9 +481,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 +504,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 +511,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 +535,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 +584,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 +659,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

@@ -23,16 +23,28 @@
</view> </view>
</view> </view>
<view
v-if="showPaymentStatusPanel"
class="payment-status-panel tn-margin-bottom-lg"
:class="`payment-status-panel--${paymentStatusType}`"
>
<view class="payment-status-panel__label">{{ $tr('支付状态') }}</view>
<view class="payment-status-panel__main">
<view class="payment-status-panel__value">{{ paymentStatusText }}</view>
<view v-if="canViewBill" class="payment-status-panel__hint">{{ $tr('请尽快完成定金支付') }}</view>
</view>
</view>
<view <view
v-if="statusReminderText" v-if="statusReminderText"
class="status-reminder-panel tn-margin-bottom-lg" class="status-reminder-panel tn-margin-bottom-lg"
:class="{ :class="{
'status-reminder-panel--warning': shouldPayDeposit(detail), 'status-reminder-panel--warning': shouldPayDeposit(detail) && !isWaitingButlerRoomAssignment(detail),
'status-reminder-panel--info': isWaitingRoomAssignment(detail) 'status-reminder-panel--info': isWaitingRoomAssignment(detail)
}" }"
> >
<view class="status-reminder-panel__icon"> <view class="status-reminder-panel__icon">
<text :class="shouldPayDeposit(detail) ? 'tn-icon-warning-fill' : 'tn-icon-time-fill'"></text> <text :class="shouldPayDeposit(detail) && !isWaitingButlerRoomAssignment(detail) ? 'tn-icon-warning-fill' : 'tn-icon-time-fill'"></text>
</view> </view>
<view class="status-reminder-panel__content"> <view class="status-reminder-panel__content">
<view class="status-reminder-panel__title">{{ $tr('当前进度提醒') }}</view> <view class="status-reminder-panel__title">{{ $tr('当前进度提醒') }}</view>
@@ -116,7 +128,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"
@@ -145,7 +157,7 @@
</template> </template>
<script> <script>
import { contractDetail, orderSearchedReservationFlow, roomInspectionDetail, userDetailCancel } from '@/api/index.js' import { accountQueryBills, contractDetail, orderSearchedReservationFlow, 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,
@@ -202,6 +214,43 @@ function normalizeApplicationList(list = []) {
}) })
} }
function resolveApplicationPaymentStatusInfo(item = {}) {
if (!item || item.isViewingApplication) {
return {
key: 'exempt',
desc: ''
}
}
const desc = String(item.payStatusDesc || item.paymentStatusDesc || '').trim()
const status = Number(
item.payStatus !== undefined && item.payStatus !== null
? item.payStatus
: item.paymentStatus
)
if (status === 10 || /已支付|paid/i.test(desc)) {
return { key: 'paid', desc }
}
if (/退款|refunded?/i.test(desc)) {
return { key: 'refunded', desc }
}
if (/失败|failed?/i.test(desc)) {
return { key: 'failed', desc }
}
if (/处理中|processing/i.test(desc)) {
return { key: 'processing', desc }
}
if (status === 0 || /待支付|未支付|unpaid|pending/i.test(desc)) {
return { key: 'pending', desc }
}
return {
key: desc ? 'processing' : 'pending',
desc
}
}
function parseJsonStringMaybe(value) { function parseJsonStringMaybe(value) {
if (typeof value !== 'string') { if (typeof value !== 'string') {
return value return value
@@ -233,6 +282,7 @@ export default {
watch: { watch: {
language() { language() {
this.loadReservationFlow() this.loadReservationFlow()
this.loadPendingDepositBillState()
} }
}, },
computed: { computed: {
@@ -278,6 +328,42 @@ export default {
heroBadgeClass() { heroBadgeClass() {
return this.isViewingApplication ? 'badge-viewing' : 'badge-stay' return this.isViewingApplication ? 'badge-viewing' : 'badge-stay'
}, },
showPaymentStatusPanel() {
return !this.isViewingApplication
},
paymentStatusText() {
if (this.isWaitingButlerRoomAssignment(this.detail)) {
return this.$tr('等待管家排房')
}
const statusInfo = resolveApplicationPaymentStatusInfo(this.detail)
if (this.$getLanguage() !== 'en' && statusInfo.desc) {
return statusInfo.desc
}
const textMap = {
exempt: this.$tr('无需支付'),
paid: this.$tr('已支付'),
refunded: this.$tr('已退款'),
failed: this.$tr('支付失败'),
processing: this.$tr('处理中'),
pending: this.$tr('待支付')
}
return textMap[statusInfo.key] || statusInfo.desc || this.$tr('待支付')
},
paymentStatusType() {
if (this.isWaitingButlerRoomAssignment(this.detail)) {
return 'info'
}
const statusInfo = resolveApplicationPaymentStatusInfo(this.detail)
const typeMap = {
exempt: 'neutral',
paid: 'success',
refunded: 'neutral',
failed: 'danger',
processing: 'info',
pending: 'warning'
}
return typeMap[statusInfo.key] || 'warning'
},
displayDateRange() { displayDateRange() {
if (!this.detail.start && !this.detail.end) { if (!this.detail.start && !this.detail.end) {
return '--' return '--'
@@ -329,27 +415,39 @@ export default {
if (reservationStepText) { if (reservationStepText) {
return reservationStepText return reservationStepText
} }
if (this.isWaitingButlerRoomAssignment(this.detail)) {
return this.$tr('等待管家排房')
}
if (this.shouldPayDeposit(this.detail)) { if (this.shouldPayDeposit(this.detail)) {
return this.$tr('请支付定金') return this.$tr('请支付定金')
} }
if (this.isWaitingRoomAssignment(this.detail)) { if (this.isWaitingRoomAssignment(this.detail)) {
return this.$tr('等待预订中心分配房间') return this.$tr('等待管家排房')
} }
return '' return ''
}, },
canViewBill() { canViewBill() {
return this.shouldPayDeposit(this.detail) return this.shouldPayDeposit(this.detail)
&& this.hasPendingDepositBill(this.detail)
&& !this.isViewingApplication && !this.isViewingApplication
&& this.detail && this.detail
&& this.detail.detailCode && this.detail.detailCode
&& this.statusType === 'pending' && this.statusType === 'pending'
}, },
canCancelApplication() {
if (!(this.detail && this.detail.detailCode && Number(this.detail.status) === 0)) {
return false
}
if (this.isViewingApplication) {
return true
}
if (Number(this.detail.payStatus) === 10) {
return false
}
return this.hasPendingDepositBill(this.detail)
},
canCancelOrder() { canCancelOrder() {
return this.shouldPayDeposit(this.detail) return this.canCancelApplication && !this.canceling
&& !this.isViewingApplication
&& this.detail
&& this.detail.detailCode
&& !this.canceling
} }
}, },
onLoad(options = {}) { onLoad(options = {}) {
@@ -358,11 +456,39 @@ export default {
}, },
onShow() { onShow() {
this.loadReservationFlow() this.loadReservationFlow()
this.loadPendingDepositBillState()
}, },
methods: { methods: {
getBillNo(item = {}) {
return item.no || item.billNo || ''
},
normalizeBillList(response) {
const payload = parseJsonStringMaybe(response && response.data ? response.data : response)
if (Array.isArray(payload)) {
return payload.filter(item => item && typeof item === 'object')
}
if (payload && Array.isArray(payload.data)) {
return payload.data.filter(item => item && typeof item === 'object')
}
return []
},
isPaidApplication(item = {}) { isPaidApplication(item = {}) {
return !item.isViewingApplication && Number(item.payStatus) === 10 return !item.isViewingApplication && Number(item.payStatus) === 10
}, },
shouldQueryPendingDepositBill(item = {}) {
return !item.isViewingApplication
&& Number(item.status) === 0
&& Number(item.payStatus) !== 10
&& !!String(item.detailCode || this.detailCode || '').trim()
},
hasPendingDepositBill(item = {}) {
return !!String(this.getBillNo(item && item.pendingDepositBill ? item.pendingDepositBill : {}) || '').trim()
},
isWaitingButlerRoomAssignment(item = {}) {
return this.shouldQueryPendingDepositBill(item)
&& !!(item && item.pendingDepositBillQueried)
&& !this.hasPendingDepositBill(item)
},
shouldPayDeposit(item = {}) { shouldPayDeposit(item = {}) {
return !item.isViewingApplication return !item.isViewingApplication
&& Number(item.status) === 0 && Number(item.status) === 0
@@ -371,7 +497,7 @@ export default {
isWaitingRoomAssignment(item = {}) { isWaitingRoomAssignment(item = {}) {
return !item.isViewingApplication return !item.isViewingApplication
&& Number(item.status) === 0 && Number(item.status) === 0
&& Number(item.payStatus) === 10 && (Number(item.payStatus) === 10 || this.isWaitingButlerRoomAssignment(item))
}, },
getReservationCurrentStepValue() { getReservationCurrentStepValue() {
return Number(this.detail && this.detail.reservationCurrentNode ? this.detail.reservationCurrentNode.step : 0) return Number(this.detail && this.detail.reservationCurrentNode ? this.detail.reservationCurrentNode.step : 0)
@@ -491,6 +617,50 @@ export default {
isViewingApplication: false isViewingApplication: false
} }
}, },
syncDetailToCache(detail = this.detail) {
const list = normalizeApplicationList(getMyApplicationList())
if (!list.length || !detail || !detail.detailCode) {
return
}
saveMyApplicationList(list.map((item) => {
if (String(item.detailCode || '') !== String(detail.detailCode || '')) {
return item
}
return {
...item,
...detail
}
}))
},
async loadPendingDepositBillState() {
const currentDetail = this.detail && typeof this.detail === 'object' ? this.detail : {}
const referenceTypeNo = String(currentDetail.detailCode || this.detailCode || '').trim()
if (!this.shouldQueryPendingDepositBill(currentDetail) || !referenceTypeNo) {
return
}
try {
const response = await accountQueryBills({
referenceTypeNo,
status: 10,
paymentStatus: 0,
ProviderType: 'WechatMiniProgram'
}, this.language)
if (referenceTypeNo !== String(this.detail && this.detail.detailCode || this.detailCode || '').trim()) {
return
}
const billList = this.normalizeBillList(response)
const nextDetail = {
...this.detail,
pendingDepositBill: Array.isArray(billList) && billList.length ? billList[0] : null,
pendingDepositBillQueried: true
}
this.detail = nextDetail
this.syncDetailToCache(nextDetail)
} catch (error) {
console.error('load pending deposit bill failed', error)
}
},
async loadReservationFlow() { async loadReservationFlow() {
if (!this.detail || this.detail.isViewingApplication) { if (!this.detail || this.detail.isViewingApplication) {
return return
@@ -601,11 +771,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 +815,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 +825,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'
}) })
}) })
@@ -777,6 +950,85 @@ export default {
border: 1px solid rgba(59, 130, 246, 0.14); border: 1px solid rgba(59, 130, 246, 0.14);
} }
.payment-status-panel {
padding: 24rpx 28rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
border: 1rpx solid transparent;
}
.payment-status-panel__label {
flex-shrink: 0;
font-size: 24rpx;
color: #64748B;
font-weight: 600;
}
.payment-status-panel__main {
min-width: 0;
text-align: right;
}
.payment-status-panel__value {
font-size: 34rpx;
line-height: 1.2;
font-weight: 700;
}
.payment-status-panel__hint {
margin-top: 8rpx;
font-size: 22rpx;
color: #9A3412;
}
.payment-status-panel--warning {
background: linear-gradient(180deg, rgba(255, 247, 237, 0.96) 0%, rgba(255, 251, 245, 0.98) 100%);
border-color: rgba(249, 115, 22, 0.16);
}
.payment-status-panel--warning .payment-status-panel__value {
color: #C2410C;
}
.payment-status-panel--success {
background: linear-gradient(180deg, rgba(236, 253, 245, 0.96) 0%, rgba(240, 253, 250, 0.98) 100%);
border-color: rgba(16, 185, 129, 0.16);
}
.payment-status-panel--success .payment-status-panel__value {
color: #047857;
}
.payment-status-panel--info {
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96) 0%, rgba(248, 250, 252, 0.98) 100%);
border-color: rgba(59, 130, 246, 0.14);
}
.payment-status-panel--info .payment-status-panel__value {
color: #1D4ED8;
}
.payment-status-panel--danger {
background: linear-gradient(180deg, rgba(254, 242, 242, 0.96) 0%, rgba(255, 245, 245, 0.98) 100%);
border-color: rgba(239, 68, 68, 0.16);
}
.payment-status-panel--danger .payment-status-panel__value {
color: #B91C1C;
}
.payment-status-panel--neutral {
background: linear-gradient(180deg, rgba(241, 245, 249, 0.96) 0%, rgba(248, 250, 252, 0.98) 100%);
border-color: rgba(148, 163, 184, 0.16);
}
.payment-status-panel--neutral .payment-status-panel__value {
color: #475569;
}
.status-reminder-panel__icon { .status-reminder-panel__icon {
width: 68rpx; width: 68rpx;
height: 68rpx; height: 68rpx;

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

@@ -8,7 +8,7 @@
<view class="overview-panel"> <view class="overview-panel">
<view class="overview-header"> <view class="overview-header">
<view> <view>
<view class="overview-title">{{ $tr('合同影像') }}</view> <view class="overview-title">{{ contractDisplayName || $tr('合同影像') }}</view>
<view class="overview-subtitle">{{ $tr('合同编号:') }}{{ contractNo || $tr('未获取') }}</view> <view class="overview-subtitle">{{ $tr('合同编号:') }}{{ contractNo || $tr('未获取') }}</view>
</view> </view>
<view class="overview-badge"> <view class="overview-badge">
@@ -83,6 +83,7 @@
import { addFilePrefix } from '@/utils/common.js' import { addFilePrefix } from '@/utils/common.js'
import { contractPreview } from '@/api/index.js' import { contractPreview } from '@/api/index.js'
import { normalizeRichTextImages } from '../../utils/rich-text' import { normalizeRichTextImages } from '../../utils/rich-text'
import { fetchContractDisplayInfo } from '../../utils/contract-display'
function resolvePreviewPayload(response) { function resolvePreviewPayload(response) {
const payload = response && response.data ? response.data : response const payload = response && response.data ? response.data : response
@@ -103,6 +104,7 @@ export default {
data() { data() {
return { return {
contractNo: '', contractNo: '',
contractDisplayName: '',
orderNo: '', orderNo: '',
loading: false, loading: false,
imageUrl: '', imageUrl: '',
@@ -114,9 +116,24 @@ export default {
this.contractNo = decodeURIComponent(options.no || '') this.contractNo = decodeURIComponent(options.no || '')
this.orderNo = decodeURIComponent(options.orderNo || '') this.orderNo = decodeURIComponent(options.orderNo || '')
this.imageUrl = addFilePrefix(decodeURIComponent(options.contentImageUrl || '')) this.imageUrl = addFilePrefix(decodeURIComponent(options.contentImageUrl || ''))
this.fetchContractDisplayName()
this.fetchContractDetail() this.fetchContractDetail()
}, },
methods: { methods: {
fetchContractDisplayName() {
if (!this.contractNo) {
return Promise.resolve()
}
return fetchContractDisplayInfo(this.contractNo, this.$getLanguage())
.then(({ contractDisplayName }) => {
this.contractDisplayName = contractDisplayName || ''
})
.catch((error) => {
console.error('fetch contract display name failed', error)
this.contractDisplayName = ''
})
},
fetchContractDetail() { fetchContractDetail() {
if (!this.contractNo || (this.imageUrl && this.contractContent)) { if (!this.contractNo || (this.imageUrl && this.contractContent)) {
return Promise.resolve() return Promise.resolve()

View File

@@ -8,7 +8,7 @@
<view class="overview-panel"> <view class="overview-panel">
<view class="overview-header"> <view class="overview-header">
<view> <view>
<view class="overview-title">{{ $tr('签约前确认') }}</view> <view class="overview-title">{{ contractDisplayName || $tr('签约前确认') }}</view>
<view class="overview-subtitle">{{ $tr('合同编号:') }}{{ contractNo || $tr('未获取') }}</view> <view class="overview-subtitle">{{ $tr('合同编号:') }}{{ contractNo || $tr('未获取') }}</view>
</view> </view>
<view class="overview-badge"> <view class="overview-badge">
@@ -106,6 +106,7 @@ import { contractPreview, contractSign, uploadCommonFile } from '@/api/index.js'
import { syncLoginStayState } from '../../utils/session-sync' import { syncLoginStayState } from '../../utils/session-sync'
import ContractSignDialog from '../../components/ContractSignDialog.vue' import ContractSignDialog from '../../components/ContractSignDialog.vue'
import { normalizeRichTextImages } from '../../utils/rich-text' import { normalizeRichTextImages } from '../../utils/rich-text'
import { fetchContractDisplayInfo } from '../../utils/contract-display'
function normalizeContractContent(content = '') { function normalizeContractContent(content = '') {
return normalizeRichTextImages( return normalizeRichTextImages(
@@ -144,6 +145,7 @@ export default {
data() { data() {
return { return {
contractNo: '', contractNo: '',
contractDisplayName: '',
orderNo: '', orderNo: '',
contractContent: '', contractContent: '',
loading: false, loading: false,
@@ -158,9 +160,24 @@ export default {
this.contractNo = decodeURIComponent(options.no || '') this.contractNo = decodeURIComponent(options.no || '')
this.orderNo = decodeURIComponent(options.orderNo || '') this.orderNo = decodeURIComponent(options.orderNo || '')
this.signerName = this.getSignerName() this.signerName = this.getSignerName()
this.fetchContractDisplayName()
this.fetchContractContent() this.fetchContractContent()
}, },
methods: { methods: {
fetchContractDisplayName() {
if (!this.contractNo) {
return Promise.resolve()
}
return fetchContractDisplayInfo(this.contractNo, this.$getLanguage())
.then(({ contractDisplayName }) => {
this.contractDisplayName = contractDisplayName || ''
})
.catch((error) => {
console.error('fetch contract display name failed', error)
this.contractDisplayName = ''
})
},
fetchContractContent() { fetchContractContent() {
if (!this.contractNo) { if (!this.contractNo) {
return Promise.resolve() return Promise.resolve()

File diff suppressed because it is too large Load Diff

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"

View File

@@ -52,6 +52,15 @@
</view> </view>
</view> </view>
<view
v-if="shouldShowApplicationPaymentStatus(item)"
class="payment-status-banner tn-margin-top"
:class="`payment-status-banner--${getApplicationPaymentStatusType(item)}`"
>
<text class="payment-status-banner__label">{{ $tr('支付状态') }}</text>
<text class="payment-status-banner__value">{{ getApplicationPaymentStatusText(item) }}</text>
</view>
<view class="card-body tn-margin-top"> <view class="card-body tn-margin-top">
<view class="info-row tn-flex tn-flex-row-between"> <view class="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ $tr('所属公寓') }}</text> <text class="info-label">{{ $tr('所属公寓') }}</text>
@@ -154,6 +163,43 @@ function isViewingApplicationRecord(item = {}) {
return VIEWING_APPLICATION_PATTERN.test(remark) return VIEWING_APPLICATION_PATTERN.test(remark)
} }
function resolveApplicationPaymentStatusInfo(item = {}) {
if (!item || item.isViewingApplication) {
return {
key: 'exempt',
desc: ''
}
}
const desc = String(item.payStatusDesc || item.paymentStatusDesc || '').trim()
const status = Number(
item.payStatus !== undefined && item.payStatus !== null
? item.payStatus
: item.paymentStatus
)
if (status === 10 || /已支付|paid/i.test(desc)) {
return { key: 'paid', desc }
}
if (/退款|refunded?/i.test(desc)) {
return { key: 'refunded', desc }
}
if (/失败|failed?/i.test(desc)) {
return { key: 'failed', desc }
}
if (/处理中|processing/i.test(desc)) {
return { key: 'processing', desc }
}
if (status === 0 || /待支付|未支付|unpaid|pending/i.test(desc)) {
return { key: 'pending', desc }
}
return {
key: desc ? 'processing' : 'pending',
desc
}
}
function normalizeApplicationList(list = []) { function normalizeApplicationList(list = []) {
if (!Array.isArray(list)) { if (!Array.isArray(list)) {
return [] return []
@@ -362,6 +408,36 @@ export default {
} }
return item.intendedRoomTypeName || this.$tr('入住申请') return item.intendedRoomTypeName || this.$tr('入住申请')
}, },
shouldShowApplicationPaymentStatus(item = {}) {
return !item.isViewingApplication
},
getApplicationPaymentStatusText(item = {}) {
const statusInfo = resolveApplicationPaymentStatusInfo(item)
if (this.$getLanguage() !== 'en' && statusInfo.desc) {
return statusInfo.desc
}
const textMap = {
exempt: this.$tr('无需支付'),
paid: this.$tr('已支付'),
refunded: this.$tr('已退款'),
failed: this.$tr('支付失败'),
processing: this.$tr('处理中'),
pending: this.$tr('待支付')
}
return textMap[statusInfo.key] || statusInfo.desc || this.$tr('待支付')
},
getApplicationPaymentStatusType(item = {}) {
const statusInfo = resolveApplicationPaymentStatusInfo(item)
const typeMap = {
exempt: 'neutral',
paid: 'success',
refunded: 'neutral',
failed: 'danger',
processing: 'info',
pending: 'warning'
}
return typeMap[statusInfo.key] || 'warning'
},
resolveReferenceTypeNo(item = {}) { resolveReferenceTypeNo(item = {}) {
return item.detailCode || '' return item.detailCode || ''
}, },
@@ -555,6 +631,72 @@ export default {
font-weight: 700; font-weight: 700;
} }
.payment-status-banner {
padding: 20rpx 24rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
border: 1rpx solid transparent;
}
.payment-status-banner__label {
font-size: 24rpx;
color: #64748B;
font-weight: 600;
}
.payment-status-banner__value {
font-size: 28rpx;
font-weight: 700;
}
.payment-status-banner--warning {
background: linear-gradient(180deg, rgba(255, 247, 237, 0.96) 0%, rgba(255, 251, 245, 0.98) 100%);
border-color: rgba(249, 115, 22, 0.16);
}
.payment-status-banner--warning .payment-status-banner__value {
color: #C2410C;
}
.payment-status-banner--success {
background: linear-gradient(180deg, rgba(236, 253, 245, 0.96) 0%, rgba(240, 253, 250, 0.98) 100%);
border-color: rgba(16, 185, 129, 0.16);
}
.payment-status-banner--success .payment-status-banner__value {
color: #047857;
}
.payment-status-banner--info {
background: linear-gradient(180deg, rgba(239, 246, 255, 0.96) 0%, rgba(248, 250, 252, 0.98) 100%);
border-color: rgba(59, 130, 246, 0.14);
}
.payment-status-banner--info .payment-status-banner__value {
color: #1D4ED8;
}
.payment-status-banner--danger {
background: linear-gradient(180deg, rgba(254, 242, 242, 0.96) 0%, rgba(255, 245, 245, 0.98) 100%);
border-color: rgba(239, 68, 68, 0.16);
}
.payment-status-banner--danger .payment-status-banner__value {
color: #B91C1C;
}
.payment-status-banner--neutral {
background: linear-gradient(180deg, rgba(241, 245, 249, 0.96) 0%, rgba(248, 250, 252, 0.98) 100%);
border-color: rgba(148, 163, 184, 0.16);
}
.payment-status-banner--neutral .payment-status-banner__value {
color: #475569;
}
.status-pending { .status-pending {
background: rgba(245, 158, 11, 0.12); background: rgba(245, 158, 11, 0.12);
color: #B45309; color: #B45309;

View File

@@ -48,7 +48,7 @@
<view v-else-if="displayList.length" class="list-wrapper"> <view v-else-if="displayList.length" class="list-wrapper">
<view <view
v-for="(item, index) in displayList" v-for="(item, index) in displayList"
:key="item.no || item.contractNo || item.orderNo || index" :key="getContractKey(item, index)"
class="contract-card" class="contract-card"
:style="{ animationDelay: `${index * 0.08}s` }" :style="{ animationDelay: `${index * 0.08}s` }"
> >
@@ -58,8 +58,8 @@
<text class="tn-icon-edit-write-fill"></text> <text class="tn-icon-edit-write-fill"></text>
</view> </view>
<view class="card-title-group"> <view class="card-title-group">
<view class="card-title">{{ $tr('住宿合同') }}</view> <view class="card-title">{{ item.contractDisplayName || $tr('住宿合同') }}</view>
<view class="card-code">{{ item.no || item.contractNo || '--' }}</view> <view class="card-code">{{ $tr('合同编号:') }}{{ item.no || item.contractNo || '--' }}</view>
</view> </view>
</view> </view>
<view :class="['status-badge', `status-${resolveStatusType(item.approvalStatus)}`]"> <view :class="['status-badge', `status-${resolveStatusType(item.approvalStatus)}`]">
@@ -88,10 +88,10 @@
<text class="info-label">{{ $tr('记录说明') }}</text> <text class="info-label">{{ $tr('记录说明') }}</text>
<text class="info-value">{{ getStatusTip(item) }}</text> <text class="info-value">{{ getStatusTip(item) }}</text>
</view> </view>
<view v-if="item.signImageUrl" class="sign-row"> <view v-if="item.contentImageUrl" class="sign-row">
<text class="info-label">{{ $tr('我的签名') }}</text> <text class="info-label">{{ $tr('合同图片') }}</text>
<view class="sign-preview"> <view class="sign-preview">
<image :src="item.signImageUrl" mode="aspectFit"></image> <image :src="item.contentImageUrl" mode="aspectFit" @click="previewContractImage(index)"></image>
</view> </view>
</view> </view>
</view> </view>
@@ -100,7 +100,7 @@
<view <view
class="action-btn" class="action-btn"
:class="{ 'action-btn--secondary': !shouldShowSignAction(item) }" :class="{ 'action-btn--secondary': !shouldShowSignAction(item) }"
@click="handleContractAction(item)" @click="handleContractAction(index)"
> >
{{ resolveActionText(item) }} {{ resolveActionText(item) }}
</view> </view>
@@ -121,8 +121,42 @@
<script> <script>
import { addFilePrefix } from '@/utils/common.js' import { addFilePrefix } from '@/utils/common.js'
import { getMyEffectiveContracts, getMyPendingSignContracts } from '@/api/index.js'
import { getSessionSnapshot } from '../../utils/app-state' import { getSessionSnapshot } from '../../utils/app-state'
import { syncLoginStayState } from '../../utils/session-sync' import { syncLoginStayState } from '../../utils/session-sync'
import { enrichContractDisplayNames } from '../../utils/contract-display'
function pickFirstValue(...values) {
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (value === undefined || value === null) {
continue
}
const text = String(value).trim()
if (text) {
return text
}
}
return ''
}
function parseContractList(response) {
const payload = response && response.data ? response.data : response
if (Array.isArray(payload)) {
return payload
}
if (payload && Array.isArray(payload.data)) {
return payload.data
}
if (payload && payload.data && Array.isArray(payload.data.data)) {
return payload.data.data
}
return []
}
function normalizeContract(item = {}) { function normalizeContract(item = {}) {
return { return {
@@ -190,6 +224,67 @@ export default {
const list = this.$store && this.$store.state && this.$store.state.vuex_effective_contracts const list = this.$store && this.$store.state && this.$store.state.vuex_effective_contracts
return Array.isArray(list) ? list : [] return Array.isArray(list) ? list : []
}, },
resolveContractItem(source) {
if (typeof source === 'number') {
return this.displayList[source] || {}
}
if (source && typeof source === 'object') {
return source
}
return {}
},
resolveCurrentOrderNo(currentOrder = null) {
const storeCurrentOrder = this.$store && this.$store.state && this.$store.state.vuex_current_order
const session = getSessionSnapshot()
const sessionCurrentOrder = session && session.resolvedUser && session.resolvedUser.currentOrder
return pickFirstValue(
currentOrder && (currentOrder.orderNo || currentOrder.no || currentOrder.code || currentOrder.OrderNo),
storeCurrentOrder && (storeCurrentOrder.orderNo || storeCurrentOrder.no || storeCurrentOrder.code || storeCurrentOrder.OrderNo),
sessionCurrentOrder && (sessionCurrentOrder.orderNo || sessionCurrentOrder.no || sessionCurrentOrder.code || sessionCurrentOrder.OrderNo)
)
},
fetchLatestContractsByOrderNo(orderNo = '') {
const normalizedOrderNo = String(orderNo || '').trim()
if (!normalizedOrderNo) {
return Promise.resolve(null)
}
const query = {
OrderNo: normalizedOrderNo,
ProviderType: 'WechatMiniProgram'
}
return Promise.all([
getMyPendingSignContracts(query, this.language).catch(() => []),
getMyEffectiveContracts(query, this.language).catch(() => [])
]).then(([pendingContractsResponse, effectiveContractsResponse]) => {
return {
pendingContracts: parseContractList(pendingContractsResponse),
effectiveContracts: parseContractList(effectiveContractsResponse)
}
})
},
updateContractImageUrl(targetNo = '', imageUrl = '') {
const normalizedNo = String(targetNo || '').trim()
const normalizedImageUrl = addFilePrefix(String(imageUrl || '').trim())
if (!normalizedNo || !normalizedImageUrl) {
return
}
const patchList = (list = []) => list.map((contract) => {
const contractNo = String((contract && (contract.no || contract.contractNo)) || '').trim()
if (contractNo !== normalizedNo) {
return contract
}
return {
...contract,
contentImageUrl: normalizedImageUrl
}
})
this.pendingContracts = patchList(this.pendingContracts)
this.effectiveContracts = patchList(this.effectiveContracts)
},
isCurrentLogin() { isCurrentLogin() {
const storeUser = this.getStoreUser() const storeUser = this.getStoreUser()
if (storeUser && Object.keys(storeUser).length) { if (storeUser && Object.keys(storeUser).length) {
@@ -201,12 +296,23 @@ export default {
pendingContracts = [], pendingContracts = [],
effectiveContracts = [] effectiveContracts = []
} = {}) { } = {}) {
this.pendingContracts = Array.isArray(pendingContracts) const normalizedPendingContracts = Array.isArray(pendingContracts)
? pendingContracts.map(normalizeContract) ? pendingContracts.map(normalizeContract)
: [] : []
this.effectiveContracts = Array.isArray(effectiveContracts) const normalizedEffectiveContracts = Array.isArray(effectiveContracts)
? effectiveContracts.map(normalizeContract) ? effectiveContracts.map(normalizeContract)
: [] : []
this.pendingContracts = normalizedPendingContracts
this.effectiveContracts = normalizedEffectiveContracts
Promise.all([
enrichContractDisplayNames(normalizedPendingContracts, this.language),
enrichContractDisplayNames(normalizedEffectiveContracts, this.language)
]).then(([nextPendingContracts, nextEffectiveContracts]) => {
this.pendingContracts = nextPendingContracts
this.effectiveContracts = nextEffectiveContracts
})
}, },
refreshContracts() { refreshContracts() {
if (!this.isCurrentLogin()) { if (!this.isCurrentLogin()) {
@@ -218,20 +324,32 @@ export default {
this.loading = true this.loading = true
return syncLoginStayState({ force: true }) return syncLoginStayState({ force: true })
.then((result = {}) => { .then((result = {}) => {
this.applyContractState({ const fallbackState = {
pendingContracts: result.pendingContracts, pendingContracts: result.pendingContracts,
effectiveContracts: result.effectiveContracts effectiveContracts: result.effectiveContracts
}) }
const currentOrderNo = this.resolveCurrentOrderNo(result.currentOrder)
return this.fetchLatestContractsByOrderNo(currentOrderNo)
.then((latestState) => {
this.applyContractState(latestState || fallbackState)
})
}) })
.catch((error) => { .catch((error) => {
console.error('refresh contracts failed', error) console.error('refresh contracts failed', error)
const storePendingContracts = this.getStorePendingContracts() const storePendingContracts = this.getStorePendingContracts()
const storeEffectiveContracts = this.getStoreEffectiveContracts() const storeEffectiveContracts = this.getStoreEffectiveContracts()
const session = getSessionSnapshot() const session = getSessionSnapshot()
this.applyContractState({ const fallbackState = {
pendingContracts: storePendingContracts.length ? storePendingContracts : session.pendingContracts, pendingContracts: storePendingContracts.length ? storePendingContracts : session.pendingContracts,
effectiveContracts: storeEffectiveContracts.length ? storeEffectiveContracts : session.effectiveContracts effectiveContracts: storeEffectiveContracts.length ? storeEffectiveContracts : session.effectiveContracts
}) }
const currentOrderNo = this.resolveCurrentOrderNo()
return this.fetchLatestContractsByOrderNo(currentOrderNo)
.then((latestState) => {
this.applyContractState(latestState || fallbackState)
})
}) })
.finally(() => { .finally(() => {
this.loading = false this.loading = false
@@ -265,6 +383,9 @@ export default {
formatDate(value) { formatDate(value) {
return value ? String(value).slice(0, 10) : '--' return value ? String(value).slice(0, 10) : '--'
}, },
getContractKey(item = {}, index = 0) {
return item.no || item.contractNo || item.orderNo || `contract-${index}`
},
formatDateRange(start, end) { formatDateRange(start, end) {
if (!start && !end) { if (!start && !end) {
return '--' return '--'
@@ -290,15 +411,84 @@ export default {
if (this.shouldShowSignAction(item)) { if (this.shouldShowSignAction(item)) {
return this.$tr('去签约') return this.$tr('去签约')
} }
return this.$tr('查看详情') return this.$tr('查看合同')
}, },
shouldShowSignAction(item = {}) { shouldShowSignAction(item = {}) {
return Number(item.approvalStatus) === 0 && !item.signImageUrl return Number(item.approvalStatus) === 0 && !item.signImageUrl
}, },
handleContractAction(item = {}) { previewContractImage(source = {}) {
const item = this.resolveContractItem(source)
const directImageUrl = addFilePrefix(String(item.contentImageUrl || '').trim())
if (directImageUrl) {
uni.previewImage({
urls: [directImageUrl],
current: directImageUrl
})
return Promise.resolve(directImageUrl)
}
const orderNo = String(item.orderNo || '').trim()
const contractNo = String(item.no || item.contractNo || '').trim()
if (!orderNo) {
uni.showToast({
title: this.$tr('暂无合同图片'),
icon: 'none'
})
return Promise.resolve('')
}
uni.showLoading({
title: this.$tr('加载中')
})
return this.fetchLatestContractsByOrderNo(orderNo)
.then((latestState) => {
const effectiveContracts = latestState && Array.isArray(latestState.effectiveContracts)
? latestState.effectiveContracts
: []
const matchedContract = effectiveContracts.find((contract) => {
const currentNo = String((contract && (contract.no || contract.contractNo)) || '').trim()
const currentOrderNo = String((contract && contract.orderNo) || '').trim()
if (contractNo && currentNo) {
return currentNo === contractNo
}
return currentOrderNo === orderNo
}) || effectiveContracts[0] || null
const imageUrl = addFilePrefix(String(matchedContract && matchedContract.contentImageUrl || '').trim())
if (!imageUrl) {
uni.showToast({
title: this.$tr('暂无合同图片'),
icon: 'none'
})
return ''
}
if (contractNo) {
this.updateContractImageUrl(contractNo, imageUrl)
}
uni.previewImage({
urls: [imageUrl],
current: imageUrl
})
return imageUrl
})
.catch((error) => {
console.error('preview contract image failed', error)
uni.showToast({
title: this.$tr('合同图片加载失败'),
icon: 'none'
})
return ''
})
.finally(() => {
uni.hideLoading()
})
},
handleContractAction(source = {}) {
const item = this.resolveContractItem(source)
const contractNo = encodeURIComponent(item.no || item.contractNo || '') const contractNo = encodeURIComponent(item.no || item.contractNo || '')
const orderNo = encodeURIComponent(item.orderNo || '') const orderNo = encodeURIComponent(item.orderNo || '')
const contentImageUrl = encodeURIComponent(item.contentImageUrl || '')
if (Number(item.approvalStatus) === 0 && !item.signImageUrl) { if (Number(item.approvalStatus) === 0 && !item.signImageUrl) {
uni.navigateTo({ uni.navigateTo({
@@ -307,9 +497,7 @@ export default {
return return
} }
uni.navigateTo({ this.previewContractImage(item)
url: `/pkgService/contract_detail/contract_detail?no=${contractNo}&orderNo=${orderNo}&contentImageUrl=${contentImageUrl}`
})
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,13 @@
<text class="tn-icon-lock tn-color-gray" style="font-size: 80rpx;"></text> <text class="tn-icon-lock tn-color-gray" style="font-size: 80rpx;"></text>
</view> </view>
<view class="hero-title tn-text-bold">{{ $tr('暂无门锁权限') }}</view> <view class="hero-title tn-text-bold">{{ $tr('暂无门锁权限') }}</view>
<view class="hero-desc tn-margin-top-sm tn-text-center">{{ $tr('您当前暂未分配智能门锁,或门锁暂未联网。\n入住后即可使用一键开门与临时密码下发功能。') }}</view> <view class="hero-desc tn-margin-top-sm tn-text-center">{{ $tr('您当前暂未分配智能门锁,或门锁暂未联网。\n入住后即可使用门锁密码、指纹录入与临时密码下发功能。') }}</view>
<view class="tn-flex tn-margin-top-xl tn-width-full tn-flex-row-between"> <view class="tn-flex tn-margin-top-xl tn-width-full tn-flex-row-between">
<view class="btn-light tn-flex-1 tn-margin-right-sm tn-flex tn-flex-col-center tn-flex-row-center tn-padding-sm" style="border-radius: 12rpx; opacity: 0.6;"> <view class="btn-light tn-flex-1 tn-margin-right-sm tn-flex tn-flex-col-center tn-flex-row-center tn-padding-sm" style="border-radius: 12rpx; opacity: 0.6;">
<text class="tn-icon-key tn-margin-right-xs"></text> {{ $tr('临时密码') }} <text class="tn-icon-key tn-margin-right-xs"></text> {{ $tr('临时密码') }}
</view> </view>
<view class="btn-primary tn-flex-1 tn-margin-left-sm tn-flex tn-flex-col-center tn-flex-row-center tn-padding-sm" style="border-radius: 12rpx; opacity: 0.6;"> <view class="btn-primary tn-flex-1 tn-margin-left-sm tn-flex tn-flex-col-center tn-flex-row-center tn-padding-sm" style="border-radius: 12rpx; opacity: 0.6;">
<text class="tn-icon-unlock tn-margin-right-xs"></text> {{ $tr('一键开锁') }} <text class="tn-icon-fingerprint tn-margin-right-xs"></text> {{ $tr('录入指纹') }}
</view> </view>
</view> </view>
</view> </view>

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

@@ -8,6 +8,7 @@
<current-application-bill-panel <current-application-bill-panel
ref="billPanel" ref="billPanel"
:reference-type-no="referenceTypeNo" :reference-type-no="referenceTypeNo"
:auto-resolve-reference="!pendingBillNo || !!referenceTypeNo"
@reference-resolved="handleReferenceResolved" @reference-resolved="handleReferenceResolved"
@all-paid="handleAllPaid" @all-paid="handleAllPaid"
/> />
@@ -16,6 +17,7 @@
</template> </template>
<script> <script>
import { accountQueryBill } from '@/api/index.js'
import { fetchEnums } from '@/utils/enum-store.js' import { fetchEnums } from '@/utils/enum-store.js'
import { saveActiveApplicationCode, saveHomePageTabId } from '@/utils/app-state' import { saveActiveApplicationCode, saveHomePageTabId } from '@/utils/app-state'
import CurrentApplicationBillPanel from '../components/CurrentApplicationBillPanel.vue' import CurrentApplicationBillPanel from '../components/CurrentApplicationBillPanel.vue'
@@ -26,17 +28,54 @@ export default {
}, },
data() { data() {
return { return {
referenceTypeNo: '' referenceTypeNo: '',
pendingBillNo: ''
} }
}, },
async onLoad(options) { async onLoad(options) {
await fetchEnums(false, this.$getLanguage()) await fetchEnums(false, this.$getLanguage())
this.referenceTypeNo = options && options.referenceTypeNo ? decodeURIComponent(options.referenceTypeNo) : '' this.referenceTypeNo = options && options.referenceTypeNo ? decodeURIComponent(options.referenceTypeNo) : ''
this.pendingBillNo = options && options.billNo ? decodeURIComponent(options.billNo) : ''
if (!this.referenceTypeNo && this.pendingBillNo) {
await this.resolveReferenceTypeNoByBillNo(this.pendingBillNo)
}
if (this.referenceTypeNo) { if (this.referenceTypeNo) {
saveActiveApplicationCode(this.referenceTypeNo) saveActiveApplicationCode(this.referenceTypeNo)
} }
}, },
methods: { methods: {
extractBillDetail(response) {
const payload = response && response.data ? response.data : response
if (payload && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
return payload.data
}
return payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {}
},
async resolveReferenceTypeNoByBillNo(billNo = '') {
const currentBillNo = String(billNo || '').trim()
if (!currentBillNo) {
return ''
}
try {
const response = await accountQueryBill({ billNo: currentBillNo }, this.$getLanguage())
const detail = this.extractBillDetail(response)
const referenceTypeNo = String(
detail.referenceTypeNo
|| detail.ReferenceTypeNo
|| detail.detailCode
|| detail.applicationCode
|| ''
).trim()
if (referenceTypeNo) {
this.referenceTypeNo = referenceTypeNo
saveActiveApplicationCode(referenceTypeNo)
}
return referenceTypeNo
} catch (error) {
console.error('根据账单号解析申请单号失败', error)
return ''
}
},
handleReferenceResolved(referenceTypeNo) { handleReferenceResolved(referenceTypeNo) {
this.referenceTypeNo = referenceTypeNo || '' this.referenceTypeNo = referenceTypeNo || ''
if (this.referenceTypeNo) { if (this.referenceTypeNo) {

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) {
@@ -177,7 +178,7 @@ const store = new Vuex.Store({
? getMyPendingSignContracts({ OrderNo: currentOrderNo, ProviderType: 'WechatMiniProgram' }, language).catch((error) => logRequestError('getMyPendingSignContracts', error)) ? getMyPendingSignContracts({ OrderNo: currentOrderNo, ProviderType: 'WechatMiniProgram' }, language).catch((error) => logRequestError('getMyPendingSignContracts', error))
: Promise.resolve([]), : Promise.resolve([]),
currentOrderNo currentOrderNo
? getMyEffectiveContracts({ OrderNo: currentOrderNo }, language).catch((error) => logRequestError('getMyEffectiveContracts', error)) ? getMyEffectiveContracts({ OrderNo: currentOrderNo, ProviderType: 'WechatMiniProgram' }, language).catch((error) => logRequestError('getMyEffectiveContracts', error))
: Promise.resolve([]) : Promise.resolve([])
]) ])
}).then(([validOrders, currentOrder, pendingContractsResponse, effectiveContractsResponse]) => { }).then(([validOrders, currentOrder, pendingContractsResponse, effectiveContractsResponse]) => {

View File

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

View File

@@ -77,8 +77,8 @@ function buildRoomDataFromOrder(order = {}) {
order.roomTypeName || order.roomType || '' order.roomTypeName || order.roomType || ''
].filter(Boolean).join(' · ') ].filter(Boolean).join(' · ')
const start = String(order.startDate || order.start || order.checkinDate || '').slice(0, 10) const start = String(order.contractStartTime || order.startDate || order.start || order.checkinDate || '').slice(0, 10)
const end = String(order.endDate || order.end || order.checkoutDate || '').slice(0, 10) const end = String(order.contractEndTime || order.endDate || order.end || order.checkoutDate || '').slice(0, 10)
return { return {
...order, ...order,

View File

@@ -11,12 +11,8 @@ export function resolveExistingBookingNavigationTarget(stayState = {}) {
const currentBooking = source.currentBooking || null const currentBooking = source.currentBooking || null
const hasCheckedIn = source.hasCheckedIn === true const hasCheckedIn = source.hasCheckedIn === true
if (currentBooking && currentBooking.orderNo) {
return `/pkgBooking/booking_detail/booking_detail?orderNo=${encodeURIComponent(currentBooking.orderNo)}`
}
if (currentBooking) { if (currentBooking) {
return '/pkgBooking/booking_detail/booking_detail' return '/pages/index/index?pageId=stay-service'
} }
if (hasCheckedIn) { if (hasCheckedIn) {

View File

@@ -13,11 +13,13 @@ function getGuardText(language = 'zh') {
return { return {
loginTitle: zh ? '提示' : 'Notice', loginTitle: zh ? '提示' : 'Notice',
loginContent: zh ? '您还未登录,请先登录或注册账号。' : 'Please sign in or register an account first.', loginContent: zh ? '您还未登录,请先登录或注册账号。' : 'Please sign in or register an account first.',
loginCancelText: zh ? '取消' : 'Cancel',
loginConfirmText: zh ? '去登录' : 'Sign In', loginConfirmText: zh ? '去登录' : 'Sign In',
existingBookingTitle: zh ? '已有进行中的预订' : 'Existing Booking', existingBookingTitle: zh ? '已有进行中的预订' : 'Existing Booking',
existingBookingContent: zh existingBookingContent: zh
? '您当前状态为“{status}”,请先查看预订详情。' ? '您当前状态为“{status}”,请先查看预订详情。'
: 'Your current booking status is "{status}". Please review the booking detail first.', : 'Your current booking status is "{status}". Please review the booking detail first.',
existingBookingCancelText: zh ? '取消' : 'Cancel',
viewBooking: zh ? '查看预订' : 'View Booking' viewBooking: zh ? '查看预订' : 'View Booking'
} }
} }
@@ -69,6 +71,7 @@ export function ensureBookingAccess(options = {}) {
uni.showModal({ uni.showModal({
title: options.loginTitle || guardText.loginTitle, title: options.loginTitle || guardText.loginTitle,
content: options.loginContent || guardText.loginContent, content: options.loginContent || guardText.loginContent,
cancelText: options.loginCancelText || guardText.loginCancelText,
confirmText: options.loginConfirmText || guardText.loginConfirmText, confirmText: options.loginConfirmText || guardText.loginConfirmText,
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
@@ -89,6 +92,7 @@ export function ensureBookingAccess(options = {}) {
uni.showModal({ uni.showModal({
title: guardText.existingBookingTitle, title: guardText.existingBookingTitle,
content: guardText.existingBookingContent.replace('{status}', statusText), content: guardText.existingBookingContent.replace('{status}', statusText),
cancelText: guardText.existingBookingCancelText,
confirmText: guardText.viewBooking, confirmText: guardText.viewBooking,
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {

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.contractStartTime || safeOrder.startDate || safeOrder.start || safeOrder.checkinDate || checkInPeriod.start
const endDate = safeOrder.contractEndTime || 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)
} }
} }

112
utils/contract-display.js Normal file
View File

@@ -0,0 +1,112 @@
import { contractDetail } from '@/api/index.js'
const contractNameCache = Object.create(null)
const contractDetailCache = Object.create(null)
function pickFirstText(...values) {
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (value === undefined || value === null) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
function parseContractDetailResponse(response) {
const payload = response && response.data ? response.data : response
if (payload && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
return payload.data
}
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload
}
return {}
}
function resolveContractNameFromSource(source = {}) {
return pickFirstText(
source.contractDisplayName,
source.contractName,
source.contractTemplateName,
source.contractTypeName,
source.contractType,
source.type,
source.typeName,
source.templateName,
source.title
)
}
function resolveContractNo(source = {}) {
return pickFirstText(source.no, source.contractNo)
}
export function getContractDisplayName(source = {}, fallbackText = '') {
const directName = resolveContractNameFromSource(source)
if (directName) return directName
const contractNo = resolveContractNo(source)
if (contractNo && contractNameCache[contractNo]) return contractNameCache[contractNo]
return fallbackText || ''
}
export async function fetchContractDisplayInfo(contractNo = '', language) {
const normalizedContractNo = String(contractNo || '').trim()
if (!normalizedContractNo) {
return {
contractDisplayName: '',
detail: {}
}
}
if (contractDetailCache[normalizedContractNo]) {
const cachedDetail = contractDetailCache[normalizedContractNo]
return {
contractDisplayName: resolveContractNameFromSource(cachedDetail) || contractNameCache[normalizedContractNo] || '',
detail: cachedDetail
}
}
const response = await contractDetail({ contractNo: normalizedContractNo }, language)
const detail = parseContractDetailResponse(response)
const contractDisplayName = resolveContractNameFromSource(detail)
contractDetailCache[normalizedContractNo] = detail
contractNameCache[normalizedContractNo] = contractDisplayName
return {
contractDisplayName,
detail
}
}
export async function enrichContractDisplayNames(list = [], language) {
const sourceList = Array.isArray(list) ? list : []
const pendingNos = [...new Set(
sourceList
.filter(item => !resolveContractNameFromSource(item))
.map(item => resolveContractNo(item))
.filter(Boolean)
)]
await Promise.all(
pendingNos.map((contractNo) =>
fetchContractDisplayInfo(contractNo, language).catch(() => ({
contractDisplayName: '',
detail: {}
}))
)
)
return sourceList.map((item) => {
const contractNo = resolveContractNo(item)
return {
...item,
contractDisplayName: getContractDisplayName(item, '')
|| (contractNo ? contractNameCache[contractNo] : '')
|| ''
}
})
}

View File

@@ -0,0 +1,90 @@
import { sysdictPage } from '@/api/index.js'
export const MOBILE_DEPOSIT_OTHER_PAYMENT_CONFIG_TYPE = 'MobileDepositOtherPaymentConfig'
export const MOBILE_PROVIDER_TYPE = 'WechatMiniProgram'
export const DEPOSIT_OTHER_PAYMENT_CONFIG_KEY = 'depositOtherPayment'
function normalizeText(value) {
return String(value || '').trim()
}
function parseJsonStringMaybe(value) {
const text = normalizeText(value)
if (!text) {
return {}
}
try {
const parsed = JSON.parse(text)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch (error) {
return {}
}
}
function normalizeRows(response) {
const payload = response && response.data !== undefined ? response.data : response
if (Array.isArray(payload)) {
return payload
}
if (payload && Array.isArray(payload.data)) {
return payload.data
}
if (payload && payload.data && Array.isArray(payload.data.data)) {
return payload.data.data
}
return []
}
function getProviderType(row = {}) {
return normalizeText(row.providerType || row.ProviderType)
}
export function createDefaultDepositOtherPaymentConfig() {
return {
code: '',
key: DEPOSIT_OTHER_PAYMENT_CONFIG_KEY,
phone: '',
qrCode: '',
contentZh: '',
contentEn: ''
}
}
export function normalizeDepositOtherPaymentConfig(row = {}) {
const defaults = createDefaultDepositOtherPaymentConfig()
const extra = parseJsonStringMaybe(row.extraInfo || row.extraConfig || row.ExtraConfig)
return {
code: normalizeText(row.code),
key: normalizeText(row.key) || defaults.key,
phone: normalizeText(extra.phone || row.description),
qrCode: normalizeText(extra.qrCode || extra.qrCodeImage || extra.qrImage),
contentZh: normalizeText(extra.contentZh || extra.content || defaults.contentZh),
contentEn: normalizeText(extra.contentEn || defaults.contentEn)
}
}
function findMatchedRow(rows = []) {
const providerRows = rows.filter(item => getProviderType(item) === MOBILE_PROVIDER_TYPE)
const legacyRows = rows.filter(item => !getProviderType(item))
return (
providerRows.find(item => normalizeText(item && item.key) === DEPOSIT_OTHER_PAYMENT_CONFIG_KEY)
|| providerRows[0]
|| legacyRows.find(item => normalizeText(item && item.key) === DEPOSIT_OTHER_PAYMENT_CONFIG_KEY)
|| legacyRows[0]
|| null
)
}
export function loadDepositOtherPaymentConfig(language) {
return sysdictPage({
Type: MOBILE_DEPOSIT_OTHER_PAYMENT_CONFIG_TYPE,
ProviderType: MOBILE_PROVIDER_TYPE,
PageIndex: 1,
PageSize: 100
}, language).then((response) => {
const rows = normalizeRows(response)
const matchedRow = findMatchedRow(rows)
return normalizeDepositOtherPaymentConfig(matchedRow || {})
})
}

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, CONTRACT: 3,
CHECKIN: 40 BILL: 4,
CHECKIN: 5
} }
function getLanguage(language) { function getLanguage(language) {
@@ -89,7 +90,87 @@ export function getReservationCurrentNode(flow = {}) {
return null return null
} }
return nodes.find(item => item.isCurrent) || nodes[nodes.length - 1] const currentNodes = nodes.filter(item => item.isCurrent)
if (currentNodes.length) {
const currentCheckinNode = currentNodes.find(item => (
Number(item.step || 0) === RESERVATION_FLOW_STEP.CHECKIN
&& String(item.refNo || '').trim()
))
if (currentCheckinNode) {
return currentCheckinNode
}
// 后端偶发会把前序节点也标记成 isCurrent,优先取排序最靠后的当前节点
return currentNodes[currentNodes.length - 1]
}
const checkinNode = nodes.find(item => (
Number(item.step || 0) === RESERVATION_FLOW_STEP.CHECKIN
&& String(item.refNo || '').trim()
))
if (checkinNode) {
return checkinNode
}
return nodes[nodes.length - 1]
}
export function getReservationCurrentFlow(flowList = []) {
const flows = normalizeArray(flowList)
.filter(item => item && typeof item === 'object')
.map(item => ({
flow: item,
currentNode: getReservationCurrentNode(item)
}))
.filter(item => item.currentNode)
if (!flows.length) {
return null
}
const compareNode = (target) => {
const node = target.currentNode || {}
const step = Number(node.step || 0)
const sort = Number(node.sort || 0)
const refNo = String(node.refNo || '').trim()
const isCurrent = !!node.isCurrent
const isCheckinWithRefNo = step === RESERVATION_FLOW_STEP.CHECKIN && !!refNo
return [
isCheckinWithRefNo ? 1 : 0,
isCurrent ? 1 : 0,
step,
sort
]
}
return flows
.sort((a, b) => {
const aScore = compareNode(a)
const bScore = compareNode(b)
for (let index = 0; index < aScore.length; index += 1) {
if (aScore[index] !== bScore[index]) {
return bScore[index] - aScore[index]
}
}
return 0
})[0].flow
}
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 = []) {
@@ -131,7 +212,7 @@ export function getReservationStepMeta(step, language = 'zh') {
}, },
[RESERVATION_FLOW_STEP.BILL]: { [RESERVATION_FLOW_STEP.BILL]: {
label: currentLanguage === 'en' ? 'Bill' : '账单', label: currentLanguage === 'en' ? 'Bill' : '账单',
actionText: currentLanguage === 'en' ? 'Go to Bill' : '去缴费' actionText: currentLanguage === 'en' ? 'Go to Payment' : '去支付'
}, },
[RESERVATION_FLOW_STEP.CONTRACT]: { [RESERVATION_FLOW_STEP.CONTRACT]: {
label: currentLanguage === 'en' ? 'Contract' : '合同', label: currentLanguage === 'en' ? 'Contract' : '合同',

View File

@@ -22,6 +22,11 @@ const BANNER_TEXT_FALLBACK_MAP = {
} }
const VALUE_TEXT_FALLBACK_MAP = { const VALUE_TEXT_FALLBACK_MAP = {
apartment: {
'绿居·和山国际公寓': 'Luju Heshan Intl. Apartments',
'和山国际公寓': 'Heshan Intl. Apartments',
'乾晖里': 'Qianhui Residence'
},
tags: { tags: {
'押一付一': 'Monthly Deposit', '押一付一': 'Monthly Deposit',
'大型超市': 'Large Supermarket', '大型超市': 'Large Supermarket',
@@ -148,6 +153,10 @@ export function localizeHousingTag(tag = '', language = getCurrentLanguage()) {
return localizeKnownValue('tags', tag, language) return localizeKnownValue('tags', tag, language)
} }
export function localizeApartmentName(name = '', language = getCurrentLanguage()) {
return localizeKnownValue('apartment', name, language)
}
export function localizePayMethodText(payMethod = '', language = getCurrentLanguage()) { export function localizePayMethodText(payMethod = '', language = getCurrentLanguage()) {
return localizeKnownValue('payMethod', payMethod, language) return localizeKnownValue('payMethod', payMethod, language)
} }
@@ -330,7 +339,19 @@ export function normalizeBannerItem(item = {}, index = 0) {
export function normalizeImageList(item = {}) { export function normalizeImageList(item = {}) {
const candidates = [] const candidates = []
const listFields = [item.images, item.imageUrls, item.pictures, item.pictureList] const listFields = [
item.images,
item.imageUrls,
item.pictures,
item.pictureList,
item.bannerImages,
item.carouselImages,
item.imageList,
item.photos,
item.photoList,
item.album,
item.albums
]
listFields.forEach((field) => { listFields.forEach((field) => {
if (Array.isArray(field)) { if (Array.isArray(field)) {
@@ -343,7 +364,19 @@ export function normalizeImageList(item = {}) {
} }
}) })
;[item.imageUrl, item.image, item.cover, item.picture].forEach((image) => { ;[
item.imageUrl,
item.image,
item.cover,
item.coverUrl,
item.picture,
item.poster,
item.posterUrl,
item.banner,
item.bannerUrl,
item.areaImage,
item.areaImageUrl
].forEach((image) => {
const normalized = normalizeBannerImage(image) const normalized = normalizeBannerImage(image)
if (normalized) { if (normalized) {
candidates.push(normalized) candidates.push(normalized)

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,11 +125,13 @@ 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
} }
return { return {
ProviderType: 'WechatMiniProgram',
OrderNo: orderNo OrderNo: orderNo
} }
} }

64
utils/todo-route.js Normal file
View File

@@ -0,0 +1,64 @@
function ensureArray(value) {
return Array.isArray(value) ? value : []
}
function pickFirstText() {
for (let index = 0; index < arguments.length; index += 1) {
const value = arguments[index]
if (value === null || value === undefined) continue
const text = String(value).trim()
if (text) return text
}
return ''
}
function normalizeNumber(value, fallback = 0) {
const num = Number(value)
return Number.isFinite(num) ? num : fallback
}
function normalizeTodoEntry(raw) {
if (raw && typeof raw === 'object') {
return {
...raw,
_todoCode: pickFirstText(raw.code, raw.no, raw.contractNo, raw.billNo, raw.orderNo, raw.referenceTypeNo)
}
}
const text = pickFirstText(raw)
return text ? { code: text, _todoCode: text } : null
}
function extractTodoEntries(item) {
const sourceList = [item && item.items, item && item.codes, item && item.list, item && item.data]
for (let index = 0; index < sourceList.length; index += 1) {
const current = sourceList[index]
if (!Array.isArray(current)) continue
return current.map(entry => normalizeTodoEntry(entry)).filter(Boolean)
}
return []
}
export function extractTodoRows(response) {
const payload = response && response.data !== undefined ? response.data : response
if (Array.isArray(payload)) return payload
if (!payload || typeof payload !== 'object') return []
if (Array.isArray(payload.data)) return payload.data
if (payload.data && Array.isArray(payload.data.data)) return payload.data.data
return []
}
export function normalizeTodoList(rows = []) {
return ensureArray(rows).map((item) => {
const entries = extractTodoEntries(item)
const count = Math.max(normalizeNumber(item && item.count, entries.length), entries.length)
return {
todoType: String(item && item.todoType !== undefined ? item.todoType : ''),
todoTypeDesc: pickFirstText(item && item.todoTypeDesc),
count,
entries,
codes: entries
.map(entry => ({ code: pickFirstText(entry && entry._todoCode) }))
.filter(entry => entry.code)
}
}).filter(item => item.count > 0 || item.entries.length > 0 || item.todoTypeDesc)
}