feat: 优化首页服务入口与租住业务流程

补齐首页待办跳转、押金支付引导和多类租住服务页面能力,统一租客端关键业务流程体验。
This commit is contained in:
liangjian
2026-08-25 13:53:12 +08:00
parent 1111ac148b
commit 9fee2c3a74
41 changed files with 6985 additions and 933 deletions

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

@@ -348,8 +348,8 @@ export function normalizeBookingOrder(order = {}, language = getCurrentLanguage(
const statusKey = normalizeStatusKey(safeOrder)
const statusCode = getOrderStatusCode(safeOrder)
const checkInPeriod = safeOrder.checkInPeriod || {}
const startDate = safeOrder.startDate || safeOrder.start || safeOrder.checkinDate || checkInPeriod.start
const endDate = safeOrder.endDate || safeOrder.end || safeOrder.checkoutDate || checkInPeriod.end
const startDate = safeOrder.contractStartTime || safeOrder.startDate || safeOrder.start || safeOrder.checkinDate || checkInPeriod.start
const endDate = safeOrder.contractEndTime || safeOrder.endDate || safeOrder.end || safeOrder.checkoutDate || checkInPeriod.end
return {
...safeOrder,
orderNo: getOrderNo(safeOrder),

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

@@ -1,8 +1,8 @@
const RESERVATION_FLOW_STEP = {
APPLICATION: 1,
INSPECTION: 2,
BILL: 3,
CONTRACT: 4,
CONTRACT: 3,
BILL: 4,
CHECKIN: 5
}
@@ -90,7 +90,70 @@ 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 = {}) {
@@ -149,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

@@ -131,6 +131,7 @@ function buildEffectiveContractQuery(order = {}) {
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)
}