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}`
}
function shouldIgnoreGatewayRoom404(error = {}) {
return Number(error && error.statusCode) === 404
}
function normalizeUploadResponse(data) {
if (typeof data !== 'string') {
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) {
return request({
@@ -430,14 +449,15 @@ export function orderSearchedReservationFlow(data = {}, language) {
})
}
export function roomInspectionMyPage(params = {}, language) {
export function roomInspectionMyPage(params = {}, language, options = {}) {
return request({
url: '/roominspection/my/page',
method: 'GET',
params: params || {},
header: {
'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) {
const payload = data || {}
return request({
@@ -576,9 +614,13 @@ export function paymentStatusByBillNo(params = {}, 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({
url: '/ApplicationForm/user/detail/cancel',
url: '/applicationform/user/detail/cancel',
method: 'POST',
data: payload,
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) {
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) {
return request({
url: '/facility/managers',
@@ -818,7 +884,14 @@ export function gatewayRoomDeviceList(brandId = '', roomCode = '', language) {
method: 'POST',
header: {
'X-Requested-Language': resolveLanguageHeader(language)
},
hideErrorToast: true
})
.catch((error) => {
if (shouldIgnoreGatewayRoom404(error)) {
return []
}
return Promise.reject(error)
})
}
@@ -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) {
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",
"description" : "",
"versionName" : "1.0.0",

View File

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

View File

@@ -252,6 +252,10 @@ export default {
type: Number,
default: 0
},
isActive: {
type: Boolean,
default: false
},
language: {
type: String,
default: 'zh'
@@ -348,7 +352,14 @@ export default {
})
},
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) {
uni.navigateTo({

View File

@@ -91,6 +91,10 @@ export default {
type: Number,
default: 0
},
isActive: {
type: Boolean,
default: false
},
language: {
type: String,
default: 'zh'
@@ -144,9 +148,19 @@ export default {
.trim()
},
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) {
if (!this.isActive) {
return
}
this.$emit('banner-switch', index)
}
}

View File

@@ -28,6 +28,7 @@
>
<swiper-item>
<SelectRoomHero
:is-active="currentScreen === 0"
:language="currentLang"
:banner-list="bannerList"
:current-banner="currentBanner"
@@ -48,6 +49,7 @@
:show-scrollbar="false"
>
<SelectRoomAreaPanel
:is-active="currentScreen === 1"
:language="currentLang"
:banner-list="bannerList"
:current-banner-index="currentBannerIndex"
@@ -196,12 +198,12 @@ export default {
watch: {
currentLang() {
this.loadBannerList()
this.loadAreaList()
this.refreshSelectRoomState()
}
},
async mounted() {
this.checkLoginStatus()
await Promise.all([this.loadBannerList(), this.loadAreaList()])
await this.loadBannerList()
},
methods: {
getLocalizedBannerText(banner = {}, field = '') {
@@ -236,6 +238,29 @@ export default {
this.isLogin = session.isLogin
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() {
this.isBannerLoading = true
this.bannerLoadError = ''
@@ -408,10 +433,18 @@ export default {
})
},
handleBannerChange(index) {
this.currentBannerIndex = index
const nextIndex = Number(index)
if (!Number.isFinite(nextIndex) || nextIndex === this.currentBannerIndex) {
return
}
this.currentBannerIndex = nextIndex
},
switchBanner(index) {
this.currentBannerIndex = index
const nextIndex = Number(index)
if (!Number.isFinite(nextIndex) || nextIndex === this.currentBannerIndex) {
return
}
this.currentBannerIndex = nextIndex
},
collapseToTopBanner() {
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>
import { getRegisteredUser, saveRegisteredUser, startAuthorizedLoginSession } from '../../utils/app-state'
import { mergeBackendUserInfo, normalizeUser, resolveRegistrationApplicationCode } from '../../utils/flow'
import { mergeBackendUserInfo, normalizeUser } from '../../utils/flow'
import {
extractLoginSession,
matchRegisteredUser,
@@ -83,8 +83,8 @@ import {
resolveLoginPreparation
} from '../../utils/login-helper'
import { resolveBookingRedirectAfterLogin } from '../../utils/booking-entry'
import { syncLoginStayState } from '../../utils/session-sync'
import { registrationMyorder, userMe, usersLogin } from '@/api/index.js'
import { parseReservationFlowResponse, getReservationCurrentFlow, getReservationCurrentNode, hasEffectiveReservationFlow } from '../../utils/reservation-flow'
import { registrationMyorder, orderSearchedReservationFlow, userMe, usersLogin } from '@/api/index.js'
export default {
data() {
@@ -235,14 +235,39 @@ export default {
return extractLoginSession(response, this.$getLanguage())
},
fetchUserInfoAfterLogin() {
return userMe({}, this.$getLanguage())
.then(userInfoRes => {
return userInfoRes && userInfoRes.data ? userInfoRes.data : userInfoRes
fetchReservationFlowAfterLogin(userCode = '') {
const resolvedUserCode = String(userCode || '').trim()
if (!resolvedUserCode) {
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 => {
console.error('fetch userinfo failed', error)
return null
console.error('fetch reservation flow after login failed', error)
return {
flowList: [],
currentFlow: null,
currentNode: null,
hasActiveReservationFlow: false
}
})
},
rejectHandledLogin(options = {}) {
@@ -274,14 +299,15 @@ export default {
async resolveLoginState() {
const localRegisteredUser = normalizeUser(getRegisteredUser())
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 loginPreparation = resolveLoginPreparation({
language: this.$getLanguage(),
matchedLocalUser,
latestRegistration,
account: this.form.account,
password: this.form.password
password: this.form.password,
userCode
})
if (!loginPreparation.ok) {
@@ -301,21 +327,47 @@ export default {
} = loginState
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)
startAuthorizedLoginSession(finalUser, token, expires)
let nextRedirectUrl = this.redirectUrl
try {
const stayState = await syncLoginStayState({
language: this.$getLanguage(),
force: true
})
const stayState = {
hasCheckedIn: currentStep === 5,
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)
} catch (error) {
console.error('sync stay state after login failed', error)
console.error('resolve booking redirect after login failed', error)
}
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>
<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>
<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>
</template>
<script>
import { resolveExistingBookingNavigationTarget } from '../../utils/booking-entry'
import { getSessionSnapshot } from '../../utils/app-state'
import { syncLoginStayState } from '../../utils/session-sync'
import { accountQueryBill, accountQueryBills, applicationFormDetail, paymentBillPay, paymentPayingBills, submitApplicationForm } from '../../api/index.js'
import { saveActiveApplicationCode, saveHomePageTabId } from '../../utils/app-state'
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 {
data() {
return {
isSubmitting: false,
applyData: null,
showDepositNotice: false,
hasReadNotice: false,
isClosingDepositNoticeForPayment: false,
depositNoticeReadyAt: 0,
pendingDepositPaymentContext: null,
form: {
startDate: '',
endDate: ''
@@ -163,44 +332,339 @@ export default {
console.error('Failed to parse apply data', e);
})
}
this.redirectIfBookingUnavailable()
},
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) {
this.form.startDate = e.detail.value;
},
onEndDateChange(e) {
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() {
if (this.isSubmitting) return;
if (!this.form.startDate || !this.form.endDate) {
@@ -213,24 +677,39 @@ export default {
}
this.isSubmitting = true;
const orderData = {
...this.applyData,
startDate: this.form.startDate,
endDate: this.form.endDate,
stayNights: this.stayNights,
sourceType: 'booking-intent'
};
uni.showLoading({ title: this.language === 'zh' ? '提交中...' : 'Submitting...' });
let roomDataStr = encodeURIComponent(JSON.stringify(orderData));
uni.showLoading({ title: this.language === 'zh' ? '正在生成订单...' : 'Creating Order...' });
setTimeout(() => {
uni.hideLoading();
const payload = this.buildSubmitPayload()
return submitApplicationForm(payload, this.language)
.then(async (response) => {
const applicationCode = extractSubmitApplicationCode(response)
if (!applicationCode) {
throw new Error(this.$tr('申请单编号缺失'))
}
saveActiveApplicationCode(applicationCode)
const paymentContext = await this.resolvePaymentPreview(applicationCode).catch(() => ({
referenceTypeNo: applicationCode,
billNo: '',
paymentNo: '',
pendingPaymentOrder: null
}))
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;
uni.redirectTo({
url: `/pkgBooking/pay/pay?roomData=${roomDataStr}`
});
}, 1000);
}
}
}
@@ -347,4 +826,92 @@ export default {
opacity: 0.6;
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>

View File

@@ -1,31 +1,51 @@
<template>
<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>
</tn-nav-bar>
<view class="content-wrapper" :style="{ paddingTop: vuex_custom_bar_height + 'px' }">
<!-- 房型/区域信息 -->
<view class="info-card tn-flex tn-flex-row-between tn-flex-col-center" v-if="displayInfoTitle">
<view class="info-left">
<view class="info-title">{{ displayInfoTitle }}</view>
<view class="info-desc">{{ displayInfoDesc }}</view>
<!-- 顶部信息卡片 -->
<view class="hero-card" v-if="displayInfoTitle">
<view class="hero-glow"></view>
<view class="hero-inner tn-flex tn-flex-row-between tn-flex-col-center">
<view class="hero-content">
<view class="hero-tag">
<text class="tn-icon-company hero-tag-icon"></text>
<text class="hero-tag-text">{{ $tr('专属管家') }}</text>
</view>
<view class="info-icon">
<text class="tn-icon-company"></text>
<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 class="hero-accent"></view>
</view>
<!-- 看房时间卡片 -->
<view class="form-card">
<view class="card-header tn-flex tn-flex-row-between tn-flex-col-bottom">
<view class="header-left tn-flex tn-flex-col-bottom">
<text class="card-title">{{ $tr('选择看房时间') }}</text>
<text class="card-subtitle">{{ $tr('请选择您方便的时间段') }}</text>
<view class="card-header tn-flex tn-flex-col-bottom">
<view class="header-icon header-icon--primary">
<text class="tn-icon-time"></text>
</view>
<view class="header-titles">
<view class="card-title">{{ $tr('选择看房时间') }}</view>
<view class="card-subtitle">{{ $tr('请选择您方便的时间段') }}</view>
</view>
</view>
<view class="section-label">{{ $tr('预约日期') }}</view>
<scroll-view class="date-scroll" scroll-x :show-scrollbar="false">
<view class="date-flex">
<view
@@ -37,10 +57,12 @@
>
<text class="date-week">{{ item.week }}</text>
<text class="date-day">{{ item.label }}</text>
<view class="date-indicator"></view>
</view>
</view>
</scroll-view>
<view class="section-label section-label--time">{{ $tr('可约时段') }}</view>
<scroll-view class="time-scroll" scroll-x :show-scrollbar="false">
<view class="time-flex">
<view
@@ -50,23 +72,43 @@
:class="{ 'time-slot--active': currentTimeIndex === index }"
@click="selectTimeSlot(index)"
>
{{ item }}
<text class="time-slot-text">{{ item }}</text>
</view>
</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 class="form-card">
<view class="card-header">
<view class="header-left tn-flex tn-flex-col-bottom">
<text class="card-title">{{ $tr('您的租住需求') }}</text>
<text class="card-subtitle">{{ $tr('帮助管家更好地为您推荐房源') }}</text>
<view class="card-header tn-flex tn-flex-col-bottom">
<view class="header-icon header-icon--luxury">
<text class="tn-icon-like"></text>
</view>
<view class="header-titles">
<view class="card-title">{{ $tr('您的租住需求') }}</view>
<view class="card-subtitle">{{ $tr('帮助管家更好地为您推荐房源') }}</view>
</view>
</view>
<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
v-model="formData.intendedRoomTypeCode"
:initial-area-code="formData.intendedAreaCode"
@@ -75,7 +117,10 @@
</view>
<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
v-for="(item, index) in durationOptions"
@@ -90,9 +135,14 @@
</view>
<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-icon-wrap">
<text class="tn-icon-money input-icon"></text>
</view>
<input
class="custom-input"
v-model="formData.budget"
@@ -103,10 +153,15 @@
</view>
</view>
<view class="field-item">
<view class="field-label">{{ $tr('工作/通勤地点') }}</view>
<view class="field-item field-item--last">
<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-icon-wrap">
<text class="tn-icon-location input-icon"></text>
</view>
<input
class="custom-input"
v-model="formData.workplace"
@@ -114,16 +169,26 @@
:placeholder="$tr('请输入主要通勤地点')"
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>
<!-- 底部占位,避免被 fixed 按钮遮挡 -->
<view class="footer-spacer"></view>
</view>
<!-- 底部操作区 -->
<view class="footer-bar tn-safe-area-inset-bottom">
<view class="submit-btn" @click="handleSubmit">
<text>{{ $tr('提交预约') }}</text>
<view class="footer-inner">
<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>
@@ -191,6 +256,9 @@ export default {
language() {
return this.$getLanguage()
},
navBackgroundColor() {
return this.scrollTop > 20 ? '#F8FAFF' : 'transparent'
},
displayInfoTitle() {
if (!this.sourceData) {
return ''
@@ -214,6 +282,20 @@ export default {
: (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(' · ')
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() {
@@ -236,9 +318,13 @@ export default {
workplace: ''
},
sourceData: null,
isSubmitting: false
isSubmitting: false,
scrollTop: 0
}
},
onPageScroll(event) {
this.scrollTop = (event && event.scrollTop) || 0
},
onLoad(options) {
this.syncDurationOptions()
this.generateDates()
@@ -350,6 +436,26 @@ export default {
selectDuration(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() {
if (this.isSubmitting) {
return
@@ -391,8 +497,8 @@ export default {
showCancel: false,
confirmColor: '#01BEFF',
success: () => {
uni.redirectTo({
url: '/pkgService/my_application/my_application'
uni.reLaunch({
url: '/pages/index/index?pageId=stay-service'
})
}
})
@@ -414,174 +520,387 @@ export default {
</script>
<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 {
min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
background: linear-gradient(180deg, #EAF4FF 0%, $bg-page 30%, #FFFFFF 100%);
display: flex;
flex-direction: column;
}
.nav-title {
font-size: 34rpx;
font-weight: bold;
color: #1D2129;
font-weight: 700;
color: $text-primary;
letter-spacing: 2rpx;
}
.content-wrapper {
flex: 1;
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
}
/* 顶部信息卡片 */
.info-card {
margin: 30rpx;
padding: 40rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, #01BEFF 0%, #0099FF 100%);
box-shadow: 0 16rpx 32rpx rgba(1, 190, 255, 0.2);
color: #ffffff;
/* 顶部信息卡片(Hero) */
.hero-card {
position: relative;
margin: 24rpx 30rpx 30rpx;
border-radius: 36rpx;
background: linear-gradient(135deg, $hero-bg-start 0%, $hero-bg-end 100%);
box-shadow: 0 24rpx 60rpx rgba(10, 22, 40, 0.22);
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;
min-width: 0;
}
.info-title {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 12rpx;
letter-spacing: 2rpx;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
.hero-tag {
display: inline-flex;
align-items: center;
padding: 8rpx 18rpx;
background: rgba(255, 255, 255, 0.12);
border: 1rpx solid rgba(255, 255, 255, 0.18);
border-radius: 24rpx;
margin-bottom: 20rpx;
backdrop-filter: blur(8rpx);
}
.info-desc {
font-size: 24rpx;
opacity: 0.9;
.hero-tag-icon {
font-size: 22rpx;
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;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.info-icon {
font-size: 80rpx;
opacity: 0.2;
margin-left: 20rpx;
.hero-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.72);
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 {
margin: 0 30rpx 30rpx;
padding: 40rpx 30rpx;
border-radius: 24rpx;
background: #ffffff;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.03);
padding: 36rpx 32rpx;
border-radius: 32rpx;
background: $surface;
box-shadow: $shadow-card;
border: 1rpx solid $border-soft;
}
.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 {
font-size: 34rpx;
font-weight: bold;
color: #1D2129;
margin-right: 16rpx;
font-size: 32rpx;
font-weight: 700;
color: $text-primary;
margin-bottom: 6rpx;
letter-spacing: 1rpx;
}
.card-subtitle {
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 {
width: 100%;
white-space: nowrap;
margin-bottom: 30rpx;
}
.date-flex {
display: inline-flex;
gap: 20rpx;
padding-bottom: 10rpx;
gap: 18rpx;
padding: 6rpx 4rpx 16rpx;
}
.date-item {
width: 110rpx;
height: 130rpx;
width: 120rpx;
height: 144rpx;
background: #F4F8FF;
border-radius: 16rpx;
border-radius: 24rpx;
flex-shrink: 0;
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 {
background: #EAF0FF;
border-color: #01BEFF;
background: linear-gradient(135deg, $brand-primary 0%, #0099FF 100%);
border-color: rgba($brand-primary, 0.35);
box-shadow: $shadow-active;
transform: translateY(-4rpx);
}
.date-week {
font-size: 24rpx;
color: #86909C;
margin-bottom: 8rpx;
transition: all 0.3s ease;
color: $text-secondary;
margin-bottom: 10rpx;
transition: color 0.25s ease;
}
.date-item--active .date-week {
color: #01BEFF;
font-weight: bold;
color: rgba(255, 255, 255, 0.9);
}
.date-day {
font-size: 28rpx;
color: #1D2129;
font-weight: 500;
transition: all 0.3s ease;
font-size: 30rpx;
color: $text-primary;
font-weight: 700;
transition: color 0.25s ease;
}
.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 {
width: 100%;
white-space: nowrap;
}
.time-flex {
display: inline-flex;
gap: 20rpx;
padding-bottom: 8rpx;
gap: 18rpx;
padding: 6rpx 4rpx 16rpx;
}
.time-slot {
width: 220rpx;
height: 72rpx;
line-height: 72rpx;
text-align: center;
width: 200rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
background: #F4F8FF;
border-radius: 12rpx;
border-radius: 40rpx;
font-size: 26rpx;
color: #4E5969;
color: $text-secondary;
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;
}
.time-slot--active {
background: #01BEFF;
color: #ffffff;
font-weight: bold;
box-shadow: 0 8rpx 16rpx rgba(1, 190, 255, 0.2);
background: linear-gradient(135deg, $brand-primary 0%, #0099FF 100%);
color: #FFFFFF;
font-weight: 600;
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;
}
.field-item:last-child {
.field-item--last {
margin-bottom: 0;
}
.field-label {
display: flex;
align-items: center;
font-size: 28rpx;
color: #1D2129;
font-weight: bold;
margin-bottom: 24rpx;
font-weight: 700;
color: $text-primary;
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 {
padding: 0 32rpx;
height: 68rpx;
line-height: 68rpx;
padding: 0 34rpx;
height: 72rpx;
line-height: 72rpx;
background: #F4F8FF;
border-radius: 34rpx;
border-radius: 36rpx;
font-size: 26rpx;
color: #4E5969;
color: $text-secondary;
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 {
background: #EAF0FF;
border-color: #01BEFF;
color: #01BEFF;
font-weight: bold;
background: linear-gradient(135deg, rgba($brand-primary, 0.1) 0%, rgba($brand-primary, 0.06) 100%);
border-color: rgba($brand-primary, 0.35);
color: $brand-primary;
font-weight: 600;
box-shadow: 0 6rpx 16rpx rgba($brand-primary, 0.12);
}
/* 输入框 */
.input-box {
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;
height: 88rpx;
padding: 0 30rpx;
background: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
margin-right: 18rpx;
box-shadow: 0 4rpx 10rpx rgba(0, 0, 0, 0.04);
}
.input-icon {
font-size: 36rpx;
color: #A1B0C4;
margin-right: 16rpx;
font-size: 28rpx;
color: $brand-primary;
}
.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 {
flex: 1;
font-size: 28rpx;
color: #1D2129;
color: $text-primary;
font-weight: 500;
}
.footer-spacer {
height: 180rpx;
}
/* 底部按钮 */
@@ -652,30 +1048,61 @@ export default {
bottom: 0;
left: 0;
width: 100%;
background: #ffffff;
padding: 24rpx 40rpx;
box-shadow: 0 -4rpx 24rpx rgba(0, 0, 0, 0.04);
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(20rpx);
padding: 24rpx 40rpx calc(24rpx + env(safe-area-inset-bottom));
box-shadow: 0 -8rpx 32rpx rgba(26, 34, 51, 0.06);
z-index: 100;
}
.footer-inner {
width: 100%;
}
.submit-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, #01BEFF 0%, #0099FF 100%);
border-radius: 48rpx;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
background: linear-gradient(135deg, #A1B0C4 0%, #86909C 100%);
color: #FFFFFF;
font-size: 32rpx;
font-weight: bold;
font-weight: 700;
letter-spacing: 2rpx;
box-shadow: 0 12rpx 24rpx rgba(1, 190, 255, 0.25);
transition: all 0.3s ease;
box-shadow: 0 8rpx 24rpx rgba(134, 144, 156, 0.2);
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);
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>

View File

@@ -1,40 +1,53 @@
<template>
<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-label">{{ $tr('意向房型') }}</view>
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }">
{{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : $tr('请选择房型') }}
{{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : $tr('请选择意向房型') }}
</view>
<view class="selected-area tn-text-ellipsis" v-if="selectedArea">
<text class="tn-icon-location area-icon"></text>
{{ getAreaDisplayName(selectedArea) }}
</view>
</view>
<text class="tn-icon-right tn-color-gray"></text>
<view class="selector-arrow">
<text class="tn-icon-right"></text>
</view>
</view>
<tn-popup v-model="showPopup" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">{{ $tr('选择意向房型') }}</text>
<view class="popup-title">{{ $tr('选择意向房型') }}</view>
<view class="popup-subtitle">{{ $tr('选择您心仪的房型与区域') }}</view>
</view>
<scroll-view scroll-y class="area-list-scroll">
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
<tn-loading mode="circle"></tn-loading>
<view class="tn-margin-top-sm tn-color-gray">{{ $tr('房型加载中...') }}</view>
<tn-loading mode="circle" :size="48"></tn-loading>
<view class="tn-margin-top tn-color-gray">{{ $tr('房型加载中...') }}</view>
</view>
<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 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="tn-flex tn-flex-col-center">
<text class="area-dot"></text>
<text>{{ getAreaDisplayName(area) }}</text>
<view class="area-title-left tn-flex tn-flex-col-center">
<view class="area-icon-wrap">
<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>
<text :class="collapsedAreas[index] ? 'tn-icon-down' : 'tn-icon-up'" class="tn-color-gray"></text>
</view>
<view class="room-type-grid" v-show="!collapsedAreas[index]">
<view
@@ -45,7 +58,15 @@
@click="handleSelect(area, room)"
>
<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>
@@ -59,25 +80,6 @@
import { areaPagination } from '@/api/index.js'
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 {
name: 'BookViewingRoomTypeSelector',
props: {
@@ -230,17 +232,30 @@ export default {
</script>
<style scoped lang="scss">
$luxury-gold: #D8B56A;
$text-primary: #1A2233;
$text-secondary: #6A7A99;
$text-tertiary: #A1B0C4;
$surface: #FFFFFF;
$bg-popup: #F8FAFF;
.room-type-selector {
width: 100%;
}
.selector-header {
background: #FFFFFF;
.selector-card {
background: $surface;
border-radius: 20rpx;
padding: 26rpx 32rpx;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
min-height: 100rpx;
padding: 26rpx 30rpx;
border: 2rpx solid rgba($brand-primary, 0.12);
box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.05);
min-height: 120rpx;
transition: all 0.25s ease;
}
.selector-card:active {
background: #FAFCFF;
border-color: rgba($brand-primary, 0.25);
}
.selected-info {
@@ -248,72 +263,175 @@ export default {
overflow: hidden;
}
.selected-label {
font-size: 22rpx;
color: $text-tertiary;
margin-bottom: 8rpx;
}
.selected-name {
font-size: 28rpx;
color: #1A2233;
font-weight: 600;
font-size: 30rpx;
color: $text-primary;
font-weight: 700;
&.is-empty {
color: #A0B0C0;
color: $text-tertiary;
font-weight: normal;
}
}
.selected-area {
font-size: 24rpx;
color: $text-secondary;
margin-top: 10rpx;
display: flex;
align-items: center;
}
.area-icon {
font-size: 22rpx;
color: #6A7A99;
margin-top: 6rpx;
margin-right: 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 {
display: flex;
flex-direction: column;
height: 100%;
background: #F8FAFF;
background: $bg-popup;
}
.popup-header {
padding: 40rpx 30rpx 20rpx;
padding: 40rpx 30rpx 28rpx;
text-align: center;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
background: #FFFFFF;
background: $surface;
border-bottom: 1rpx solid rgba($brand-primary, 0.08);
}
.popup-title {
font-size: 32rpx;
font-size: 34rpx;
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 {
flex: 1;
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 {
margin-bottom: 30rpx;
background: #FFFFFF;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.04);
border: 1rpx solid rgba($brand-primary, 0.05);
margin-bottom: 28rpx;
background: $surface;
border-radius: 24rpx;
padding: 28rpx;
box-shadow: 0 8rpx 28rpx rgba(26, 34, 51, 0.04);
border: 1rpx solid rgba($brand-primary, 0.06);
}
.area-title {
font-size: 28rpx;
font-weight: 600;
color: #1A2233;
margin-bottom: 20rpx;
margin-bottom: 24rpx;
}
.area-dot {
width: 12rpx;
height: 12rpx;
.area-title-left {
flex: 1;
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%;
background: $brand-primary;
margin-right: 12rpx;
box-shadow: 0 0 8rpx rgba($brand-primary, 0.4);
background: #F4F8FF;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
text {
font-size: 24rpx;
color: $text-secondary;
}
}
.room-type-grid {
@@ -323,17 +441,18 @@ export default {
}
.room-type-item {
padding: 20rpx;
border-radius: 16rpx;
background: #FFFFFF;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
transition: all 0.3s ease;
position: relative;
padding: 24rpx 20rpx;
border-radius: 20rpx;
background: #FAFCFF;
border: 2rpx solid rgba($brand-primary, 0.1);
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
&.is-active {
background: #FFFFFF;
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 {
color: $brand-primary;
@@ -342,18 +461,66 @@ export default {
.room-price {
color: $brand-primary;
}
.active-mark {
transform: scale(1);
opacity: 1;
}
}
}
.room-name {
font-size: 26rpx;
font-weight: 600;
color: #1A2233;
margin-bottom: 8rpx;
font-size: 28rpx;
font-weight: 700;
color: $text-primary;
margin-bottom: 10rpx;
}
.room-price {
font-size: 24rpx;
color: $text-secondary;
display: flex;
align-items: baseline;
&--empty {
font-size: 22rpx;
color: #6A7A99;
color: $text-tertiary;
}
}
.price-symbol {
font-size: 22rpx;
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>

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,14 @@
<template>
<view class="section-panel" v-if="facilityList && facilityList.length">
<view class="section-header">
<view class="title-bar"></view>
<text class="section-title">{{ $tr('配套设施') }}</text>
</view>
<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="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>
</view>
</view>
@@ -29,25 +32,39 @@ export default {
</script>
<style scoped lang="scss">
$navy-deep: #1E3A5F;
$gold: #C9A96E;
$gold-light: #D4BC8C;
$text-primary: #0F172A;
$text-secondary: #64748B;
.section-panel {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1rpx solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.05);
background: #FFFFFF;
border: 1rpx solid rgba($navy-deep, 0.08);
box-shadow: 0 8rpx 28rpx rgba(30, 58, 95, 0.06);
margin: 24rpx 32rpx 0;
padding: 32rpx;
border-radius: 24rpx;
border-radius: 28rpx;
}
.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 {
font-size: 32rpx;
font-weight: bold;
color: #fff;
font-weight: 800;
color: $navy-deep;
}
.facility-grid {
@@ -58,14 +75,23 @@ export default {
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 {
font-size: 52rpx;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 12rpx;
font-size: 44rpx;
color: $navy-deep;
}
.facility-name {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.7);
color: $text-secondary;
font-weight: 500;
}
</style>

View File

@@ -1,8 +1,11 @@
<template>
<view class="section-panel room-section">
<view class="section-header tn-flex tn-flex-row-between tn-flex-col-center">
<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 class="room-list-vertical">
<view
class="room-card-v"
@@ -99,25 +102,43 @@ export default {
</script>
<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 {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1rpx solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.05);
background: #FFFFFF;
border: 1rpx solid rgba($navy-deep, 0.08);
box-shadow: 0 8rpx 28rpx rgba(30, 58, 95, 0.06);
margin: 24rpx 32rpx 0;
padding: 32rpx;
border-radius: 24rpx;
border-radius: 28rpx;
}
.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 {
font-size: 32rpx;
font-weight: bold;
color: #fff;
font-weight: 800;
color: $navy-deep;
}
.room-list-vertical {
@@ -129,11 +150,17 @@ export default {
.room-card-v {
width: 100%;
display: flex;
border-radius: 16rpx;
border-radius: 24rpx;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
border: 1rpx solid rgba(255, 255, 255, 0.1);
box-shadow: none;
background: #FFFFFF;
border: 1rpx solid rgba($navy-deep, 0.08);
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 {
@@ -146,38 +173,38 @@ export default {
.img {
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.1);
background: linear-gradient(135deg, #E2E8F0 0%, #CBD5E1 100%);
}
.pay-method-tag {
position: absolute;
top: 12rpx;
left: 12rpx;
background: rgba(0, 0, 0, 0.6);
background: rgba($navy-deep, 0.85);
backdrop-filter: blur(8rpx);
color: #fff;
color: $gold-light;
font-size: 18rpx;
padding: 4rpx 10rpx;
border-radius: 6rpx;
padding: 4rpx 12rpx;
border-radius: 8rpx;
font-weight: 500;
}
.room-info-v {
flex: 1;
padding: 16rpx 20rpx;
padding: 20rpx 24rpx;
display: flex;
flex-direction: column;
min-width: 0;
}
.room-header-v {
margin-bottom: 8rpx;
margin-bottom: 10rpx;
}
.room-name-v {
font-size: 30rpx;
font-weight: bold;
color: #fff;
font-weight: 800;
color: $navy-deep;
flex: 1;
}
@@ -188,16 +215,18 @@ export default {
.tag-v {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.15);
color: $navy-deep;
background: rgba($navy-deep, 0.06);
padding: 4rpx 12rpx;
border-radius: 6rpx;
border-radius: 8rpx;
font-weight: 500;
}
.room-desc-v {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.6);
margin-bottom: auto; /* 将底部价格推下去 */
color: $text-tertiary;
margin-bottom: auto;
line-height: 1.5;
}
.room-bottom-v {
@@ -205,41 +234,45 @@ export default {
}
.room-price-v {
color: #ffb8b8;
color: $navy-deep;
display: flex;
align-items: baseline;
}
.price-symbol {
font-size: 20rpx;
font-weight: bold;
font-weight: 800;
margin-right: 2rpx;
color: $gold;
}
.price-val {
font-size: 36rpx;
font-weight: 800;
line-height: 1;
color: $navy-deep;
}
.price-unit {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.6);
color: $text-tertiary;
margin-left: 4rpx;
font-weight: 500;
}
.go-btn-v {
background: rgba(255, 255, 255, 0.15);
color: #fff;
background: linear-gradient(135deg, $navy-light 0%, $navy-deep 100%);
color: #FFFFFF;
font-size: 22rpx;
padding: 8rpx 20rpx;
padding: 10rpx 22rpx;
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 {
font-size: 20rpx;
margin-left: 4rpx;
color: $gold-light;
}
</style>

View File

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

View File

@@ -1,39 +1,19 @@
<template>
<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
:isBack="true"
:bottomShadow="false"
backgroundColor="transparent"
:backgroundColor="navBarBackgroundColor"
:customBackStyle="navBarBackStyle"
backIconColor="#FFFFFF"
:backIconColor="navBarIconColor"
: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>
<!-- 上层:透明可滚动内容区 -->
<!-- 可滚动内容区 -->
<scroll-view
class="content-scroll"
scroll-y
@@ -42,24 +22,39 @@
enable-back-to-top
>
<view class="scroll-inner">
<!-- 留出足够的顶部空间,让背景图展示出来 -->
<view class="hero-space tn-flex tn-flex-direction-column tn-flex-col-bottom">
<!-- 照片计数器移到店名上方,避免与导航栏重叠 -->
<view v-if="storeGalleryList.length > 1" class="hero-counter tn-flex tn-flex-col-center">
<!-- Hero 大图区 -->
<view class="hero-panel">
<swiper
v-if="storeGalleryList.length"
class="hero-swiper"
circular
autoplay
:interval="5000"
:duration="1000"
@change="onHeroSwiperChange"
>
<swiper-item v-for="(image, index) in storeGalleryList" :key="`${image}-${index}`">
<image class="hero-image" :src="image" mode="aspectFill"></image>
</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 class="store-title-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>
<!-- 悬浮毛玻璃信息流 -->
<view class="floating-content-wrap">
<!-- 信息流 -->
<view class="content-body">
<store-info
:storeData="storeData"
:language="language"
@@ -83,14 +78,14 @@
</view>
</scroll-view>
<!-- 悬浮操作栏 (固定在底部,透明风格) -->
<!-- 底部操作栏 -->
<view class="action-bar-wrap tn-safe-area-inset-bottom">
<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>{{ $tr('分享') }}</text>
</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>{{ contactButtonText }}</text>
</button>
@@ -103,8 +98,8 @@
import StoreInfo from './components/StoreInfo.vue';
import FacilityList from './components/FacilityList.vue';
import RoomList from './components/RoomList.vue';
import { facilityManagers } from '@/api/index.js'
import { localizeFacilityName, localizeHousingTag } from '../../utils/select-room-normalizers'
import { areaDetailPagination, facilityManagers } from '@/api/index.js'
import { localizeFacilityName, localizeHousingTag, normalizeAreaItem } from '../../utils/select-room-normalizers'
function parseCoordinate(...values) {
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) {
return this.storeData.images.filter(Boolean)
}
if (this.heroImage) {
return [this.heroImage]
}
return []
},
heroImage() {
if (this.storeData.images && 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 ''
},
storeTags() {
@@ -224,24 +213,37 @@ export default {
}
return this.$tr('电话咨询')
},
navBarBackgroundColor() {
const opacity = Math.min(Math.max((this.scrollTop - 20) / 80, 0), 1);
return `rgba(255, 255, 255, ${opacity})`;
},
navBarBackStyle() {
const isDarkBg = this.scrollTop < 100;
const isTop = this.scrollTop < 60;
return {
transition: 'all 0.3s',
background: isDarkBg ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.15)',
backdropFilter: 'blur(10px)',
background: isTop ? 'rgba(255, 255, 255, 0.92)' : '#FFFFFF',
backdropFilter: 'blur(12rpx)',
WebkitBackdropFilter: 'blur(12rpx)',
borderRadius: '50%',
width: '64rpx',
height: '64rpx',
width: '68rpx',
height: '68rpx',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
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() {
return Math.min(Math.max((this.scrollTop - 80) / 100, 0), 1);
return Math.min(Math.max((this.scrollTop - 60) / 80, 0), 1);
}
},
onLoad(options) {
@@ -276,7 +278,74 @@ export default {
applyStoreData(data) {
this.storeData = data && typeof data === 'object' ? data : {}
this.roomTypes = this.storeData.roomTypes || []
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() {
const areaCode = this.storeData.code || this.storeData.areaCode || ''
@@ -412,9 +481,20 @@ export default {
</script>
<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 {
height: 100vh;
background-color: #000; /* 全屏黑色底色,防止滑动露白 */
background: linear-gradient(180deg, $bg-blue 0%, $bg-page 25%, #FFFFFF 100%);
display: flex;
flex-direction: column;
position: relative;
@@ -424,7 +504,6 @@ export default {
.nav-title {
font-size: 34rpx;
font-weight: 700;
color: #fff;
transition: opacity 0.3s;
max-width: 420rpx;
overflow: hidden;
@@ -432,40 +511,14 @@ export default {
white-space: nowrap;
}
.transparent-nav-bar ::v-deep .tn-nav-bar {
background: transparent !important;
.glass-nav-bar ::v-deep .tn-nav-bar {
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
box-shadow: 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 {
position: relative;
z-index: 10;
@@ -482,28 +535,48 @@ export default {
.scroll-inner {
min-height: 101%;
padding-top: calc(100rpx + env(safe-area-inset-top));
}
/* 留出背景展示区 */
.hero-space {
/* Hero 大图区 */
.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%;
height: 35vh; /* 进一步降低留白高度,确保底部房型列表至少透出一条数据 */
padding: 0 32rpx 40rpx;
justify-content: flex-end; /* 内容沉底 */
height: 100%;
}
.hero-empty {
color: $text-tertiary;
font-size: 28rpx;
}
.hero-counter {
position: absolute;
right: 24rpx;
bottom: 24rpx;
height: 52rpx;
padding: 0 20rpx;
border-radius: 999rpx;
background: rgba(0, 0, 0, 0.3);
background: rgba($navy-deep, 0.85);
backdrop-filter: blur(14px);
color: #ffffff;
color: $gold-light;
font-size: 24rpx;
font-weight: 500;
border: 1rpx solid rgba(255, 255, 255, 0.2);
margin-bottom: 24rpx;
align-self: flex-end; /* 靠右对齐 */
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(30, 58, 95, 0.2);
}
.counter-icon {
@@ -511,59 +584,72 @@ export default {
font-size: 26rpx;
}
.store-title-wrap {
width: 100%;
color: #fff;
/* 店名主卡片 */
.store-title-card {
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 {
font-size: 64rpx;
font-weight: bold;
margin-bottom: 24rpx;
text-shadow: 0 4rpx 16rpx rgba(0,0,0,0.6);
font-size: 48rpx;
font-weight: 800;
color: $navy-deep;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
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 {
gap: 16rpx;
}
.tag-item {
font-size: 24rpx;
padding: 8rpx 20rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border: 1rpx solid rgba(255, 255, 255, 0.2);
color: #fff;
text-shadow: 0 2rpx 4rpx rgba(0,0,0,0.3);
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: rgba($navy-deep, 0.06);
border: 1rpx solid rgba($navy-deep, 0.12);
color: $navy-deep;
font-weight: 500;
}
/* 悬浮毛玻璃信息流 */
.floating-content-wrap {
position: relative;
padding-bottom: 180rpx;
/* 信息流 */
.content-body {
padding: 24rpx 0 0;
}
/* 底部操作栏 - 毛玻璃悬浮 */
/* 底部操作栏 */
.action-bar-wrap {
position: fixed;
left: 0;
right: 0;
bottom: 0;
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;
pointer-events: none; /* 让渐变层不阻挡点击 */
}
.action-bar {
display: flex;
gap: 24rpx;
pointer-events: auto; /* 恢复按钮区域点击 */
}
.action-btn {
@@ -573,35 +659,41 @@ export default {
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: bold;
font-weight: 700;
padding: 0;
border: 1rpx solid rgba(255, 255, 255, 0.3);
line-height: 1;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
transition: all 0.2s;
}
button.action-btn::after {
border: none;
}
.action-btn:active {
transform: scale(0.97);
}
.action-btn__icon {
margin-right: 12rpx;
font-size: 34rpx;
}
.action-btn--glass {
.action-btn--secondary {
flex: 0 0 200rpx;
background: rgba(255, 255, 255, 0.15);
color: #fff;
background: rgba($navy-deep, 0.06);
color: $navy-deep;
border: 1rpx solid rgba($navy-deep, 0.12);
}
.action-btn--primary-glass {
.action-btn--primary {
flex: 1;
background: rgba($brand-primary, 0.85);
color: #fff;
border-color: rgba($brand-primary, 0.5);
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.3);
background: linear-gradient(135deg, $navy-light 0%, $navy-deep 100%);
color: #FFFFFF;
box-shadow: 0 12rpx 32rpx rgba($navy-deep, 0.25);
}
.action-btn--primary .action-btn__icon {
color: $gold-light;
}
.bottom-spacer {

View File

@@ -23,16 +23,28 @@
</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
v-if="statusReminderText"
class="status-reminder-panel tn-margin-bottom-lg"
:class="{
'status-reminder-panel--warning': shouldPayDeposit(detail),
'status-reminder-panel--warning': shouldPayDeposit(detail) && !isWaitingButlerRoomAssignment(detail),
'status-reminder-panel--info': isWaitingRoomAssignment(detail)
}"
>
<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 class="status-reminder-panel__content">
<view class="status-reminder-panel__title">{{ $tr('当前进度提醒') }}</view>
@@ -116,7 +128,7 @@
:class="{ 'danger-btn--loading': canceling }"
@click.stop="handleCancelOrder"
>
<text class="tn-text-bold">{{ canceling ? $tr('取消中...') : $tr('取消订单') }}</text>
<text class="tn-text-bold">{{ canceling ? $tr('取消中...') : $tr('取消申请') }}</text>
</view>
<view
v-if="currentStepActionText"
@@ -145,7 +157,7 @@
</template>
<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 {
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) {
if (typeof value !== 'string') {
return value
@@ -233,6 +282,7 @@ export default {
watch: {
language() {
this.loadReservationFlow()
this.loadPendingDepositBillState()
}
},
computed: {
@@ -278,6 +328,42 @@ export default {
heroBadgeClass() {
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() {
if (!this.detail.start && !this.detail.end) {
return '--'
@@ -329,27 +415,39 @@ export default {
if (reservationStepText) {
return reservationStepText
}
if (this.isWaitingButlerRoomAssignment(this.detail)) {
return this.$tr('等待管家排房')
}
if (this.shouldPayDeposit(this.detail)) {
return this.$tr('请支付定金')
}
if (this.isWaitingRoomAssignment(this.detail)) {
return this.$tr('等待预订中心分配房间')
return this.$tr('等待管家排房')
}
return ''
},
canViewBill() {
return this.shouldPayDeposit(this.detail)
&& this.hasPendingDepositBill(this.detail)
&& !this.isViewingApplication
&& this.detail
&& this.detail.detailCode
&& 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() {
return this.shouldPayDeposit(this.detail)
&& !this.isViewingApplication
&& this.detail
&& this.detail.detailCode
&& !this.canceling
return this.canCancelApplication && !this.canceling
}
},
onLoad(options = {}) {
@@ -358,11 +456,39 @@ export default {
},
onShow() {
this.loadReservationFlow()
this.loadPendingDepositBillState()
},
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 = {}) {
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 = {}) {
return !item.isViewingApplication
&& Number(item.status) === 0
@@ -371,7 +497,7 @@ export default {
isWaitingRoomAssignment(item = {}) {
return !item.isViewingApplication
&& Number(item.status) === 0
&& Number(item.payStatus) === 10
&& (Number(item.payStatus) === 10 || this.isWaitingButlerRoomAssignment(item))
},
getReservationCurrentStepValue() {
return Number(this.detail && this.detail.reservationCurrentNode ? this.detail.reservationCurrentNode.step : 0)
@@ -491,6 +617,50 @@ export default {
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() {
if (!this.detail || this.detail.isViewingApplication) {
return
@@ -601,11 +771,16 @@ export default {
}
})
},
getCancelOrderContent() {
return this.isViewingApplication
? this.$tr('取消后将关闭当前预约看房申请,是否继续?')
: this.$tr('取消后将关闭当前申请单,是否继续?')
},
confirmCancelOrder() {
return new Promise((resolve) => {
uni.showModal({
title: this.$tr('取消订单'),
content: this.$tr('取消后将关闭当前未支付的定金订单,是否继续?'),
title: this.$tr('取消申请'),
content: this.getCancelOrderContent(),
confirmColor: '#ef4444',
success: (res) => resolve(!!(res && res.confirm)),
fail: () => resolve(false)
@@ -640,9 +815,7 @@ export default {
}
this.canceling = true
return userDetailCancel({
detailCode: this.detail.detailCode,
code: this.detail.detailCode,
referenceTypeNo: this.detail.detailCode
detailCode: this.detail.detailCode
}, this.language)
.then(() => {
this.detail = {
@@ -652,14 +825,14 @@ export default {
}
this.syncCanceledDetailToCache()
uni.showToast({
title: this.$tr('订单已取消'),
title: this.$tr('申请已取消'),
icon: 'success'
})
})
})
.catch((error) => {
uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('取消订单失败'),
title: (error && (error.message || error.msg)) || this.$tr('取消申请失败'),
icon: 'none'
})
})
@@ -777,6 +950,85 @@ export default {
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 {
width: 68rpx;
height: 68rpx;

View File

@@ -241,7 +241,7 @@ import {
getAccountBillRefundStatusLabel,
getAccountReferenceTypeLabel
} 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
@@ -730,7 +730,7 @@ export default {
getCancelButtonText(item = {}) {
return this.cancelingBillNo === this.getBillNo(item)
? this.$tr('取消中...')
: this.$tr('取消订单')
: this.$tr('取消申请')
},
resolveReferenceTypeNo(item = {}) {
return item.code || item.detailCode || ''
@@ -755,6 +755,19 @@ export default {
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()
if (cachedActiveCode) {
this.currentReferenceTypeNo = cachedActiveCode
@@ -1011,8 +1024,8 @@ export default {
confirmCancelOrder() {
return new Promise((resolve) => {
uni.showModal({
title: this.$tr('取消订单'),
content: this.$tr('取消后将关闭当前未支付订单,是否继续?'),
title: this.$tr('取消申请'),
content: this.$tr('取消后将关闭当前申请单,是否继续?'),
confirmColor: '#ef4444',
success: (res) => resolve(!!(res && res.confirm)),
fail: () => resolve(false)
@@ -1020,18 +1033,8 @@ export default {
})
},
buildCancelOrderPayload(item = {}) {
const pendingOrder = this.getPendingPaymentOrder(item) || {}
const billNo = this.getBillNo(item)
const paymentNo = pendingOrder.paymentNo || pendingOrder.payNo || pendingOrder.payRequestNo || ''
return {
detailCode: this.currentReferenceTypeNo,
code: this.currentReferenceTypeNo,
referenceTypeNo: this.currentReferenceTypeNo,
billNo,
BillNo: billNo,
paymentNo,
payNo: paymentNo,
payRequestNo: pendingOrder.payRequestNo || ''
detailCode: this.currentReferenceTypeNo
}
},
createPaymentSession(item = {}) {
@@ -1274,7 +1277,7 @@ export default {
return userDetailCancel(this.buildCancelOrderPayload(item), this.language)
.then(() => {
uni.showToast({
title: this.$tr('订单已取消'),
title: this.$tr('申请已取消'),
icon: 'success'
})
return this.refreshBillList()
@@ -1282,7 +1285,7 @@ export default {
})
.catch((error) => {
uni.showToast({
title: (error && (error.message || error.msg)) || this.$tr('取消订单失败'),
title: (error && (error.message || error.msg)) || this.$tr('取消申请失败'),
icon: 'none'
})
})

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -52,6 +52,15 @@
</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="info-row tn-flex tn-flex-row-between">
<text class="info-label">{{ $tr('所属公寓') }}</text>
@@ -154,6 +163,43 @@ function isViewingApplicationRecord(item = {}) {
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 = []) {
if (!Array.isArray(list)) {
return []
@@ -362,6 +408,36 @@ export default {
}
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 = {}) {
return item.detailCode || ''
},
@@ -555,6 +631,72 @@ export default {
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 {
background: rgba(245, 158, 11, 0.12);
color: #B45309;

View File

@@ -48,7 +48,7 @@
<view v-else-if="displayList.length" class="list-wrapper">
<view
v-for="(item, index) in displayList"
:key="item.no || item.contractNo || item.orderNo || index"
:key="getContractKey(item, index)"
class="contract-card"
:style="{ animationDelay: `${index * 0.08}s` }"
>
@@ -58,8 +58,8 @@
<text class="tn-icon-edit-write-fill"></text>
</view>
<view class="card-title-group">
<view class="card-title">{{ $tr('住宿合同') }}</view>
<view class="card-code">{{ item.no || item.contractNo || '--' }}</view>
<view class="card-title">{{ item.contractDisplayName || $tr('住宿合同') }}</view>
<view class="card-code">{{ $tr('合同编号:') }}{{ item.no || item.contractNo || '--' }}</view>
</view>
</view>
<view :class="['status-badge', `status-${resolveStatusType(item.approvalStatus)}`]">
@@ -88,10 +88,10 @@
<text class="info-label">{{ $tr('记录说明') }}</text>
<text class="info-value">{{ getStatusTip(item) }}</text>
</view>
<view v-if="item.signImageUrl" class="sign-row">
<text class="info-label">{{ $tr('我的签名') }}</text>
<view v-if="item.contentImageUrl" class="sign-row">
<text class="info-label">{{ $tr('合同图片') }}</text>
<view class="sign-preview">
<image :src="item.signImageUrl" mode="aspectFit"></image>
<image :src="item.contentImageUrl" mode="aspectFit" @click="previewContractImage(index)"></image>
</view>
</view>
</view>
@@ -100,7 +100,7 @@
<view
class="action-btn"
:class="{ 'action-btn--secondary': !shouldShowSignAction(item) }"
@click="handleContractAction(item)"
@click="handleContractAction(index)"
>
{{ resolveActionText(item) }}
</view>
@@ -121,8 +121,42 @@
<script>
import { addFilePrefix } from '@/utils/common.js'
import { getMyEffectiveContracts, getMyPendingSignContracts } from '@/api/index.js'
import { getSessionSnapshot } from '../../utils/app-state'
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 = {}) {
return {
@@ -190,6 +224,67 @@ export default {
const list = this.$store && this.$store.state && this.$store.state.vuex_effective_contracts
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() {
const storeUser = this.getStoreUser()
if (storeUser && Object.keys(storeUser).length) {
@@ -201,12 +296,23 @@ export default {
pendingContracts = [],
effectiveContracts = []
} = {}) {
this.pendingContracts = Array.isArray(pendingContracts)
const normalizedPendingContracts = Array.isArray(pendingContracts)
? pendingContracts.map(normalizeContract)
: []
this.effectiveContracts = Array.isArray(effectiveContracts)
const normalizedEffectiveContracts = Array.isArray(effectiveContracts)
? 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() {
if (!this.isCurrentLogin()) {
@@ -218,9 +324,15 @@ export default {
this.loading = true
return syncLoginStayState({ force: true })
.then((result = {}) => {
this.applyContractState({
const fallbackState = {
pendingContracts: result.pendingContracts,
effectiveContracts: result.effectiveContracts
}
const currentOrderNo = this.resolveCurrentOrderNo(result.currentOrder)
return this.fetchLatestContractsByOrderNo(currentOrderNo)
.then((latestState) => {
this.applyContractState(latestState || fallbackState)
})
})
.catch((error) => {
@@ -228,9 +340,15 @@ export default {
const storePendingContracts = this.getStorePendingContracts()
const storeEffectiveContracts = this.getStoreEffectiveContracts()
const session = getSessionSnapshot()
this.applyContractState({
const fallbackState = {
pendingContracts: storePendingContracts.length ? storePendingContracts : session.pendingContracts,
effectiveContracts: storeEffectiveContracts.length ? storeEffectiveContracts : session.effectiveContracts
}
const currentOrderNo = this.resolveCurrentOrderNo()
return this.fetchLatestContractsByOrderNo(currentOrderNo)
.then((latestState) => {
this.applyContractState(latestState || fallbackState)
})
})
.finally(() => {
@@ -265,6 +383,9 @@ export default {
formatDate(value) {
return value ? String(value).slice(0, 10) : '--'
},
getContractKey(item = {}, index = 0) {
return item.no || item.contractNo || item.orderNo || `contract-${index}`
},
formatDateRange(start, end) {
if (!start && !end) {
return '--'
@@ -290,15 +411,84 @@ export default {
if (this.shouldShowSignAction(item)) {
return this.$tr('去签约')
}
return this.$tr('查看详情')
return this.$tr('查看合同')
},
shouldShowSignAction(item = {}) {
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 orderNo = encodeURIComponent(item.orderNo || '')
const contentImageUrl = encodeURIComponent(item.contentImageUrl || '')
if (Number(item.approvalStatus) === 0 && !item.signImageUrl) {
uni.navigateTo({
@@ -307,9 +497,7 @@ export default {
return
}
uni.navigateTo({
url: `/pkgService/contract_detail/contract_detail?no=${contractNo}&orderNo=${orderNo}&contentImageUrl=${contentImageUrl}`
})
this.previewContractImage(item)
}
}
}

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>
</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="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('临时密码') }}
</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;">
<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 File

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

View File

@@ -1,23 +1,35 @@
<template>
<view class="date-range-selector">
<view class="date-item">
<view class="date-label">{{ resolvedStartLabel }}</view>
<picker mode="date" @change="onStartChange">
<view class="date-input" :class="{ 'is-empty': !start }">
{{ start || resolvedStartPlaceholder }}
<view class="date-range-selector tn-flex tn-flex-col-center">
<picker mode="date" @change="onStartChange" class="date-picker date-picker--start">
<view class="date-card" :class="{ 'is-empty': !start, 'is-active': start }">
<view class="date-card-header tn-flex tn-flex-row-between tn-flex-col-center">
<text class="date-label">{{ resolvedStartLabel }}</text>
<view class="date-icon-wrap">
<text class="tn-icon-calendar"></text>
</view>
</view>
<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 class="date-divider">{{ $tr('至') }}</view>
<view class="date-item">
<view class="date-label">{{ resolvedEndLabel }}</view>
<picker mode="date" @change="onEndChange">
<view class="date-input" :class="{ 'is-empty': !end }">
{{ end || resolvedEndPlaceholder }}
<picker mode="date" @change="onEndChange" class="date-picker date-picker--end">
<view class="date-card" :class="{ 'is-empty': !end, 'is-active': end }">
<view class="date-card-header tn-flex tn-flex-row-between tn-flex-col-center">
<text class="date-label">{{ resolvedEndLabel }}</text>
<view class="date-icon-wrap">
<text class="tn-icon-calendar"></text>
</view>
</view>
<view class="date-value">{{ end || resolvedEndPlaceholder }}</view>
</view>
</picker>
</view>
</view>
</template>
<script>
@@ -75,44 +87,92 @@ export default {
</script>
<style scoped lang="scss">
$text-primary: #1A2233;
$text-secondary: #6A7A99;
$text-tertiary: #A1B0C4;
.date-range-selector {
display: flex;
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);
width: 100%;
}
.date-item {
.date-picker {
flex: 1;
text-align: center;
min-width: 0;
}
.date-divider {
margin: 0 24rpx;
color: #94A3B8;
font-size: 24rpx;
font-weight: 500;
.date-card {
background: #F4F8FF;
border-radius: 20rpx;
padding: 24rpx;
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 {
font-size: 22rpx;
color: #64748B;
margin-bottom: 12rpx;
color: $text-secondary;
}
.date-input {
font-size: 28rpx;
color: #1A2B4C;
font-weight: 600;
.date-icon-wrap {
width: 40rpx;
height: 40rpx;
border-radius: 12rpx;
background: rgba($brand-primary, 0.1);
display: flex;
align-items: center;
justify-content: center;
&.is-empty {
color: #CBD5E1;
font-weight: normal;
text {
font-size: 22rpx;
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>

View File

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

View File

@@ -1,40 +1,53 @@
<template>
<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-label">{{ $tr('意向房型') }}</view>
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }">
{{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : $tr('请选择意向房型') }}
</view>
<view class="selected-area tn-text-ellipsis" v-if="selectedArea">
<text class="tn-icon-location area-icon"></text>
{{ getAreaDisplayName(selectedArea) }}
</view>
</view>
<text class="tn-icon-right tn-color-gray"></text>
<view class="selector-arrow">
<text class="tn-icon-right"></text>
</view>
</view>
<tn-popup v-model="showPopup" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">{{ $tr('选择意向房型') }}</text>
<view class="popup-title">{{ $tr('选择意向房型') }}</view>
<view class="popup-subtitle">{{ $tr('选择您心仪的房型与区域') }}</view>
</view>
<scroll-view scroll-y class="area-list-scroll">
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
<tn-loading mode="circle"></tn-loading>
<view class="tn-margin-top-sm tn-color-gray">{{ $tr('房型加载中...') }}</view>
<tn-loading mode="circle" :size="48"></tn-loading>
<view class="tn-margin-top tn-color-gray">{{ $tr('房型加载中...') }}</view>
</view>
<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 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="tn-flex tn-flex-col-center">
<text class="area-dot"></text>
<text>{{ getAreaDisplayName(area) }}</text>
<view class="area-title-left tn-flex tn-flex-col-center">
<view class="area-icon-wrap">
<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>
<text :class="collapsedAreas[index] ? 'tn-icon-down' : 'tn-icon-up'" class="tn-color-gray"></text>
</view>
<view class="room-type-grid" v-show="!collapsedAreas[index]">
<view
@@ -45,7 +58,15 @@
@click="handleSelect(area, room)"
>
<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>
@@ -145,7 +166,7 @@ export default {
})
.catch((e) => {
console.error('load room types error', e)
uni.showToast({ title: this.$tr('房型列表加载失败'), icon: 'none' })
uni.showToast({ title: this.$tr('房型加载失败'), icon: 'none' })
})
.finally(() => {
this.isLoading = false
@@ -227,17 +248,30 @@ export default {
</script>
<style scoped lang="scss">
$luxury-gold: #D8B56A;
$text-primary: #1A2233;
$text-secondary: #6A7A99;
$text-tertiary: #A1B0C4;
$surface: #FFFFFF;
$bg-popup: #F8FAFF;
.room-type-selector {
width: 100%;
}
.selector-header {
background: #FFFFFF;
.selector-card {
background: $surface;
border-radius: 20rpx;
padding: 26rpx 32rpx;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
min-height: 100rpx;
padding: 26rpx 30rpx;
border: 2rpx solid rgba($brand-primary, 0.12);
box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.05);
min-height: 120rpx;
transition: all 0.25s ease;
}
.selector-card:active {
background: #FAFCFF;
border-color: rgba($brand-primary, 0.25);
}
.selected-info {
@@ -245,72 +279,175 @@ export default {
overflow: hidden;
}
.selected-label {
font-size: 22rpx;
color: $text-tertiary;
margin-bottom: 8rpx;
}
.selected-name {
font-size: 28rpx;
color: #1A2233;
font-weight: 600;
font-size: 30rpx;
color: $text-primary;
font-weight: 700;
&.is-empty {
color: #A0B0C0;
color: $text-tertiary;
font-weight: normal;
}
}
.selected-area {
font-size: 24rpx;
color: $text-secondary;
margin-top: 10rpx;
display: flex;
align-items: center;
}
.area-icon {
font-size: 22rpx;
color: #6A7A99;
margin-top: 6rpx;
margin-right: 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 {
display: flex;
flex-direction: column;
height: 100%;
background: #F8FAFF;
background: $bg-popup;
}
.popup-header {
padding: 40rpx 30rpx 20rpx;
padding: 40rpx 30rpx 28rpx;
text-align: center;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
background: #FFFFFF;
background: $surface;
border-bottom: 1rpx solid rgba($brand-primary, 0.08);
}
.popup-title {
font-size: 32rpx;
font-size: 34rpx;
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 {
flex: 1;
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 {
margin-bottom: 30rpx;
background: #FFFFFF;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.04);
border: 1rpx solid rgba($brand-primary, 0.05);
margin-bottom: 28rpx;
background: $surface;
border-radius: 24rpx;
padding: 28rpx;
box-shadow: 0 8rpx 28rpx rgba(26, 34, 51, 0.04);
border: 1rpx solid rgba($brand-primary, 0.06);
}
.area-title {
font-size: 28rpx;
font-weight: 600;
color: #1A2233;
margin-bottom: 20rpx;
margin-bottom: 24rpx;
}
.area-dot {
width: 12rpx;
height: 12rpx;
.area-title-left {
flex: 1;
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%;
background: $brand-primary;
margin-right: 12rpx;
box-shadow: 0 0 8rpx rgba($brand-primary, 0.4);
background: #F4F8FF;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
text {
font-size: 24rpx;
color: $text-secondary;
}
}
.room-type-grid {
@@ -320,36 +457,86 @@ export default {
}
.room-type-item {
padding: 20rpx;
border-radius: 16rpx;
background: #FFFFFF;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
transition: all 0.3s ease;
position: relative;
padding: 24rpx 20rpx;
border-radius: 20rpx;
background: #FAFCFF;
border: 2rpx solid rgba($brand-primary, 0.1);
transition: all 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
&.is-active {
background: #FFFFFF;
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 {
color: $brand-primary;
}
.room-price {
color: $brand-primary;
}
.active-mark {
transform: scale(1);
opacity: 1;
}
}
}
.room-name {
font-size: 26rpx;
font-weight: 600;
color: #1A2233;
margin-bottom: 8rpx;
font-size: 28rpx;
font-weight: 700;
color: $text-primary;
margin-bottom: 10rpx;
}
.room-price {
font-size: 24rpx;
color: $text-secondary;
display: flex;
align-items: baseline;
&--empty {
font-size: 22rpx;
color: #6A7A99;
color: $text-tertiary;
}
}
.price-symbol {
font-size: 22rpx;
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>

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -181,7 +181,8 @@
:formData="uploadFormData"
:max-count="6"
:max-size="5 * 1024 * 1024"
uploadText=""
:uploadText="''"
:customBtn="true"
@on-success="onUploadSuccess"
@on-list-change="onPhotoListChange"
@on-remove="onRemoveImage"
@@ -426,6 +427,25 @@ function pickReadableValue(...values) {
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) {
if (typeof value !== 'string') {
return value || fallback
@@ -532,18 +552,20 @@ export default {
return 'rgba(255, 255, 255, 0.96)'
},
navBarBackStyle() {
const isDarkBg = this.scrollTop < 40
return {
transition: 'all 0.2s ease',
background: isDarkBg ? 'rgba(0, 0, 0, 0.2)' : 'transparent',
borderRadius: '50%',
width: '64rpx',
height: '64rpx',
background: this.scrollTop < 40 ? 'rgba(255, 255, 255, 0.82)' : 'rgba(255, 255, 255, 0.96)',
border: '1rpx solid rgba(43, 142, 255, 0.08)',
boxShadow: '0 10rpx 28rpx rgba(31, 76, 145, 0.10)',
backdropFilter: 'blur(18rpx)',
borderRadius: '20rpx',
width: '72rpx',
height: '72rpx',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: '12rpx',
color: isDarkBg ? '#FFFFFF' : '#000000'
color: '#1A2233'
}
},
navTitleOpacity() {
@@ -573,7 +595,12 @@ export default {
) || '--'
},
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() {
const start = String(this.currentOrderDetail.startDate || this.currentOrderDetail.start || '').slice(0, 10)

View File

@@ -8,6 +8,7 @@
<current-application-bill-panel
ref="billPanel"
:reference-type-no="referenceTypeNo"
:auto-resolve-reference="!pendingBillNo || !!referenceTypeNo"
@reference-resolved="handleReferenceResolved"
@all-paid="handleAllPaid"
/>
@@ -16,6 +17,7 @@
</template>
<script>
import { accountQueryBill } from '@/api/index.js'
import { fetchEnums } from '@/utils/enum-store.js'
import { saveActiveApplicationCode, saveHomePageTabId } from '@/utils/app-state'
import CurrentApplicationBillPanel from '../components/CurrentApplicationBillPanel.vue'
@@ -26,17 +28,54 @@ export default {
},
data() {
return {
referenceTypeNo: ''
referenceTypeNo: '',
pendingBillNo: ''
}
},
async onLoad(options) {
await fetchEnums(false, this.$getLanguage())
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) {
saveActiveApplicationCode(this.referenceTypeNo)
}
},
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) {
this.referenceTypeNo = referenceTypeNo || ''
if (this.referenceTypeNo) {

View File

@@ -64,7 +64,8 @@ function logRequestError(name, error) {
}
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) {
const value = candidates[index]
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))
: Promise.resolve([]),
currentOrderNo
? getMyEffectiveContracts({ OrderNo: currentOrderNo }, language).catch((error) => logRequestError('getMyEffectiveContracts', error))
? getMyEffectiveContracts({ OrderNo: currentOrderNo, ProviderType: 'WechatMiniProgram' }, language).catch((error) => logRequestError('getMyEffectiveContracts', error))
: Promise.resolve([])
])
}).then(([validOrders, currentOrder, pendingContractsResponse, effectiveContractsResponse]) => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -144,22 +144,23 @@ export function formatOrderDate(value) {
}
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)) {
return rawStatus
}
const contractStatus = Number(order.contractStatus)
const contractStatus = Number(safeOrder.contractStatus)
if (contractStatus === 1) {
return 'contract_signed'
}
const approvalStatus = Number(
order.approvalStatus !== undefined
? order.approvalStatus
: order.auditStatus !== undefined
? order.auditStatus
: order.statusCode
safeOrder.approvalStatus !== undefined
? safeOrder.approvalStatus
: safeOrder.auditStatus !== undefined
? safeOrder.auditStatus
: safeOrder.statusCode
)
if (approvalStatus === -1 || /reject|refuse|驳回/i.test(rawStatus)) {
@@ -169,12 +170,12 @@ function normalizeStatusKey(order = {}) {
return 'pending_review'
}
if (
order.roomNo ||
order.roomCode ||
order.roomName ||
order.checkinDate ||
order.startDate ||
order.start
safeOrder.roomNo ||
safeOrder.roomCode ||
safeOrder.roomName ||
safeOrder.checkinDate ||
safeOrder.startDate ||
safeOrder.start
) {
return 'assigned_wait_room'
}
@@ -182,17 +183,18 @@ function normalizeStatusKey(order = {}) {
}
function resolveStatusText(order = {}, statusKey, language = getCurrentLanguage()) {
const safeOrder = order || {}
const contractStatus = Number(order.contractStatus)
if (contractStatus === 1 && order.contractStatusDesc && language !== 'en') {
return order.contractStatusDesc
const contractStatus = Number(safeOrder.contractStatus)
if (contractStatus === 1 && safeOrder.contractStatusDesc && language !== 'en') {
return safeOrder.contractStatusDesc
}
if (language !== 'en' && order.approvalStatusDesc) {
return order.approvalStatusDesc
if (language !== 'en' && safeOrder.approvalStatusDesc) {
return safeOrder.approvalStatusDesc
}
if (language !== 'en' && order.statusDesc) {
return order.statusDesc
if (language !== 'en' && safeOrder.statusDesc) {
return safeOrder.statusDesc
}
const map = {
pending_review: getBookingText('pendingReview', language),
@@ -218,35 +220,39 @@ function resolveTagType(statusKey) {
}
function getBuildingFloorText(order = {}, language = getCurrentLanguage()) {
const safeOrder = order || {}
const isEn = language === 'en'
const building = [
isEn
? (order.buildingNameEn || order.buildingEn || order.buildingSectionNameEn || order.buildingName || order.building || order.buildingSectionName || '')
: (order.buildingName || order.building || order.buildingSectionName || ''),
? (safeOrder.buildingNameEn || safeOrder.buildingEn || safeOrder.buildingSectionNameEn || safeOrder.buildingName || safeOrder.building || safeOrder.buildingSectionName || '')
: (safeOrder.buildingName || safeOrder.building || safeOrder.buildingSectionName || ''),
isEn
? (order.floorNameEn || order.floorEn || order.floorName || order.floor || '')
: (order.floorName || order.floor || '')
? (safeOrder.floorNameEn || safeOrder.floorEn || safeOrder.floorName || safeOrder.floor || '')
: (safeOrder.floorName || safeOrder.floor || '')
].filter(Boolean).join(' ')
return building || '--'
}
function getRoomTypeText(order = {}, language = getCurrentLanguage()) {
const safeOrder = order || {}
const isEn = language === 'en'
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 = {}) {
return order.orderNo || order.no || order.code || '--'
const safeOrder = order || {}
return safeOrder.orderNo || safeOrder.no || safeOrder.code || '--'
}
export function getOrderStatusCode(order = {}) {
const safeOrder = order || {}
const candidates = [
order.orderStatus,
order.statusCode,
order.status
safeOrder.orderStatus,
safeOrder.statusCode,
safeOrder.status
]
for (let index = 0; index < candidates.length; index += 1) {
@@ -264,14 +270,15 @@ export function getOrderStatusCode(order = {}) {
}
export function getOrderTime(order = {}) {
const checkInPeriod = order.checkInPeriod || {}
const safeOrder = order || {}
const checkInPeriod = safeOrder.checkInPeriod || {}
const candidates = [
order.updateTime,
order.createTime,
order.createdAt,
order.startDate,
order.start,
order.checkinDate,
safeOrder.updateTime,
safeOrder.createTime,
safeOrder.createdAt,
safeOrder.startDate,
safeOrder.start,
safeOrder.checkinDate,
checkInPeriod.start
]
@@ -287,13 +294,14 @@ export function sortOrdersByLatest(orderList = []) {
}
function hasStayFields(order = {}) {
const safeOrder = order || {}
return !!(
order.roomNo
|| order.roomCode
|| order.slotCode
|| order.contractNo
|| order.actualCheckInTime
|| order.checkinTime
safeOrder.roomNo
|| safeOrder.roomCode
|| safeOrder.slotCode
|| safeOrder.contractNo
|| safeOrder.actualCheckInTime
|| safeOrder.checkinTime
)
}
@@ -336,32 +344,33 @@ export function isCurrentBookingOrder(order = {}) {
}
export function normalizeBookingOrder(order = {}, language = getCurrentLanguage()) {
const statusKey = normalizeStatusKey(order)
const statusCode = getOrderStatusCode(order)
const checkInPeriod = order.checkInPeriod || {}
const startDate = order.startDate || order.start || order.checkinDate || checkInPeriod.start
const endDate = order.endDate || order.end || order.checkoutDate || checkInPeriod.end
const safeOrder = order || {}
const statusKey = normalizeStatusKey(safeOrder)
const statusCode = getOrderStatusCode(safeOrder)
const checkInPeriod = safeOrder.checkInPeriod || {}
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 {
...order,
orderNo: getOrderNo(order),
roomTypeText: getRoomTypeText(order, language),
buildingFloorText: getBuildingFloorText(order, language),
...safeOrder,
orderNo: getOrderNo(safeOrder),
roomTypeText: getRoomTypeText(safeOrder, language),
buildingFloorText: getBuildingFloorText(safeOrder, language),
startDateText: formatOrderDate(startDate),
endDateText: formatOrderDate(endDate),
createdAtText: formatOrderDateTime(order.createTime || order.updateTime || order.createdAt),
createdAtText: formatOrderDateTime(safeOrder.createTime || safeOrder.updateTime || safeOrder.createdAt),
roomNoText: language === 'en'
? (order.roomNoEn || order.roomCodeEn || order.roomNameEn || order.roomNo || order.roomCode || order.roomName || '--')
: (order.roomNo || order.roomCode || order.roomName || order.roomNoEn || order.roomCodeEn || order.roomNameEn || '--'),
? (safeOrder.roomNoEn || safeOrder.roomCodeEn || safeOrder.roomNameEn || safeOrder.roomNo || safeOrder.roomCode || safeOrder.roomName || '--')
: (safeOrder.roomNo || safeOrder.roomCode || safeOrder.roomName || safeOrder.roomNoEn || safeOrder.roomCodeEn || safeOrder.roomNameEn || '--'),
areaText: language === 'en'
? (order.areaNameEn || order.campusNameEn || order.areaName || order.campusName || '--')
: (order.areaName || order.campusName || '--'),
rejectReasonText: order.rejectReason || order.approveReason || order.remark || '',
? (safeOrder.areaNameEn || safeOrder.campusNameEn || safeOrder.areaName || safeOrder.campusName || '--')
: (safeOrder.areaName || safeOrder.campusName || '--'),
rejectReasonText: safeOrder.rejectReason || safeOrder.approveReason || safeOrder.remark || '',
startDate,
endDate,
status: statusKey,
statusCode,
statusKey,
statusText: resolveStatusText(order, statusKey, language),
statusText: resolveStatusText(safeOrder, statusKey, language),
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'))
}
const userCode = responseData.userCode
|| responseData.code
|| nestedData.userCode
|| nestedData.code
|| response.userCode
|| response.code
|| ''
return {
token,
expires
expires,
userCode
}
}
@@ -98,11 +107,13 @@ export function buildLoginSessionUser(options = {}) {
const account = source.account || ''
const password = source.password || ''
const language = source.language || 'zh'
const userCode = source.userCode || ''
if (latestRegistration) {
return buildUserFromRegistrationApplication(latestRegistration, {
password,
language
language,
userCode
})
}
@@ -113,7 +124,8 @@ export function buildLoginSessionUser(options = {}) {
return normalizeUser({
account,
username: account,
password
password,
userCode
})
}
@@ -124,6 +136,7 @@ export function resolveLoginPreparation(options = {}) {
const latestRegistration = source.latestRegistration || null
const account = source.account || ''
const password = source.password || ''
const userCode = source.userCode || ''
if (!latestRegistration) {
return {
@@ -132,7 +145,8 @@ export function resolveLoginPreparation(options = {}) {
matchedLocalUser,
account,
password,
language
language,
userCode
})
}
}
@@ -164,7 +178,8 @@ export function resolveLoginPreparation(options = {}) {
matchedLocalUser,
account,
password,
language
language,
userCode
})
}
}

View File

@@ -1,8 +1,9 @@
const RESERVATION_FLOW_STEP = {
INSPECTION: 10,
BILL: 20,
CONTRACT: 30,
CHECKIN: 40
APPLICATION: 1,
INSPECTION: 2,
CONTRACT: 3,
BILL: 4,
CHECKIN: 5
}
function getLanguage(language) {
@@ -89,7 +90,87 @@ export function getReservationCurrentNode(flow = {}) {
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 = []) {
@@ -131,7 +212,7 @@ export function getReservationStepMeta(step, language = 'zh') {
},
[RESERVATION_FLOW_STEP.BILL]: {
label: currentLanguage === 'en' ? 'Bill' : '账单',
actionText: currentLanguage === 'en' ? 'Go to Bill' : '去缴费'
actionText: currentLanguage === 'en' ? 'Go to Payment' : '去支付'
},
[RESERVATION_FLOW_STEP.CONTRACT]: {
label: currentLanguage === 'en' ? 'Contract' : '合同',

View File

@@ -22,6 +22,11 @@ const BANNER_TEXT_FALLBACK_MAP = {
}
const VALUE_TEXT_FALLBACK_MAP = {
apartment: {
'绿居·和山国际公寓': 'Luju Heshan Intl. Apartments',
'和山国际公寓': 'Heshan Intl. Apartments',
'乾晖里': 'Qianhui Residence'
},
tags: {
'押一付一': 'Monthly Deposit',
'大型超市': 'Large Supermarket',
@@ -148,6 +153,10 @@ export function localizeHousingTag(tag = '', language = getCurrentLanguage()) {
return localizeKnownValue('tags', tag, language)
}
export function localizeApartmentName(name = '', language = getCurrentLanguage()) {
return localizeKnownValue('apartment', name, language)
}
export function localizePayMethodText(payMethod = '', language = getCurrentLanguage()) {
return localizeKnownValue('payMethod', payMethod, language)
}
@@ -330,7 +339,19 @@ export function normalizeBannerItem(item = {}, index = 0) {
export function normalizeImageList(item = {}) {
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) => {
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)
if (normalized) {
candidates.push(normalized)

View File

@@ -113,7 +113,8 @@ function pickFirstValue(...values) {
}
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) {
return null
}
@@ -124,11 +125,13 @@ function buildPendingContractQuery(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) {
return null
}
return {
ProviderType: 'WechatMiniProgram',
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)
}