chore: initial import

This commit is contained in:
liangjian
2026-06-29 09:42:53 +08:00
commit 9fea127de1
241 changed files with 82767 additions and 0 deletions

20
utils/account-api.js Normal file
View File

@@ -0,0 +1,20 @@
export {
applicationFormMePage,
checkRecordByChecked,
contractPreview,
contractSign,
getCountryList,
getEnums,
getMyEffectiveContracts,
getMyPendingSignContracts,
notificationPage,
notificationRead,
orderSearchedMePage,
registrationApplicationMePage,
submitApplicationForm,
submitRegistrationApplication,
sysdictPage,
userMe,
uploadCommonFile,
usersLogin
} from '../api/index'

431
utils/app-state.js Normal file
View File

@@ -0,0 +1,431 @@
import store from '@/store'
const STORAGE_KEYS = {
REGISTERED_USER: 'registeredUser',
AUTH_TOKEN: 'authToken',
AUTH_TOKEN_EXPIRES: 'authTokenExpires',
IS_LOGIN: 'isLogin',
USER_INFO: 'userInfo',
HAS_CHECKED_IN: 'hasCheckedIn',
MY_ROOM_DATA: 'myRoomData',
CURRENT_BOOKING: 'currentBooking',
BOOKING_LIST: 'bookingList',
PENDING_CONTRACTS: 'pendingContracts',
PENDING_CONTRACT_COUNT: 'pendingContractCount',
EFFECTIVE_CONTRACTS: 'effectiveContracts',
MY_APPLICATION: 'myApplication',
ACTIVE_APPLICATION_CODE: 'activeApplicationCode',
FAILED_REGISTRATION_DRAFT: 'failedRegistrationDraft',
HOME_PAGE_TAB_ID: 'homePageTabId',
MESSAGE_LIST: 'messageList',
SERVICE_ORDER_LIST: 'serviceOrderList',
UTILITY_RECHARGE_LIST: 'utilityRechargeList',
LEGACY_DEMO_CACHE_CLEARED: 'legacyDemoCacheCleared'
}
function getStorage(key, fallback = null) {
const value = uni.getStorageSync(key)
return value === '' || typeof value === 'undefined' ? fallback : value
}
function getStoreState() {
return (store && store.state) || {}
}
function getStoreUser() {
const user = getStoreState().vuex_user
return user && typeof user === 'object' ? user : {}
}
function getStorePendingContracts() {
const list = getStoreState().vuex_pending_contracts
return Array.isArray(list) ? list : []
}
function getStoreEffectiveContracts() {
const list = getStoreState().vuex_effective_contracts
return Array.isArray(list) ? list : []
}
function getStoreValidOrders() {
const list = getStoreState().vuex_valid_orders
return Array.isArray(list) ? list : []
}
function getStoreCurrentOrder() {
return getStoreState().vuex_current_order || null
}
function buildRoomDataFromOrder(order = {}) {
if (!order || typeof order !== 'object' || !Object.keys(order).length) {
return null
}
const imageUrls = Array.isArray(order.imageUrls)
? order.imageUrls
: typeof order.imageUrls === 'string'
? order.imageUrls.split(',').map(item => item.trim()).filter(Boolean)
: []
const roomLabel = [
order.buildingName || order.buildingSectionName || order.unitName || '',
order.roomNo || order.roomCode || order.roomName || ''
].filter(Boolean).join(' ')
const title = [
order.areaName || order.campusName || '',
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)
return {
...order,
id: roomLabel || order.roomNo || order.roomCode || order.orderNo || '',
roomLabel: roomLabel || order.roomNo || order.roomCode || '',
titleZh: title || order.roomTypeName || order.roomName || '当前入住房间',
titleEn: order.roomTypeNameEn || title || order.roomTypeName || 'Current Stay',
leaseText: [start, end].filter(Boolean).join(' - '),
image: order.imageUrl || imageUrls[0] || ''
}
}
function clearStoreSessionState() {
if (!store || !store.commit) {
return
}
store.commit('$tStore', { name: 'vuex_user', value: {} })
store.commit('$tStore', { name: 'vuex_checkin_status', value: false })
store.commit('$tStore', { name: 'vuex_current_order', value: null })
store.commit('$tStore', { name: 'vuex_pending_contracts', value: [] })
store.commit('$tStore', { name: 'vuex_pending_contract_count', value: 0 })
store.commit('$tStore', { name: 'vuex_effective_contracts', value: [] })
store.commit('$tStore', { name: 'vuex_valid_orders', value: [] })
}
export { STORAGE_KEYS }
export function getRegisteredUser() {
return getStorage(STORAGE_KEYS.REGISTERED_USER, null)
}
export function saveRegisteredUser(user) {
uni.setStorageSync(STORAGE_KEYS.REGISTERED_USER, user)
return user
}
export function getCurrentUser() {
return getStorage(STORAGE_KEYS.USER_INFO, null)
}
export function saveCurrentUser(user) {
uni.setStorageSync(STORAGE_KEYS.USER_INFO, user)
return user
}
export function getLoginStatus() {
return !!getStorage(STORAGE_KEYS.IS_LOGIN, false)
}
export function setLoginStatus(isLogin) {
uni.setStorageSync(STORAGE_KEYS.IS_LOGIN, !!isLogin)
return !!isLogin
}
export function getAuthToken() {
return getStorage(STORAGE_KEYS.AUTH_TOKEN, '')
}
export function saveAuthToken(token) {
uni.setStorageSync(STORAGE_KEYS.AUTH_TOKEN, token || '')
return token || ''
}
export function clearAuthToken() {
uni.removeStorageSync(STORAGE_KEYS.AUTH_TOKEN)
}
export function getAuthTokenExpires() {
return getStorage(STORAGE_KEYS.AUTH_TOKEN_EXPIRES, '')
}
export function saveAuthTokenExpires(expires) {
uni.setStorageSync(STORAGE_KEYS.AUTH_TOKEN_EXPIRES, expires || '')
return expires || ''
}
export function clearAuthTokenExpires() {
uni.removeStorageSync(STORAGE_KEYS.AUTH_TOKEN_EXPIRES)
}
export function startLoginSession(user) {
setLoginStatus(true)
saveCurrentUser(user)
if (store && store.commit) {
store.commit('$tStore', {
name: 'vuex_user',
value: user && typeof user === 'object' ? user : {}
})
}
return user
}
export function startAuthorizedLoginSession(user, token, expires) {
if (token) saveAuthToken(token)
if (expires) saveAuthTokenExpires(expires)
return startLoginSession(user)
}
export function registerAndLogin(user) {
saveRegisteredUser(user)
startLoginSession(user)
return user
}
export function clearLoginSession() {
clearAuthToken()
clearAuthTokenExpires()
uni.removeStorageSync('lifeData')
clearStoreSessionState()
clearBookingState()
clearContractState()
clearMyRoomData()
uni.removeStorageSync(STORAGE_KEYS.HAS_CHECKED_IN)
uni.removeStorageSync(STORAGE_KEYS.IS_LOGIN)
uni.removeStorageSync(STORAGE_KEYS.USER_INFO)
uni.removeStorageSync(STORAGE_KEYS.MY_APPLICATION)
uni.removeStorageSync(STORAGE_KEYS.ACTIVE_APPLICATION_CODE)
}
export function getCheckedInStatus() {
return !!getStorage(STORAGE_KEYS.HAS_CHECKED_IN, false)
}
export function setCheckedInStatus(hasCheckedIn) {
uni.setStorageSync(STORAGE_KEYS.HAS_CHECKED_IN, !!hasCheckedIn)
return !!hasCheckedIn
}
export function getMyRoomData() {
return getStorage(STORAGE_KEYS.MY_ROOM_DATA, null)
}
export function saveMyRoomData(roomData) {
uni.setStorageSync(STORAGE_KEYS.MY_ROOM_DATA, roomData)
return roomData
}
export function clearMyRoomData() {
uni.removeStorageSync(STORAGE_KEYS.MY_ROOM_DATA)
}
export function getCurrentBooking() {
return getStorage(STORAGE_KEYS.CURRENT_BOOKING, null)
}
export function saveCurrentBooking(booking) {
uni.setStorageSync(STORAGE_KEYS.CURRENT_BOOKING, booking)
return booking
}
export function getBookingList() {
return getStorage(STORAGE_KEYS.BOOKING_LIST, [])
}
export function saveBookingList(list) {
uni.setStorageSync(STORAGE_KEYS.BOOKING_LIST, list)
return list
}
export function clearBookingState() {
uni.removeStorageSync(STORAGE_KEYS.CURRENT_BOOKING)
uni.removeStorageSync(STORAGE_KEYS.BOOKING_LIST)
}
export function getPendingContracts() {
return getStorage(STORAGE_KEYS.PENDING_CONTRACTS, [])
}
export function savePendingContracts(list) {
uni.setStorageSync(STORAGE_KEYS.PENDING_CONTRACTS, list || [])
return list || []
}
export function getPendingContractCount() {
return Number(getStorage(STORAGE_KEYS.PENDING_CONTRACT_COUNT, 0) || 0)
}
export function savePendingContractCount(count) {
const nextCount = Number(count || 0)
uni.setStorageSync(STORAGE_KEYS.PENDING_CONTRACT_COUNT, nextCount)
return nextCount
}
export function getEffectiveContracts() {
return getStorage(STORAGE_KEYS.EFFECTIVE_CONTRACTS, [])
}
export function saveEffectiveContracts(list) {
uni.setStorageSync(STORAGE_KEYS.EFFECTIVE_CONTRACTS, list || [])
return list || []
}
function patchContractListSign(list = [], contractNo = '', signImageUrl = '') {
const targetNo = String(contractNo || '').trim()
const targetSignImageUrl = String(signImageUrl || '').trim()
if (!targetNo || !Array.isArray(list) || !list.length) {
return Array.isArray(list) ? list : []
}
return list.map(item => {
const currentNo = String((item && (item.no || item.contractNo)) || '').trim()
if (currentNo !== targetNo) {
return item
}
return {
...item,
signImageUrl: targetSignImageUrl || item.signImageUrl || ''
}
})
}
export function patchStoredContractSign(contractNo, signImageUrl) {
const nextPendingContracts = patchContractListSign(getPendingContracts(), contractNo, signImageUrl)
const nextEffectiveContracts = patchContractListSign(getEffectiveContracts(), contractNo, signImageUrl)
savePendingContracts(nextPendingContracts)
saveEffectiveContracts(nextEffectiveContracts)
return {
pendingContracts: nextPendingContracts,
effectiveContracts: nextEffectiveContracts
}
}
export function clearContractState() {
uni.removeStorageSync(STORAGE_KEYS.PENDING_CONTRACTS)
uni.removeStorageSync(STORAGE_KEYS.PENDING_CONTRACT_COUNT)
uni.removeStorageSync(STORAGE_KEYS.EFFECTIVE_CONTRACTS)
}
export function getListStorage(key) {
return getStorage(key, [])
}
export function saveListStorage(key, list) {
uni.setStorageSync(key, list)
return list
}
export function prependListStorage(key, item) {
const currentList = getListStorage(key)
const nextList = [item, ...currentList]
saveListStorage(key, nextList)
return nextList
}
export function getMyApplicationList() {
return getListStorage(STORAGE_KEYS.MY_APPLICATION)
}
export function saveMyApplicationList(list) {
return saveListStorage(STORAGE_KEYS.MY_APPLICATION, list || [])
}
export function getActiveApplicationCode() {
return getStorage(STORAGE_KEYS.ACTIVE_APPLICATION_CODE, '')
}
export function saveActiveApplicationCode(code) {
const nextCode = code || ''
uni.setStorageSync(STORAGE_KEYS.ACTIVE_APPLICATION_CODE, nextCode)
return nextCode
}
export function getFailedRegistrationDraft() {
return getStorage(STORAGE_KEYS.FAILED_REGISTRATION_DRAFT, null)
}
export function saveFailedRegistrationDraft(draft) {
const nextDraft = draft && typeof draft === 'object' ? draft : null
if (!nextDraft) {
uni.removeStorageSync(STORAGE_KEYS.FAILED_REGISTRATION_DRAFT)
return null
}
uni.setStorageSync(STORAGE_KEYS.FAILED_REGISTRATION_DRAFT, nextDraft)
return nextDraft
}
export function clearFailedRegistrationDraft() {
uni.removeStorageSync(STORAGE_KEYS.FAILED_REGISTRATION_DRAFT)
}
export function getHomePageTabId() {
return getStorage(STORAGE_KEYS.HOME_PAGE_TAB_ID, '')
}
export function saveHomePageTabId(tabId) {
const nextTabId = tabId || ''
uni.setStorageSync(STORAGE_KEYS.HOME_PAGE_TAB_ID, nextTabId)
return nextTabId
}
export function buildHomePageUrl(tabId) {
const nextTabId = tabId || getHomePageTabId()
return nextTabId
? `/pages/index/index?pageId=${encodeURIComponent(nextTabId)}`
: '/pages/index/index'
}
export function getSessionSnapshot() {
const storeUser = getStoreUser()
const storageCurrentUser = getCurrentUser() || {}
const registeredUser = getRegisteredUser() || {}
const currentUser = Object.keys(storeUser).length ? storeUser : storageCurrentUser
const resolvedUser = Object.keys(currentUser).length ? currentUser : registeredUser
const storeCurrentOrder = getStoreCurrentOrder()
const storeBookingList = getStoreValidOrders()
const storePendingContracts = getStorePendingContracts()
const storeEffectiveContracts = getStoreEffectiveContracts()
const currentBooking = resolvedUser.currentBooking || getCurrentBooking()
const pendingContractCount = storePendingContracts.length || getPendingContractCount()
const hasCheckedIn = !!(resolvedUser.hasCheckedIn || getStoreState().vuex_checkin_status || getCheckedInStatus())
const myRoomData = hasCheckedIn
? (getMyRoomData() || buildRoomDataFromOrder(storeCurrentOrder))
: null
const isLogin = getLoginStatus() || !!Object.keys(currentUser).length
return {
isLogin,
currentUser,
registeredUser,
resolvedUser,
hasCheckedIn: isLogin && hasCheckedIn,
currentBooking,
bookingList: storeBookingList.length ? storeBookingList : getBookingList(),
myRoomData,
pendingContracts: storePendingContracts.length ? storePendingContracts : getPendingContracts(),
pendingContractCount,
effectiveContracts: storeEffectiveContracts.length ? storeEffectiveContracts : getEffectiveContracts()
}
}
export function clearLegacyDemoCache() {
if (uni.getStorageSync(STORAGE_KEYS.LEGACY_DEMO_CACHE_CLEARED)) {
return
}
;[
STORAGE_KEYS.CURRENT_BOOKING,
STORAGE_KEYS.BOOKING_LIST,
STORAGE_KEYS.MY_APPLICATION,
STORAGE_KEYS.MESSAGE_LIST,
STORAGE_KEYS.SERVICE_ORDER_LIST,
STORAGE_KEYS.UTILITY_RECHARGE_LIST
].forEach(key => {
uni.removeStorageSync(key)
})
uni.setStorageSync(STORAGE_KEYS.LEGACY_DEMO_CACHE_CLEARED, true)
}

1
utils/banner-api.js Normal file
View File

@@ -0,0 +1 @@
export { bannerAll } from '../api/index'

112
utils/booking-guard.js Normal file
View File

@@ -0,0 +1,112 @@
import { getSessionSnapshot } from './app-state'
import { getBookingStatusText, getStoredUser } from './flow'
import { getCurrentLanguage } from './i18n'
const ACTIVE_BOOKING_STATUSES = ['pending_review', 'approved_wait_assign', 'assigned_wait_room']
function isZh(language) {
return language !== 'en'
}
function getGuardText(language = 'zh') {
const zh = isZh(language)
return {
loginTitle: zh ? '提示' : 'Notice',
loginContent: zh ? '您还未登录,请先登录或注册账号。' : 'Please sign in or register an account first.',
loginConfirmText: zh ? '去登录' : 'Sign In',
existingBookingTitle: zh ? '已有进行中的预订' : 'Existing Booking',
existingBookingContent: zh
? '您当前状态为“{status}”,请先查看预订详情。'
: 'Your current booking status is "{status}". Please review the booking detail first.',
viewBooking: zh ? '查看预订' : 'View Booking'
}
}
function buildQueryString(query = {}) {
return Object.keys(query).reduce((result, key) => {
const value = query[key]
if (value === '' || value === null || typeof value === 'undefined') {
return result
}
result.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
return result
}, []).join('&')
}
function getCurrentPageUrl() {
const pages = getCurrentPages()
if (!Array.isArray(pages) || !pages.length) {
return ''
}
const currentPage = pages[pages.length - 1] || {}
const route = currentPage.route || ''
const options = currentPage.options || (currentPage.$page && currentPage.$page.options) || {}
if (!route) {
return ''
}
const queryString = buildQueryString(options)
return `/${route}${queryString ? `?${queryString}` : ''}`
}
export function ensureBookingAccess(options = {}) {
const language = options.language || getCurrentLanguage()
const guardText = getGuardText(language)
const session = getSessionSnapshot()
const sessionUser = session.resolvedUser && Object.keys(session.resolvedUser).length
? session.resolvedUser
: {}
const optionUser = options.userInfo && Object.keys(options.userInfo).length
? options.userInfo
: null
const userInfo = optionUser
? {
...sessionUser,
...optionUser
}
: (Object.keys(sessionUser).length ? sessionUser : getStoredUser())
if (!session.isLogin) {
uni.showModal({
title: options.loginTitle || guardText.loginTitle,
content: options.loginContent || guardText.loginContent,
confirmText: options.loginConfirmText || guardText.loginConfirmText,
success: (res) => {
if (res.confirm) {
const redirectUrl = options.loginRedirectUrl || getCurrentPageUrl()
const query = [`bypass=1`]
if (redirectUrl) {
query.push(`redirect=${encodeURIComponent(redirectUrl)}`)
}
uni.navigateTo({ url: `/pkgAccount/login/login?${query.join('&')}` })
}
}
})
return false
}
if (ACTIVE_BOOKING_STATUSES.includes(userInfo.bookingStatus)) {
const statusText = getBookingStatusText(userInfo.bookingStatus, language)
uni.showModal({
title: guardText.existingBookingTitle,
content: guardText.existingBookingContent.replace('{status}', statusText),
confirmText: guardText.viewBooking,
success: (res) => {
if (res.confirm) {
const orderNo = encodeURIComponent(
userInfo.currentBooking && userInfo.currentBooking.orderNo
? userInfo.currentBooking.orderNo
: ''
)
uni.navigateTo({
url: orderNo
? `/pkgBooking/booking_detail/booking_detail?orderNo=${orderNo}`
: '/pkgBooking/booking_detail/booking_detail'
})
}
}
})
return false
}
return true
}

386
utils/booking-helper.js Normal file
View File

@@ -0,0 +1,386 @@
import { getCurrentLanguage } from './i18n'
function getBookingText(key = '', language = getCurrentLanguage()) {
const isEn = language === 'en'
const map = {
pendingReview: isEn ? 'Pending Review' : '审核中',
approved: isEn ? 'Approved' : '已通过',
waitingCheckin: isEn ? 'Waiting Check-in' : '待入住',
rejected: isEn ? 'Rejected' : '已驳回',
processing: isEn ? 'Processing' : '处理中',
contractSigned: isEn ? 'Signed' : '已签署'
}
return map[key] || ''
}
function parseJsonStringMaybe(value) {
if (typeof value !== 'string') {
return value
}
const trimmed = value.trim()
if (!trimmed) {
return value
}
const looksLikeJson = (
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|| (trimmed.startsWith('[') && trimmed.endsWith(']'))
)
if (!looksLikeJson) {
return value
}
try {
return JSON.parse(trimmed)
} catch (error) {
console.warn('[orderSearchedMePage] JSON parse failed:', error)
return value
}
}
function parseResponsePayload(response) {
const basePayload = response && response.data ? response.data : response
return parseJsonStringMaybe(basePayload)
}
function collectObjectArray(value, depth = 0) {
if (!value || depth > 6) {
return []
}
const normalizedValue = parseJsonStringMaybe(value)
if (Array.isArray(normalizedValue)) {
if (normalizedValue.some(item => item && typeof item === 'object' && !Array.isArray(item))) {
return normalizedValue
}
for (let index = 0; index < normalizedValue.length; index += 1) {
const nestedList = collectObjectArray(normalizedValue[index], depth + 1)
if (nestedList.length) {
return nestedList
}
}
return []
}
if (typeof normalizedValue !== 'object') {
return []
}
const candidateKeys = [
'data',
'list',
'rows',
'items',
'records',
'result',
'payload',
'$values'
]
for (let index = 0; index < candidateKeys.length; index += 1) {
const key = candidateKeys[index]
if (!Object.prototype.hasOwnProperty.call(normalizedValue, key)) {
continue
}
const nestedList = collectObjectArray(normalizedValue[key], depth + 1)
if (nestedList.length) {
return nestedList
}
}
return []
}
export function parseOrderListResponse(response) {
const payload = parseResponsePayload(response)
const orderList = collectObjectArray(payload)
if (!orderList.length) {
console.warn('[orderSearchedMePage] unsupported response structure:', payload)
} else {
console.log('[orderSearchedMePage] parsed order count:', orderList.length)
}
return orderList
}
export function parseOrderDetailResponse(response) {
const payload = parseResponsePayload(response)
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload
}
const orderList = collectObjectArray(payload)
return Array.isArray(orderList) && orderList.length ? orderList[0] : {}
}
export function formatOrderDateTime(value) {
if (!value) {
return '--'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return String(value).replace('T', ' ').slice(0, 16) || '--'
}
const pad = item => String(item).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
export function formatOrderDate(value) {
if (!value) {
return '--'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return String(value).slice(0, 10) || '--'
}
const pad = item => String(item).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function normalizeStatusKey(order = {}) {
const rawStatus = String(order.status || order.orderStatus || '').trim()
if (['pending_review', 'approved_wait_assign', 'assigned_wait_room', 'rejected', 'contract_signed'].includes(rawStatus)) {
return rawStatus
}
const contractStatus = Number(order.contractStatus)
if (contractStatus === 1) {
return 'contract_signed'
}
const approvalStatus = Number(
order.approvalStatus !== undefined
? order.approvalStatus
: order.auditStatus !== undefined
? order.auditStatus
: order.statusCode
)
if (approvalStatus === -1 || /reject|refuse|驳回/i.test(rawStatus)) {
return 'rejected'
}
if (approvalStatus === 0 || /pending|review|审核中|待审批/i.test(rawStatus)) {
return 'pending_review'
}
if (
order.roomNo ||
order.roomCode ||
order.roomName ||
order.checkinDate ||
order.startDate ||
order.start
) {
return 'assigned_wait_room'
}
return 'approved_wait_assign'
}
function resolveStatusText(order = {}, statusKey, language = getCurrentLanguage()) {
const contractStatus = Number(order.contractStatus)
if (contractStatus === 1 && order.contractStatusDesc && language !== 'en') {
return order.contractStatusDesc
}
if (language !== 'en' && order.approvalStatusDesc) {
return order.approvalStatusDesc
}
if (language !== 'en' && order.statusDesc) {
return order.statusDesc
}
const map = {
pending_review: getBookingText('pendingReview', language),
approved_wait_assign: getBookingText('approved', language),
assigned_wait_room: getBookingText('waitingCheckin', language),
rejected: getBookingText('rejected', language),
contract_signed: getBookingText('contractSigned', language)
}
return map[statusKey] || getBookingText('processing', language)
}
function resolveTagType(statusKey) {
if (statusKey === 'rejected') {
return 'rejected'
}
if (statusKey === 'pending_review') {
return 'pending'
}
if (statusKey === 'contract_signed') {
return 'approved'
}
return 'approved'
}
function getBuildingFloorText(order = {}, language = getCurrentLanguage()) {
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 || ''),
isEn
? (order.floorNameEn || order.floorEn || order.floorName || order.floor || '')
: (order.floorName || order.floor || '')
].filter(Boolean).join(' ')
return building || '--'
}
function getRoomTypeText(order = {}, language = getCurrentLanguage()) {
const isEn = language === 'en'
if (isEn) {
return order.roomTypeNameEn || order.styleNameEn || order.roomNameEn || order.roomTypeName || order.styleNameZh || order.roomType || order.roomName || '--'
}
return order.roomTypeName || order.styleNameZh || order.roomType || order.roomName || '--'
}
function getOrderNo(order = {}) {
return order.orderNo || order.no || order.code || '--'
}
export function getOrderStatusCode(order = {}) {
const candidates = [
order.orderStatus,
order.statusCode,
order.status
]
for (let index = 0; index < candidates.length; index += 1) {
const value = candidates[index]
if (value === '' || value === null || value === undefined) {
continue
}
const numericValue = Number(value)
if (Number.isFinite(numericValue)) {
return numericValue
}
}
return null
}
export function getOrderTime(order = {}) {
const checkInPeriod = order.checkInPeriod || {}
const candidates = [
order.updateTime,
order.createTime,
order.createdAt,
order.startDate,
order.start,
order.checkinDate,
checkInPeriod.start
]
const timestamp = candidates
.map(value => new Date(value || 0).getTime())
.find(value => Number.isFinite(value) && value > 0)
return timestamp || 0
}
export function sortOrdersByLatest(orderList = []) {
return [...orderList].sort((prev, next) => getOrderTime(next) - getOrderTime(prev))
}
function hasStayFields(order = {}) {
return !!(
order.roomNo
|| order.roomCode
|| order.slotCode
|| order.contractNo
|| order.actualCheckInTime
|| order.checkinTime
)
}
export function isRejectedBookingOrder(order = {}) {
const statusKey = normalizeStatusKey(order)
const statusCode = getOrderStatusCode(order)
return statusKey === 'rejected' || statusCode === -1
}
export function isCheckedInOrder(order = {}) {
const statusCode = getOrderStatusCode(order)
if (statusCode !== null) {
return statusCode > 0
}
const contractStatus = Number(order.contractStatus)
if (contractStatus === 1) {
return true
}
if (isRejectedBookingOrder(order)) {
return false
}
return hasStayFields(order)
}
export function isCurrentBookingOrder(order = {}) {
if (isRejectedBookingOrder(order) || isCheckedInOrder(order)) {
return false
}
const statusCode = getOrderStatusCode(order)
if (statusCode === 0) {
return true
}
const statusKey = normalizeStatusKey(order)
return ['pending_review', 'approved_wait_assign', 'assigned_wait_room', 'contract_signed'].includes(statusKey)
}
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
return {
...order,
orderNo: getOrderNo(order),
roomTypeText: getRoomTypeText(order, language),
buildingFloorText: getBuildingFloorText(order, language),
startDateText: formatOrderDate(startDate),
endDateText: formatOrderDate(endDate),
createdAtText: formatOrderDateTime(order.createTime || order.updateTime || order.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 || '--'),
areaText: language === 'en'
? (order.areaNameEn || order.campusNameEn || order.areaName || order.campusName || '--')
: (order.areaName || order.campusName || '--'),
rejectReasonText: order.rejectReason || order.approveReason || order.remark || '',
startDate,
endDate,
status: statusKey,
statusCode,
statusKey,
statusText: resolveStatusText(order, statusKey, language),
tagType: resolveTagType(statusKey)
}
}
export function normalizeBookingList(orderList = [], language = getCurrentLanguage()) {
return sortOrdersByLatest(orderList).map(item => normalizeBookingOrder(item, language))
}
export function normalizeBookingDetail(order = {}, language = getCurrentLanguage()) {
if (!order || typeof order !== 'object' || Array.isArray(order)) {
return null
}
return normalizeBookingOrder(order, language)
}
export function pickCurrentStayOrder(orderList = []) {
return sortOrdersByLatest(orderList).find(item => isCheckedInOrder(item)) || null
}
export function pickCurrentBooking(orderList = []) {
return sortOrdersByLatest(orderList).find(item => isCurrentBookingOrder(item)) || null
}

10
utils/common.js Normal file
View File

@@ -0,0 +1,10 @@
import { config } from '../config.js'
export function addFilePrefix(url = '') {
const value = String(url || '').trim()
if (!value) return ''
if (/^(https?:\/\/|data:image|blob:)/i.test(value)) {
return value
}
return `${config.filePrefix}${value.startsWith('/') ? '' : '/'}${value}`
}

167
utils/enum-store.js Normal file
View File

@@ -0,0 +1,167 @@
import { getEnums } from '@/api/index.js'
import { getCurrentLanguage } from '@/utils/i18n'
const ENUMS_CACHE_KEY = 'mini_enums_cache_v1'
let enumsMap = {}
let enumsLanguage = ''
let fetchPromise = null
function normalizeEnumMap(data) {
const map = {}
if (Array.isArray(data)) {
data.forEach((item) => {
if (item && item.code) {
map[item.code] = Array.isArray(item.data) ? item.data : []
}
})
}
return map
}
function getNormalizedLanguage(language = '') {
return String(language || getCurrentLanguage() || 'zh').trim().toLowerCase() === 'en' ? 'en' : 'zh'
}
function readEnumsCache(language) {
const cacheLanguage = getNormalizedLanguage(language)
try {
const cached = uni.getStorageSync(ENUMS_CACHE_KEY)
if (!cached) return {}
const resolveLanguageBucket = (payload) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return {}
}
if (payload.en || payload.zh) {
const bucket = payload[cacheLanguage]
return bucket && typeof bucket === 'object' ? bucket : {}
}
return payload
}
if (typeof cached === 'string') {
const parsed = JSON.parse(cached)
return resolveLanguageBucket(parsed)
}
return resolveLanguageBucket(cached)
} catch (error) {
console.error('Failed to read enums cache', error)
return {}
}
}
function writeEnumsCache(map, language) {
const cacheLanguage = getNormalizedLanguage(language)
try {
const cached = uni.getStorageSync(ENUMS_CACHE_KEY)
let nextCache = {}
if (cached && typeof cached === 'object' && !Array.isArray(cached)) {
nextCache = { ...cached }
} else if (typeof cached === 'string') {
try {
const parsed = JSON.parse(cached)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
nextCache = { ...parsed }
}
} catch (error) {
nextCache = {}
}
}
nextCache[cacheLanguage] = map
uni.setStorageSync(ENUMS_CACHE_KEY, nextCache)
} catch (error) {
console.error('Failed to write enums cache', error)
}
}
export function initEnumsFromCache(language) {
const currentLanguage = getNormalizedLanguage(language)
if (Object.keys(enumsMap).length && enumsLanguage === currentLanguage) return enumsMap
enumsMap = readEnumsCache(currentLanguage)
enumsLanguage = currentLanguage
return enumsMap
}
export async function fetchEnums(force = false, language) {
const currentLanguage = getNormalizedLanguage(language)
if (!force && Object.keys(enumsMap).length && enumsLanguage === currentLanguage) return enumsMap
if (fetchPromise) return fetchPromise
fetchPromise = (async () => {
if (!force && (!Object.keys(enumsMap).length || enumsLanguage !== currentLanguage)) {
initEnumsFromCache(currentLanguage)
if (Object.keys(enumsMap).length) return enumsMap
}
try {
const res = await getEnums({}, currentLanguage)
const data = res && res.data ? res.data : res
const map = normalizeEnumMap(data)
if (Object.keys(map).length) {
enumsMap = map
enumsLanguage = currentLanguage
writeEnumsCache(map, currentLanguage)
}
return enumsMap
} catch (error) {
console.error('Failed to fetch enums', error)
return enumsMap
} finally {
fetchPromise = null
}
})()
return fetchPromise
}
export function getEnumOptions(code, language) {
initEnumsFromCache(language)
return enumsMap[code] || []
}
export function getEnumLabel(code, key, fallback = '', language) {
const options = getEnumOptions(code, language)
const matched = options.find(item => String(item.key) === String(key))
if (matched) return matched.value
return fallback || key
}
export function getApprovalStatusLabel(status, fallback = '', language) {
return getEnumLabel('ApprovalStatusEnum', status, fallback, language)
}
export function getGenderLabel(gender, fallback = '', language) {
return getEnumLabel('GenderEnum', gender, fallback, language)
}
export const ACCOUNT_BILL_STATUS = {
ACTIVE: 10,
FINISHED: 99
}
export const ACCOUNT_BILL_PAYMENT_STATUS = {
UNPAID: 0,
PARTIAL: 1,
PAID: 2
}
export const ACCOUNT_BILL_REFUND_STATUS = {
NONE: 0,
PARTIAL: 10,
REFUNDED: 99
}
export function getAccountBillStatusLabel(status, fallback = '', language) {
return getEnumLabel('AccountBillStatusEnum', status, fallback, language)
}
export function getAccountBillPaymentStatusLabel(status, fallback = '', language) {
return getEnumLabel('AccountBillPaymentStatusEnum', status, fallback, language)
}
export function getAccountBillRefundStatusLabel(status, fallback = '', language) {
return getEnumLabel('AccountBillRefundStatusEnum', status, fallback, language)
}
export function getAccountReferenceTypeLabel(type, fallback = '', language) {
return getEnumLabel('AccountReferenceTypeEnum', type, fallback, language)
}

294
utils/flow.js Normal file
View File

@@ -0,0 +1,294 @@
import {
getBookingList,
getCurrentUser,
getRegisteredUser,
saveBookingList,
saveCurrentBooking,
saveCurrentUser,
saveRegisteredUser
} from './app-state'
import { getApprovalStatusLabel, getGenderLabel } from './enum-store'
import { getCurrentLanguage } from './i18n'
import store from '@/store'
const AUTH_STATUS_TEXT = {
zh: {
unsubmitted: '未实名',
pending: '审核中',
approved: '已通过',
rejected: '已驳回'
},
en: {
unsubmitted: 'Not Verified',
pending: 'Under Review',
approved: 'Approved',
rejected: 'Rejected'
}
}
const BOOKING_STATUS_TEXT = {
zh: {
none: '暂无预订',
pending_review: '预订审核中',
approved_wait_assign: '审核通过',
assigned_wait_room: '已分配管家,待分房号',
rejected: '预订已驳回'
},
en: {
none: 'No Booking',
pending_review: 'Booking Under Review',
approved_wait_assign: 'Approved',
assigned_wait_room: 'Butler Assigned, Awaiting Room',
rejected: 'Booking Rejected'
}
}
function resolveLanguageMap(map, language) {
const currentLanguage = language || getCurrentLanguage()
return currentLanguage === 'en' ? map.en : map.zh
}
function getFlowGenderFallback(gender, language) {
const currentLanguage = language || getCurrentLanguage()
if (gender === 1) {
return currentLanguage === 'en' ? 'Male' : '男'
}
if (gender === 0) {
return currentLanguage === 'en' ? 'Female' : '女'
}
return ''
}
export function mapRegistrationApprovalToAuthStatus(status) {
if (status === 1) return 'approved'
if (status === -1) return 'rejected'
if (status === 0) return 'pending'
return 'unsubmitted'
}
function normalizeLookupValue(value) {
if (value === null || typeof value === 'undefined') {
return ''
}
return String(value).trim()
}
function normalizeRegistrationCodeCandidate(value) {
const normalized = normalizeLookupValue(value)
if (!normalized) {
return ''
}
// 过滤业务状态码,避免把 200/500 这类响应码误存成申请单编号
if (/^\d{1,6}$/.test(normalized)) {
return ''
}
return normalized
}
export function resolveRegistrationApplicationCode(source = {}) {
const raw = source && source.data ? source.data : source
const candidates = [
source && source.registrationCode,
source && source.data,
raw && raw.code,
raw && raw.registrationCode,
raw,
raw && raw.data && raw.data.code,
raw && raw.data && raw.data.registrationCode,
raw && raw.data,
source && source.data && source.data.data && source.data.data.code,
source && source.data && source.data.data && source.data.data.registrationCode,
source && source.code
]
return candidates.map(normalizeRegistrationCodeCandidate).find(Boolean) || ''
}
export function normalizeUser(raw = {}) {
const user = raw || {}
return {
...user,
hasAuth: user.hasAuth === true,
authStatus: user.authStatus || 'unsubmitted',
authRejectReason: user.authRejectReason || '',
authProfile: user.authProfile || null,
bookingStatus: user.bookingStatus || 'none',
bookingRejectReason: user.bookingRejectReason || '',
currentBooking: user.currentBooking || null,
hasCheckedIn: user.hasCheckedIn === true
}
}
export function buildUserFromRegistrationApplication(application = {}, options = {}) {
const source = application || {}
const normalizedOptions = options || {}
const language = normalizedOptions.language || getCurrentLanguage()
const profile = {
identityType: source.passportNumber ? 'foreign' : 'domestic',
account: source.account || normalizedOptions.account || '',
email: source.email || normalizedOptions.email || '',
roleNo: source.roleNo || normalizedOptions.roleNo || '',
name: source.name || normalizedOptions.name || '',
gender: typeof source.gender === 'number' ? source.gender : null,
genderText: source.genderDesc || getGenderLabel(source.gender, getFlowGenderFallback(source.gender, language), language),
phone: source.phone || normalizedOptions.phone || '',
country: source.country || normalizedOptions.country || '',
idCard: source.idCard || '',
workUnit: source.workUnit || '',
hasHangzhouProperty: typeof source.hasHangzhouProperty === 'boolean' ? source.hasHangzhouProperty : null,
dietaryHabitTags: source.dietaryHabitTags || [],
hobbyTags: source.hobbyTags || [],
passportNumber: source.passportNumber || '',
passportExpiry: source.passportExpiry || '',
school: source.school || '',
religionTags: source.religionTags || [],
passportAttachment: source.passportAttachment || '',
idCardFrontAttachment: source.idCardFrontAttachment || '',
idCardBackAttachment: source.idCardBackAttachment || ''
}
return normalizeUser({
id: source.convertedUserCode || source.code || normalizedOptions.id || '',
code: source.code || source.convertedUserCode || normalizedOptions.code || '',
userCode: source.convertedUserCode || source.userCode || source.code || normalizedOptions.userCode || '',
firstChar: (source.name || normalizedOptions.name || 'U').charAt(0).toUpperCase(),
name: source.name || normalizedOptions.name || '',
account: source.account || normalizedOptions.account || '',
username: source.account || normalizedOptions.account || '',
password: normalizedOptions.password || '',
email: source.email || normalizedOptions.email || '',
phone: source.phone || normalizedOptions.phone || '',
roleNo: source.roleNo || normalizedOptions.roleNo || '',
hasAuth: source.approvalStatus === 1,
authStatus: mapRegistrationApprovalToAuthStatus(source.approvalStatus),
authRejectReason: source.approveReason || '',
authDate: source.createTime ? new Date(source.createTime).toLocaleDateString() : '',
authProfile: profile
})
}
export function mergeBackendUserInfo(baseUser = {}, backendUserInfo = {}) {
const user = baseUser || {}
const response = backendUserInfo || {}
const raw = response && response.data ? response.data : response
const info = raw || {}
const mergedUser = {
...user,
id: info.code || info.userCode || user.id || '',
code: info.code || info.userCode || user.code || '',
userCode: info.userCode || info.code || user.userCode || '',
backendUserInfo: info,
name: info.name || user.name || '',
account: info.account || user.account || '',
username: info.account || user.username || user.account || '',
avatar: info.avatar || info.base64Face || user.avatar || '',
base64Face: info.base64Face || user.base64Face || '',
phone: info.phone || user.phone || '',
email: info.email || user.email || '',
roleNo: info.roleNo || user.roleNo || '',
roles: Array.isArray(info.roles) ? info.roles : (user.roles || []),
permissions: Array.isArray(info.permissions) ? info.permissions : (user.permissions || [])
}
if (mergedUser.authProfile) {
mergedUser.authProfile = {
...mergedUser.authProfile,
name: mergedUser.name || mergedUser.authProfile.name || '',
account: mergedUser.account || mergedUser.authProfile.account || '',
email: mergedUser.email || mergedUser.authProfile.email || '',
phone: mergedUser.phone || mergedUser.authProfile.phone || '',
roleNo: mergedUser.roleNo || mergedUser.authProfile.roleNo || ''
}
}
return normalizeUser(mergedUser)
}
export function getStoredUser() {
const storeUser = store && store.state ? store.state.vuex_user : {}
if (storeUser && Object.keys(storeUser).length) {
return normalizeUser(storeUser)
}
const currentUser = getCurrentUser()
if (currentUser && Object.keys(currentUser).length) {
return normalizeUser(currentUser)
}
const registeredUser = getRegisteredUser()
return normalizeUser(registeredUser || {})
}
export function saveUser(user) {
const normalizedUser = normalizeUser(user)
saveCurrentUser(normalizedUser)
saveRegisteredUser(normalizedUser)
if (store && store.commit) {
store.commit('$tStore', {
name: 'vuex_user',
value: normalizedUser
})
}
return normalizedUser
}
export function getAuthStatusText(status, language) {
const languageMap = resolveLanguageMap(AUTH_STATUS_TEXT, language)
const numericMap = {
pending: 0,
approved: 1,
rejected: -1
}
if (Object.prototype.hasOwnProperty.call(numericMap, status)) {
return getApprovalStatusLabel(numericMap[status], languageMap[status] || languageMap.unsubmitted, language)
}
return languageMap[status] || languageMap.unsubmitted
}
export function getBookingStatusText(status, language) {
const languageMap = resolveLanguageMap(BOOKING_STATUS_TEXT, language)
return languageMap[status] || languageMap.none
}
export function getBookingTagType(status) {
if (status === 'rejected') {
return 'rejected'
}
if (status === 'pending_review') {
return 'pending'
}
return 'approved'
}
export function createBooking(payload = {}) {
const source = payload || {}
const now = Date.now()
const status = source.status || 'pending_review'
return {
orderNo: `LJYD${now}`,
status,
statusText: getBookingStatusText(status),
statusTextZh: getBookingStatusText(status, 'zh'),
statusTextEn: getBookingStatusText(status, 'en'),
createdAt: new Date(now).toLocaleString(),
butlerName: '',
roomAssignStatus: 'pending',
rejectReason: '',
...source
}
}
export function saveBooking(booking) {
const normalizedBooking = booking && typeof booking === 'object'
? {
...booking,
statusTextZh: booking.statusTextZh || getBookingStatusText(booking.status || 'pending_review', 'zh'),
statusTextEn: booking.statusTextEn || getBookingStatusText(booking.status || 'pending_review', 'en')
}
: booking
const bookingList = getBookingList()
const nextList = [normalizedBooking, ...bookingList.filter(item => item.orderNo !== normalizedBooking.orderNo)]
saveCurrentBooking(normalizedBooking)
saveBookingList(nextList)
return nextList
}

130
utils/i18n.js Normal file
View File

@@ -0,0 +1,130 @@
import store from '@/store'
import globalMessages from '@/locales/messages'
export const ZH_LOCALE = 'zh-Hans'
export const EN_LOCALE = 'en'
export const DEFAULT_LOCALE = ZH_LOCALE
function safeReadStorageLocale() {
try {
const lifeData = uni.getStorageSync('lifeData') || {}
return lifeData.vuex_locale || ''
} catch (error) {
return ''
}
}
export function normalizeLocale(locale) {
const normalized = String(locale || '').trim().toLowerCase()
if (!normalized) {
return DEFAULT_LOCALE
}
if (normalized === 'en' || normalized === 'en-us' || normalized === 'en_us') {
return EN_LOCALE
}
if (normalized === 'zh' || normalized === 'zh-cn' || normalized === 'zh_cn' || normalized === 'zh-hans') {
return ZH_LOCALE
}
return normalized.startsWith('en') ? EN_LOCALE : ZH_LOCALE
}
export function getLocaleShort(locale) {
return normalizeLocale(locale) === EN_LOCALE ? 'en' : 'zh'
}
export function getCurrentLocale() {
const stateLocale = store && store.state ? store.state.vuex_locale : ''
return normalizeLocale(stateLocale || safeReadStorageLocale())
}
export function getCurrentLanguage() {
return getLocaleShort(getCurrentLocale())
}
export function getNextLocale(locale) {
return normalizeLocale(locale) === EN_LOCALE ? ZH_LOCALE : EN_LOCALE
}
export function resolveLanguageHeader(locale) {
return getLocaleShort(locale) === 'en' ? 'en-US' : 'zh-CN'
}
export function formatI18nText(template, params = {}) {
return String(template || '').replace(/\{(\w+)\}/g, (match, key) => {
return Object.prototype.hasOwnProperty.call(params, key) ? params[key] : match
})
}
function getMessageText(messages = {}, locale, key) {
const shortLocale = getLocaleShort(locale)
const fallbackMessages = messages.zh || messages[ZH_LOCALE] || {}
const scopedMessages = messages[shortLocale] || messages[normalizeLocale(locale)] || fallbackMessages
if (scopedMessages[key] !== undefined) {
return scopedMessages[key]
}
return fallbackMessages[key]
}
export function translateMessages(messages = {}, locale, key, params = {}) {
const text = getMessageText(messages, locale, key)
return formatI18nText(text !== undefined ? text : key, params)
}
export function getMessageScope(messages = {}, namespace = '') {
if (!namespace) {
return messages
}
return String(namespace)
.split('.')
.filter(Boolean)
.reduce((current, segment) => {
if (!current || typeof current !== 'object') {
return undefined
}
return current[segment]
}, messages)
}
export function getGlobalMessages(namespace = '') {
if (!namespace) {
return globalMessages
}
const zhScope = getMessageScope(globalMessages.zh || globalMessages[ZH_LOCALE] || {}, namespace)
const enScope = getMessageScope(globalMessages.en || globalMessages[EN_LOCALE] || {}, namespace)
if (!zhScope && !enScope) {
return undefined
}
return {
zh: zhScope || {},
en: enScope || {}
}
}
export function translateGlobalMessages(namespace, locale, key, params = {}) {
const scopedMessages = getGlobalMessages(namespace)
if (!scopedMessages || typeof scopedMessages !== 'object') {
return key
}
return translateMessages(scopedMessages, locale, key, params)
}
export function getGlobalMessage(namespace, locale, key, params = {}) {
const scopedMessages = getGlobalMessages(namespace)
if (!scopedMessages || typeof scopedMessages !== 'object') {
return ''
}
const text = getMessageText(scopedMessages, locale, key)
return text === undefined ? '' : formatI18nText(text, params)
}
export function pickLocalizedText(options = {}) {
const locale = getLocaleShort(options.locale)
const zhText = options.zh !== undefined ? options.zh : ''
const enText = options.en !== undefined ? options.en : zhText
if (locale === 'en') {
return enText || options.fallback || zhText || ''
}
return zhText || options.fallback || enText || ''
}

170
utils/login-helper.js Normal file
View File

@@ -0,0 +1,170 @@
import {
buildUserFromRegistrationApplication,
normalizeUser
} from './flow'
function getText(language, zhText, enText) {
return String(language || 'zh').trim().toLowerCase() === 'en' ? enText : zhText
}
function getResponseData(response = {}) {
if (response && response.data && typeof response.data === 'object') {
return response.data
}
return response || {}
}
export function matchRegisteredUser(user = {}, account = '') {
const source = user || {}
const currentAccount = String(account || '').trim()
const candidateAccounts = [
source.account,
source.username,
source.email
].map(value => String(value || '').trim()).filter(Boolean)
if (!currentAccount || !candidateAccounts.length) {
return null
}
if (!candidateAccounts.includes(currentAccount)) {
return null
}
return source
}
export function pickLatestRegistration(response = {}, options = {}) {
const source = options || {}
const account = String(source.account || '').trim()
const responseData = getResponseData(response)
const list = Array.isArray(responseData.data)
? responseData.data
: (Array.isArray(responseData) ? responseData : [])
const matchedList = list.filter(item => {
if (!item) {
return false
}
return item.account === account || item.email === account
})
const targetList = matchedList.length ? matchedList : list
return targetList[0] || null
}
export function extractLoginSession(response = {}, language = 'zh') {
const responseData = getResponseData(response)
const nestedData = responseData && responseData.data && typeof responseData.data === 'object'
? responseData.data
: {}
const token = responseData.accessToken
|| responseData.token
|| nestedData.accessToken
|| nestedData.token
|| response.accessToken
|| response.token
|| ''
const expires = responseData.expires
|| responseData.expireTime
|| nestedData.expires
|| nestedData.expireTime
|| response.expires
|| response.expireTime
|| ''
if (!token) {
throw new Error(getText(language, '登录成功但未返回 accessToken', 'Login succeeded but accessToken is missing'))
}
return {
token,
expires
}
}
export function buildHandledLoginError(language = 'zh', options = {}) {
const source = options || {}
return {
title: source.title || '',
navigateToAuthResult: source.navigateToAuthResult === true
}
}
export function buildLoginSessionUser(options = {}) {
const source = options || {}
const latestRegistration = source.latestRegistration || null
const matchedLocalUser = source.matchedLocalUser || null
const account = source.account || ''
const password = source.password || ''
const language = source.language || 'zh'
if (latestRegistration) {
return buildUserFromRegistrationApplication(latestRegistration, {
password,
language
})
}
if (matchedLocalUser) {
return matchedLocalUser
}
return normalizeUser({
account,
username: account,
password
})
}
export function resolveLoginPreparation(options = {}) {
const source = options || {}
const language = source.language || 'zh'
const matchedLocalUser = source.matchedLocalUser || null
const latestRegistration = source.latestRegistration || null
const account = source.account || ''
const password = source.password || ''
if (!latestRegistration) {
return {
ok: true,
sessionUser: buildLoginSessionUser({
matchedLocalUser,
account,
password,
language
})
}
}
if (latestRegistration.approvalStatus === 0) {
return {
ok: false,
handledError: buildHandledLoginError(language, {
title: getText(language, '注册申请审核中,暂不能登录', 'Registration is under review'),
navigateToAuthResult: true
})
}
}
if (latestRegistration.approvalStatus === -1) {
return {
ok: false,
handledError: buildHandledLoginError(language, {
title: latestRegistration.approveReason || getText(language, '注册申请已驳回', 'Rejected'),
navigateToAuthResult: true
})
}
}
return {
ok: true,
sessionUser: buildLoginSessionUser({
latestRegistration,
matchedLocalUser,
account,
password,
language
})
}
}

49
utils/message-helper.js Normal file
View File

@@ -0,0 +1,49 @@
export function resolveNotificationList(response) {
const payload = response && response.data ? response.data : response
if (payload && Array.isArray(payload.data)) {
return payload.data
}
if (payload && payload.data && Array.isArray(payload.data.data)) {
return payload.data.data
}
if (Array.isArray(payload)) {
return payload
}
return []
}
export function resolveNotificationUnreadTotal(response) {
const payload = response && response.data ? response.data : response
return Number((payload && payload.total) || (payload && payload.data && payload.data.total) || 0)
}
export function extractInspectionList(response) {
const payload = response && response.data ? response.data : response
if (Array.isArray(payload)) {
return payload
}
if (payload && Array.isArray(payload.data)) {
return payload.data
}
return []
}
export function isPendingInspectionRecord(record) {
return !!record && record.checkedUserAgree !== true && record.checkedUserAgree !== false
}
export function countPendingInspectionRecords(list = []) {
return (Array.isArray(list) ? list : []).filter(item => isPendingInspectionRecord(item)).length
}
export function formatMessageDateTime(value) {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return String(value).replace('T', ' ').slice(0, 16)
}
const pad = item => String(item).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}

47
utils/messages.js Normal file
View File

@@ -0,0 +1,47 @@
const MESSAGE_DETAIL_KEY = 'messageDetailPayload'
const MESSAGE_LIST_KEY = 'messageListSnapshot'
function normalizeLookupValue(value = '') {
return String(value || '').trim()
}
function getMessageStorageKey(baseKey, language = '') {
const normalizedLanguage = String(language || '').trim().toLowerCase() === 'en' ? 'en' : 'zh'
return `${baseKey}_${normalizedLanguage}`
}
export function saveMessageDetailPayload(payload = {}, language = '') {
uni.setStorageSync(getMessageStorageKey(MESSAGE_DETAIL_KEY, language), payload || {})
return payload || {}
}
export function getMessageDetailPayload(language = '') {
return uni.getStorageSync(getMessageStorageKey(MESSAGE_DETAIL_KEY, language)) || {}
}
export function saveMessageListSnapshot(list = [], language = '') {
const nextList = Array.isArray(list) ? list : []
uni.setStorageSync(getMessageStorageKey(MESSAGE_LIST_KEY, language), nextList)
return nextList
}
export function getMessageListSnapshot(language = '') {
const list = uni.getStorageSync(getMessageStorageKey(MESSAGE_LIST_KEY, language))
return Array.isArray(list) ? list : []
}
export function findMessageDetailPayload(id = '', code = '', language = '') {
const targetId = normalizeLookupValue(id)
const targetCode = normalizeLookupValue(code)
const list = getMessageListSnapshot(language)
return list.find(item => {
const currentId = normalizeLookupValue(item && item.id)
const currentCode = normalizeLookupValue(item && item.code)
return (targetId && currentId === targetId) || (targetCode && currentCode === targetCode)
}) || {}
}
export function clearMessageDetailPayload(language = '') {
uni.removeStorageSync(getMessageStorageKey(MESSAGE_DETAIL_KEY, language))
}

16
utils/navigation-title.js Normal file
View File

@@ -0,0 +1,16 @@
import { getGlobalMessage, normalizeLocale } from './i18n'
export function getNavigationTitle(route = '', locale) {
return getGlobalMessage('navigation.routes', normalizeLocale(locale), String(route || '').trim())
}
export function syncNavigationBarTitle(route = '', locale) {
const title = getNavigationTitle(route, locale)
if (!title) {
return ''
}
uni.setNavigationBarTitle({
title
})
return title
}

210
utils/request.js Normal file
View File

@@ -0,0 +1,210 @@
import { config } from '../config.js'
import { clearLoginSession, getAuthToken } from './app-state'
import { getCurrentLanguage } from './i18n'
const API_BASE_URL = config.baseURL
const FILE_BASE_URL = config.filePrefix
const FIXED_PROVIDER_TYPE = 'WechatMiniProgram'
function getRequestText(key = '') {
const language = getCurrentLanguage()
const isEn = language === 'en'
const map = {
requestFailed: isEn ? 'Request failed' : '请求失败',
loginExpired: isEn ? 'Login expired. Please sign in again' : '登录已过期,请重新登录',
tooManyRequests: isEn ? 'Too many requests. Please try again later' : '操作太频繁了,请稍后再试',
networkFailed: isEn ? 'Network request failed' : '网络请求失败'
}
return map[key] || ''
}
function normalizeUrl(url = '') {
if (/^https?:\/\//i.test(url)) {
return url
}
if (!url) {
return API_BASE_URL
}
if (url.startsWith('/')) {
return `${API_BASE_URL}${url}`
}
return `${API_BASE_URL}/${url}`
}
function isPlainRequestObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function appendFixedProviderType(payload) {
if (isPlainRequestObject(payload)) {
return {
...payload,
ProviderType: FIXED_PROVIDER_TYPE
}
}
return {
ProviderType: FIXED_PROVIDER_TYPE
}
}
function hasBusinessCode(payload) {
return !!payload && typeof payload === 'object' && Object.prototype.hasOwnProperty.call(payload, 'code')
}
function isBusinessSuccess(payload) {
if (!hasBusinessCode(payload)) {
return true
}
const code = String(payload.code)
return code === '200' || code === '0'
}
function normalizeBusinessError(payload, fallback = {}) {
const message = (payload && (payload.msg || payload.message)) || fallback.errMsg || getRequestText('requestFailed')
return {
...fallback,
...(payload && typeof payload === 'object' ? payload : {}),
message
}
}
function getCurrentRoute() {
const pages = getCurrentPages()
if (!Array.isArray(pages) || !pages.length) {
return ''
}
const currentPage = pages[pages.length - 1] || {}
return currentPage.route || ''
}
function shouldSkipUnauthorizedRedirect() {
const route = getCurrentRoute()
return route === 'pkgAccount/login/login' || route === 'pages/login/index' || route === 'pages/login/login'
}
function handleUnauthorized(responseData) {
clearLoginSession()
if (shouldSkipUnauthorizedRedirect()) {
return
}
uni.showToast({
title: getRequestText('loginExpired'),
icon: 'none',
duration: 2000
})
setTimeout(() => {
uni.reLaunch({
url: '/pkgAccount/login/login'
})
}, 500)
}
export function request(options = {}) {
const {
url = '',
method = 'GET',
data,
params,
header = {},
hideErrorToast = false,
withAuth = true
} = options
return new Promise((resolve, reject) => {
const token = withAuth ? getAuthToken() : ''
const uppercaseMethod = String(method || 'GET').toUpperCase()
const normalizedParams = appendFixedProviderType(params)
const normalizedData = appendFixedProviderType(data)
uni.request({
url: normalizeUrl(url),
method: uppercaseMethod,
data: uppercaseMethod === 'GET'
? (params !== undefined ? normalizedParams : normalizedData)
: normalizedData,
header: {
'Content-Type': 'application/json',
'X-Requested-Language': getCurrentLanguage(),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...header
},
success: (res) => {
const { statusCode, data: responseData } = res
if ((statusCode === 401) || (responseData && Number(responseData.code) === 401)) {
handleUnauthorized(responseData)
reject(normalizeBusinessError(responseData, res))
return
}
if (statusCode === 429) {
if (!hideErrorToast) {
uni.showToast({
title: getRequestText('tooManyRequests'),
icon: 'none',
duration: 2500
})
}
reject({
...normalizeBusinessError(responseData, res),
message: getRequestText('tooManyRequests'),
__toastShown: !hideErrorToast
})
return
}
if (statusCode >= 200 && statusCode < 300) {
if (isBusinessSuccess(responseData)) {
resolve(responseData)
return
}
const businessError = normalizeBusinessError(responseData, res)
if (!hideErrorToast) {
uni.showToast({
title: businessError.message || getRequestText('requestFailed'),
icon: 'none',
duration: 2000
})
}
reject({
...businessError,
__toastShown: !hideErrorToast
})
return
}
const requestError = normalizeBusinessError(responseData, res)
if (!hideErrorToast) {
uni.showToast({
title: requestError.message || getRequestText('requestFailed'),
icon: 'none'
})
}
reject({
...requestError,
__toastShown: !hideErrorToast
})
},
fail: (error) => {
const requestError = normalizeBusinessError(null, error)
if (!hideErrorToast) {
uni.showToast({
title: requestError.message || getRequestText('networkFailed'),
icon: 'none'
})
}
reject({
...requestError,
__toastShown: !hideErrorToast
})
}
})
})
}
export { API_BASE_URL, FILE_BASE_URL }
export default request

20
utils/rich-text.js Normal file
View File

@@ -0,0 +1,20 @@
import { addFilePrefix } from './common'
export function normalizeRichTextImages(content = '', imageStyle = '') {
const normalizedStyle = String(imageStyle || '').trim()
return String(content || '').replace(/<img\b([^>]*)>/gi, (match, rawAttrs = '') => {
const attrs = String(rawAttrs || '')
.replace(/\swidth\s*=\s*(['"]).*?\1/gi, '')
.replace(/\sheight\s*=\s*(['"]).*?\1/gi, '')
.replace(/\sstyle\s*=\s*(['"]).*?\1/gi, '')
const normalizedAttrs = attrs.replace(/\ssrc\s*=\s*(['"])(.*?)\1/gi, (srcMatch, quote, srcValue = '') => {
const normalizedSrc = addFilePrefix(srcValue)
return normalizedSrc ? ` src=${quote}${normalizedSrc}${quote}` : srcMatch
})
return normalizedStyle
? `<img${normalizedAttrs} style="${normalizedStyle}" />`
: `<img${normalizedAttrs} />`
})
}

7
utils/room-api.js Normal file
View File

@@ -0,0 +1,7 @@
export {
areaPagination,
roomDetail,
roomTypeDetail,
roomPagination,
roomtypeList
} from '../api/index'

View File

@@ -0,0 +1,406 @@
import { addFilePrefix } from './common'
import { getCurrentLanguage } from './i18n'
const BANNER_TEXT_FALLBACK_MAP = {
title: {
'绿居·和山国际公寓': 'Luju Heshan Intl. Apartments',
'和山国际公寓': 'Heshan Intl. Apartments',
'乾晖里': 'Qianhui Residence'
},
subtitle: {
'国际轻奢公寓': 'International Light Luxury Apartment',
'城芯静境': 'Urban Quiet Retreat'
},
tab: {
'国际轻奢公寓': 'Light Luxury Living',
'城芯静境': 'Urban Quiet Retreat'
},
desc: {
'山境雅境 | 涉外住区 | 精致旅居': 'Mountain Views | Global Living | Boutique Stay'
},
areaLabel: {}
}
const VALUE_TEXT_FALLBACK_MAP = {
tags: {
'押一付一': 'Monthly Deposit',
'大型超市': 'Large Supermarket',
'地铁口': 'Near Metro',
'生活便利': 'Convenient Living',
'独立厨卫': 'Private Kitchen & Bath',
'商圈环绕': 'City Center',
'民水民电': 'Residential Utilities',
'拎包入住': 'Move-in Ready',
'近地铁': 'Near Metro',
'智能家居': 'Smart Home'
},
payMethod: {
'押一付一': 'Monthly Deposit',
'押一付三': 'Deposit 1, Pay 3',
'押一付六': 'Deposit 1, Pay 6',
'押一付年': 'Deposit 1, Annual Pay',
'月付': 'Monthly Pay',
'季付': 'Quarterly Pay',
'半年付': 'Semiannual Pay',
'年付': 'Annual Pay'
},
facility: {
'大型超市': 'Large Supermarket',
'地铁口': 'Near Metro',
'快递柜': 'Parcel Locker',
'健身房': 'Gym',
'自习室': 'Study Room',
'洗衣房': 'Laundry Room',
'停车场': 'Parking',
'便利店': 'Convenience Store',
'公共厨房': 'Shared Kitchen',
'休闲区': 'Lounge'
}
}
function firstNonEmptyText(...values) {
const matched = values.find((value) => String(value || '').trim())
return matched ? String(matched).trim() : ''
}
function normalizePayCount(value) {
if (value === '' || value === null || typeof value === 'undefined') {
return null
}
const parsed = Number(value)
if (Number.isFinite(parsed) && parsed >= 0) {
return parsed
}
return null
}
function formatPayCount(value) {
return String(value)
}
function buildPayMethodFromPricePlan(pricePlanDetail = {}, fallbackText = '') {
const deposit = normalizePayCount(pricePlanDetail.deposit)
const prepay = normalizePayCount(pricePlanDetail.prepay)
if (deposit !== null && prepay !== null) {
return `押${formatPayCount(deposit)}付${formatPayCount(prepay)}`
}
return String(fallbackText || '').trim()
}
export function compactEnglishText(text = '', maxLength = 24) {
const rawText = String(text || '').trim()
if (!rawText) {
return ''
}
const compacted = rawText
.replace(/\bInternational\b/gi, 'Intl.')
.replace(/\bApartment(s)?\b/gi, 'Apt$1')
.replace(/\bResidence\b/gi, 'Resid.')
.replace(/\bBuilding\b/gi, 'Bldg.')
.replace(/\bCommunity\b/gi, 'Comm.')
.replace(/\bBoulevard\b/gi, 'Blvd.')
.replace(/\bRoad\b/gi, 'Rd.')
.replace(/\bStreet\b/gi, 'St.')
.replace(/\bAvenue\b/gi, 'Ave.')
.replace(/\s{2,}/g, ' ')
.trim()
if (compacted.length <= maxLength) {
return compacted
}
return `${compacted.slice(0, Math.max(maxLength - 3, 1)).trim()}...`
}
function getSelectRoomText(key = '', params = {}) {
const isEn = getCurrentLanguage() === 'en'
const map = {
featuredContent: isEn ? 'Featured Picks' : '精选内容',
recommendedTab: isEn ? `Featured ${params.index}` : `推荐${params.index}`
}
return map[key] || ''
}
function localizeKnownValue(field, text = '', language = getCurrentLanguage()) {
const normalizedText = String(text || '').trim()
if (!normalizedText || language !== 'en') {
return normalizedText
}
if (field === 'payMethod') {
const payMethodMatch = normalizedText.match(/^押(\d+(?:\.\d+)?)付(\d+(?:\.\d+)?)$/)
if (payMethodMatch) {
return `Deposit ${payMethodMatch[1]}, Pay ${payMethodMatch[2]}`
}
}
const fieldMap = VALUE_TEXT_FALLBACK_MAP[field] || {}
return fieldMap[normalizedText] || normalizedText
}
export function localizeHousingTag(tag = '', language = getCurrentLanguage()) {
return localizeKnownValue('tags', tag, language)
}
export function localizePayMethodText(payMethod = '', language = getCurrentLanguage()) {
return localizeKnownValue('payMethod', payMethod, language)
}
export function localizeFacilityName(name = '', language = getCurrentLanguage()) {
return localizeKnownValue('facility', name, language)
}
function resolveBannerFallbackText(field, text) {
const normalizedText = String(text || '').trim()
if (!normalizedText) {
return ''
}
return BANNER_TEXT_FALLBACK_MAP[field] && BANNER_TEXT_FALLBACK_MAP[field][normalizedText]
? BANNER_TEXT_FALLBACK_MAP[field][normalizedText]
: ''
}
function extractLabelValues(...sources) {
const values = sources.flatMap((source) => {
if (!Array.isArray(source)) {
return []
}
return source.map((item) => {
if (!item) {
return ''
}
if (typeof item === 'string') {
return item
}
if (typeof item === 'object') {
return item.value || item.label || item.name || item.key || ''
}
return ''
})
})
return [...new Set(values.filter(Boolean))]
}
export function parseBannerExtra(extra) {
if (!extra) {
return {}
}
if (typeof extra === 'object') {
return extra
}
try {
return JSON.parse(extra)
} catch (error) {
return {}
}
}
export function normalizeBannerImage(imageUrl) {
if (!imageUrl) {
return ''
}
if (/^https?:\/\//i.test(imageUrl)) {
return imageUrl
}
return addFilePrefix(imageUrl)
}
function isImageLikeLogo(value) {
if (!value) {
return false
}
return /^(https?:)?\/\//i.test(value) || value.startsWith('/') || value.startsWith('data:image')
}
function normalizeBannerLogo(extra = {}) {
const rawType = String(extra.logoType || '').trim().toLowerCase()
const rawLogo = String(extra.logo || '').trim()
const rawLogoIcon = String(extra.logoIcon || '').trim()
const rawLogoImage = String(extra.logoImage || '').trim()
const isImage = rawType === 'image' || (!rawType && !rawLogoImage && isImageLikeLogo(rawLogo))
const logoImageSource = rawLogoImage || (isImage ? rawLogo : '')
const logoIconSource = rawLogoIcon || (!isImage ? rawLogo : '')
return {
logoType: isImage ? 'image' : 'icon',
logoImage: normalizeBannerImage(logoImageSource),
logoIcon: logoIconSource,
}
}
function normalizeBannerLogoIconClass(icon) {
const normalizedIcon = String(icon || '').trim().toLowerCase()
if (!normalizedIcon) {
return ''
}
return normalizedIcon.startsWith('ri-') ? normalizedIcon : `ri-${normalizedIcon}`
}
export function normalizeBannerItem(item = {}, index = 0) {
const extra = parseBannerExtra(item.extra)
const title = firstNonEmptyText(item.title, item.name, extra.title, extra.mainTitle, extra.titleZh, getSelectRoomText('featuredContent'))
const titleEn = firstNonEmptyText(
item.titleEn,
item.title_en,
item.nameEn,
item.name_en,
extra.titleEn,
extra.title_en,
extra.enTitle,
extra.mainTitleEn,
extra.titleEnUs,
resolveBannerFallbackText('title', title),
'Featured Picks'
)
const subtitle = firstNonEmptyText(extra.subTitle, extra.subtitle, extra.remark, extra.note, item.remark)
const subtitleEn = firstNonEmptyText(
extra.subTitleEn,
extra.subtitleEn,
extra.sub_title_en,
extra.enSubtitle,
extra.remarkEn,
extra.remark_en,
extra.noteEn,
item.remarkEn,
item.remark_en,
resolveBannerFallbackText('subtitle', subtitle)
)
const desc = firstNonEmptyText(extra.desc, extra.description, extra.mainDesc, extra.slogan)
const descEn = firstNonEmptyText(
extra.descEn,
extra.descriptionEn,
extra.desc_en,
extra.description_en,
extra.enDesc,
extra.mainDescEn,
extra.sloganEn,
resolveBannerFallbackText('desc', desc)
)
const areaLabel = firstNonEmptyText(extra.areaName, extra.areaLabel, extra.area)
const areaLabelEn = firstNonEmptyText(extra.areaNameEn, extra.areaLabelEn, extra.area_name_en, extra.area_label_en, extra.enAreaName, resolveBannerFallbackText('areaLabel', areaLabel))
const tab = firstNonEmptyText(extra.tab, extra.topTab, title, getSelectRoomText('recommendedTab', { index: index + 1 }))
const tabEn = firstNonEmptyText(extra.tabEn, extra.tab_en, extra.enTab, extra.topTabEn, resolveBannerFallbackText('tab', tab), titleEn, `Featured ${index + 1}`)
const roomTypes = Array.isArray(extra.roomTypes) ? extra.roomTypes : []
const tags = Array.isArray(extra.tags) && extra.tags.length
? extra.tags.filter(Boolean)
: []
const logoInfo = normalizeBannerLogo(extra)
return {
id: item.no || item.code || item.id || `banner-${index}`,
tab,
tabEn,
tabEnDisplay: compactEnglishText(tabEn, 18),
title,
titleEn,
titleEnDisplay: compactEnglishText(titleEn, 24),
subtitle,
subtitleEn,
subtitleEnDisplay: compactEnglishText(subtitleEn, 28),
desc,
descEn,
descEnDisplay: compactEnglishText(descEn, 36),
areaCode: extra.areaCode || extra.area || '',
areaLabel,
areaLabelEn,
areaLabelEnDisplay: compactEnglishText(areaLabelEn, 22),
roomTypes,
recommendLabel: extra.recommendLabel || extra.recommend || '',
image: normalizeBannerImage(item.imageUrl || extra.imageUrl),
tags,
logoType: logoInfo.logoType,
logoImage: logoInfo.logoImage,
logoIcon: logoInfo.logoIcon,
logoIconClass: normalizeBannerLogoIconClass(logoInfo.logoIcon),
}
}
export function normalizeImageList(item = {}) {
const candidates = []
const listFields = [item.images, item.imageUrls, item.pictures, item.pictureList]
listFields.forEach((field) => {
if (Array.isArray(field)) {
field.forEach((image) => {
const normalized = normalizeBannerImage(image)
if (normalized) {
candidates.push(normalized)
}
})
}
})
;[item.imageUrl, item.image, item.cover, item.picture].forEach((image) => {
const normalized = normalizeBannerImage(image)
if (normalized) {
candidates.push(normalized)
}
})
return [...new Set(candidates)]
}
export function normalizeAreaRoomType(item = {}) {
const price = item.pricePlanDetail && typeof item.pricePlanDetail.price !== 'undefined'
? String(item.pricePlanDetail.price)
: ''
const priceValue = item.pricePlanDetail && typeof item.pricePlanDetail.price !== 'undefined'
? Number(item.pricePlanDetail.price)
: Number.MAX_SAFE_INTEGER
const tags = extractLabelValues(item.labelList, item.tags, item.labels, item.featureTags)
return {
code: item.code || '',
name: item.name || '',
nameEn: item.nameEn || '',
nameEnDisplay: compactEnglishText(item.nameEn || '', 22),
description: item.description || '',
descriptionEn: item.descriptionEn || '',
descriptionEnDisplay: compactEnglishText(item.descriptionEn || '', 34),
images: normalizeImageList(item),
price,
priceValue,
payMethod: buildPayMethodFromPricePlan(item.pricePlanDetail, item.pricePlanDetail && item.pricePlanDetail.payMethod),
tags
}
}
export function normalizeAreaItem(item = {}) {
const roomTypes = Array.isArray(item.roomTypes)
? item.roomTypes
.map((roomType) => normalizeAreaRoomType(roomType))
.filter((roomType) => roomType.code || roomType.name)
: []
const tags = extractLabelValues(item.labelList, item.tags, item.labels, item.featureTags)
return {
code: item.code || '',
name: item.name || '',
nameEn: item.nameEn || '',
nameEnDisplay: compactEnglishText(item.nameEn || '', 24),
description: item.description || '',
descriptionEn: item.descriptionEn || '',
descriptionEnDisplay: compactEnglishText(item.descriptionEn || '', 36),
address: item.address || '',
addressEn: item.addressEn || '',
addressEnDisplay: compactEnglishText(item.addressEn || '', 32),
latitude: item.latitude || item.lat || '',
longitude: item.longitude || item.lng || '',
images: normalizeImageList(item),
tags,
roomTypes
}
}

346
utils/session-sync.js Normal file
View File

@@ -0,0 +1,346 @@
import {
clearBookingState,
clearMyRoomData,
getCurrentUser,
getLoginStatus,
saveBookingList,
saveCurrentBooking,
saveEffectiveContracts,
saveCurrentUser,
saveMyRoomData,
savePendingContractCount,
savePendingContracts,
saveRegisteredUser,
setCheckedInStatus
} from './app-state'
import {
getMyEffectiveContracts,
getMyPendingSignContracts,
orderSearchedMePage
} from '@/api/index.js'
import store from '@/store'
import {
normalizeBookingList,
parseOrderListResponse,
pickCurrentBooking,
pickCurrentStayOrder
} from './booking-helper'
import { getCurrentLanguage } from './i18n'
const SYNC_TTL = 3000
let syncStayStatePromise = null
let lastSyncStayStateAt = 0
function getSessionSyncText(key = '') {
const isEn = getCurrentLanguage() === 'en'
const map = {
pendingContract: isEn ? 'Pending Contract Signature' : '待签合同'
}
return map[key] || ''
}
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 buildLeaseText(order = {}) {
const start = String(order.startDate || order.start || order.checkinDate || '').slice(0, 10)
const end = String(order.endDate || order.end || order.checkoutDate || '').slice(0, 10)
if (start && end) {
return `${start} - ${end}`
}
return start || end || ''
}
function mapOrderToRoomData(order = {}) {
const imageUrls = Array.isArray(order.imageUrls)
? order.imageUrls
: typeof order.imageUrls === 'string'
? order.imageUrls.split(',').map(item => item.trim()).filter(Boolean)
: []
const roomLabel = [
order.buildingName || order.buildingSectionName || order.unitName || '',
order.roomNo || order.roomCode || order.roomName || ''
].filter(Boolean).join(' ')
const title = [
order.areaName || order.campusName || '',
order.roomTypeName || order.roomType || ''
].filter(Boolean).join(' · ')
return {
...order,
id: roomLabel || order.roomNo || order.roomCode || order.orderNo || '',
roomLabel: roomLabel || order.roomNo || order.roomCode || '',
titleZh: title || order.roomTypeName || order.roomName || '当前入住房间',
titleEn: order.roomTypeNameEn || title || order.roomTypeName || 'Current Stay',
leaseText: buildLeaseText(order),
image: order.imageUrl || imageUrls[0] || ''
}
}
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 buildPendingContractQuery() {
const user = getCurrentUser() || {}
const userCode = pickFirstValue(user.userCode, user.code, user.UserCode)
const query = {
ProviderType: 'WechatMiniProgram'
}
if (userCode) {
query.UserCode = userCode
}
return query
}
function enrichBookingLanguageFields(booking = {}) {
if (!booking || typeof booking !== 'object') {
return booking
}
return {
...booking,
statusTextZh: booking.statusTextZh || '待签合同',
statusTextEn: booking.statusTextEn || 'Pending Contract Signature'
}
}
function buildContractStepBooking(pendingContracts = []) {
const contract = Array.isArray(pendingContracts) ? pendingContracts[0] : null
if (!contract || typeof contract !== 'object') {
return null
}
return {
...contract,
orderNo: contract.orderNo || contract.OrderNo || '',
roomName: contract.roomName || contract.roomTypeName || contract.houseTypeName || '',
roomTypeName: contract.roomTypeName || contract.houseTypeName || contract.roomName || '',
startDate: contract.start || contract.startDate || '',
endDate: contract.end || contract.endDate || '',
status: 'contract_pending_sign',
statusKey: 'contract_pending_sign',
statusCode: 10,
statusText: getSessionSyncText('pendingContract'),
statusTextZh: '待签合同',
statusTextEn: 'Pending Contract Signature'
}
}
function syncStoredUser(hasCheckedIn, currentOrder, currentBooking) {
const user = getCurrentUser()
if (!user || typeof user !== 'object' || !Object.keys(user).length) {
return
}
const nextUser = {
...user,
hasCheckedIn,
currentOrder: currentOrder || null,
currentBooking: currentBooking || null,
bookingStatus: currentBooking ? (currentBooking.status || currentBooking.statusKey || 'none') : 'none',
bookingRejectReason: currentBooking ? (currentBooking.rejectReasonText || currentBooking.rejectReason || '') : ''
}
saveCurrentUser(nextUser)
saveRegisteredUser(nextUser)
if (store && store.commit) {
store.commit('$tStore', {
name: 'vuex_user',
value: {
...(store.state && store.state.vuex_user ? store.state.vuex_user : {}),
...nextUser
}
})
}
}
function syncStoreState({
hasCheckedIn = false,
currentOrder = null,
pendingContracts = [],
effectiveContracts = [],
orderList = []
} = {}) {
if (!store || !store.commit) {
return
}
store.commit('$tStore', {
name: 'vuex_checkin_status',
value: !!hasCheckedIn
})
store.commit('$tStore', {
name: 'vuex_current_order',
value: currentOrder || null
})
store.commit('$tStore', {
name: 'vuex_pending_contracts',
value: Array.isArray(pendingContracts) ? pendingContracts : []
})
store.commit('$tStore', {
name: 'vuex_pending_contract_count',
value: Array.isArray(pendingContracts) ? pendingContracts.length : 0
})
store.commit('$tStore', {
name: 'vuex_effective_contracts',
value: Array.isArray(effectiveContracts) ? effectiveContracts : []
})
store.commit('$tStore', {
name: 'vuex_valid_orders',
value: Array.isArray(orderList) ? orderList : []
})
}
function requestStayState(language = 'zh') {
return orderSearchedMePage({
PageIndex: 1,
PageSize: 100,
IsExport: false
}, language)
.then((orderResponse) => {
return Promise.all([
Promise.resolve(orderResponse),
getMyPendingSignContracts(buildPendingContractQuery(), language).catch(() => []),
getMyEffectiveContracts({}, language).catch(() => [])
])
})
.then(([orderResponse, pendingContractsResponse, effectiveContractsResponse]) => {
const orderList = normalizeBookingList(parseOrderListResponse(orderResponse), language)
const currentOrder = pickCurrentStayOrder(orderList)
const pendingContracts = parseContractList(pendingContractsResponse)
const effectiveContracts = parseContractList(effectiveContractsResponse)
const currentBooking = enrichBookingLanguageFields(pickCurrentBooking(orderList) || buildContractStepBooking(pendingContracts))
return {
hasCheckedIn: !!currentOrder,
currentOrder,
currentBooking,
orderList,
pendingContracts,
effectiveContracts
}
})
}
export function syncLoginStayState(options = {}) {
const { language = 'zh', force = false } = options
if (!getLoginStatus()) {
return Promise.resolve({
hasCheckedIn: false,
currentOrder: null,
currentBooking: null,
orderList: []
})
}
const now = Date.now()
if (!force && syncStayStatePromise) {
return syncStayStatePromise
}
if (!force && lastSyncStayStateAt && now - lastSyncStayStateAt < SYNC_TTL) {
return Promise.resolve({
skipped: true
})
}
const syncSource = store && store.dispatch
? store.dispatch('fetchUserGlobalStatus', {
force,
language
})
: requestStayState(language)
syncStayStatePromise = syncSource
.then((result = {}) => {
const orderList = Array.isArray(result.orderList)
? result.orderList
: (Array.isArray(result.validOrders) ? result.validOrders : [])
const pendingContracts = Array.isArray(result.pendingContracts) ? result.pendingContracts : []
const effectiveContracts = Array.isArray(result.effectiveContracts) ? result.effectiveContracts : []
const currentOrder = result.currentOrder || pickCurrentStayOrder(orderList)
const currentBooking = enrichBookingLanguageFields(result.currentBooking || pickCurrentBooking(orderList) || buildContractStepBooking(pendingContracts))
const hasCheckedIn = typeof result.hasCheckedIn === 'boolean'
? result.hasCheckedIn
: !!currentOrder
saveBookingList(orderList)
if (currentBooking) {
saveCurrentBooking(currentBooking)
} else {
clearBookingState()
saveBookingList(orderList)
}
setCheckedInStatus(hasCheckedIn)
savePendingContracts(pendingContracts)
savePendingContractCount(pendingContracts.length)
saveEffectiveContracts(effectiveContracts)
if (hasCheckedIn) {
saveMyRoomData(mapOrderToRoomData(currentOrder))
} else {
clearMyRoomData()
}
syncStoreState({
hasCheckedIn,
currentOrder,
pendingContracts,
effectiveContracts,
orderList
})
syncStoredUser(hasCheckedIn, currentOrder, currentBooking)
lastSyncStayStateAt = Date.now()
return {
hasCheckedIn,
currentOrder,
currentBooking,
orderList,
pendingContracts,
effectiveContracts
}
})
.finally(() => {
syncStayStatePromise = null
})
return syncStayStatePromise
}

809
utils/smart-device.js Normal file
View File

@@ -0,0 +1,809 @@
import { getCurrentLanguage } from './i18n'
function safeJsonParse(value, fallback = null) {
if (typeof value !== 'string') {
return value
}
const text = value.trim()
if (!text) {
return fallback
}
const looksLikeJson = (
(text.startsWith('{') && text.endsWith('}'))
|| (text.startsWith('[') && text.endsWith(']'))
)
if (!looksLikeJson) {
return fallback
}
try {
return JSON.parse(text)
} catch (error) {
return fallback
}
}
function pickFirstFilledValue(...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 getSmartDeviceText(key = '', params = {}) {
const isEn = getCurrentLanguage() === 'en'
const map = {
commonSwitch: isEn ? 'General Switch' : '通用开关',
slideControl: isEn ? 'Slider Control' : '滑动控制',
curtainButton: isEn ? 'Curtain Button' : '窗帘按钮',
unknownFunction: isEn ? 'Unknown Function' : '未知功能',
fullyOpen: isEn ? 'Fully Open' : '已全开',
fullyClosed: isEn ? 'Fully Closed' : '已全关',
paused: isEn ? 'Paused' : '已暂停',
openness: isEn ? `Open Level ${params.value}%` : `开合度 ${params.value}%`,
controllable: isEn ? 'Ready to Control' : '可控制',
currentValue: isEn ? `Current Value ${params.value}%` : `当前值 ${params.value}%`,
adjustable: isEn ? 'Adjustable' : '可调节',
enabled: isEn ? 'Enabled' : '已开启',
disabled: isEn ? 'Disabled' : '已关闭',
unnamedDevice: isEn ? 'Unnamed Device' : '未命名设备'
}
return map[key] || ''
}
function collectObjectArray(value, depth = 0) {
if (!value || depth > 6) {
return []
}
const normalizedValue = typeof value === 'string'
? (safeJsonParse(value, value))
: value
if (Array.isArray(normalizedValue)) {
if (normalizedValue.some(item => item && typeof item === 'object' && !Array.isArray(item))) {
return normalizedValue
}
for (let index = 0; index < normalizedValue.length; index += 1) {
const nestedList = collectObjectArray(normalizedValue[index], depth + 1)
if (nestedList.length) {
return nestedList
}
}
return []
}
if (!normalizedValue || typeof normalizedValue !== 'object') {
return []
}
const candidateKeys = ['data', 'list', 'rows', 'items', 'records', 'result', 'payload', '$values']
for (let index = 0; index < candidateKeys.length; index += 1) {
const key = candidateKeys[index]
if (!Object.prototype.hasOwnProperty.call(normalizedValue, key)) {
continue
}
const nestedList = collectObjectArray(normalizedValue[key], depth + 1)
if (nestedList.length) {
return nestedList
}
}
return []
}
function isSmartDeviceObject(value = {}) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
if (Array.isArray(value.functions)) {
return true
}
const deviceKeys = [
'deviceId',
'deviceNo',
'deviceName',
'platformDeviceId',
'deviceType',
'facilityCode',
'facilityType',
'currentStatus'
]
return deviceKeys.some(key => Object.prototype.hasOwnProperty.call(value, key))
}
function unwrapSmartDevicePayload(value, depth = 0) {
if (!value || depth > 6) {
return {}
}
const normalizedValue = typeof value === 'string'
? (safeJsonParse(value, value))
: value
if (!normalizedValue || typeof normalizedValue !== 'object') {
return {}
}
if (Array.isArray(normalizedValue)) {
for (let index = 0; index < normalizedValue.length; index += 1) {
const nestedDevice = unwrapSmartDevicePayload(normalizedValue[index], depth + 1)
if (Object.keys(nestedDevice).length) {
return nestedDevice
}
}
return {}
}
if (isSmartDeviceObject(normalizedValue)) {
return normalizedValue
}
const candidateKeys = ['data', 'result', 'payload', 'item', 'device', 'record']
for (let index = 0; index < candidateKeys.length; index += 1) {
const key = candidateKeys[index]
if (!Object.prototype.hasOwnProperty.call(normalizedValue, key)) {
continue
}
const nestedDevice = unwrapSmartDevicePayload(normalizedValue[key], depth + 1)
if (Object.keys(nestedDevice).length) {
return nestedDevice
}
}
return {}
}
function isCurtainDeviceLike(device = {}) {
const numericDeviceType = Number(device.deviceType)
if (numericDeviceType === 2) {
return true
}
const name = pickFirstFilledValue(device.deviceName, device.deviceNo).toLowerCase()
return name.includes('窗帘') || name.includes('curtain')
}
function normalizeFunctionCode(functionCode = '', device = {}) {
const normalizedCode = String(functionCode || '').trim().toLowerCase()
if (!normalizedCode) {
return ''
}
if (['light', 'switch', 'common:switch'].includes(normalizedCode)) {
return 'common:switch'
}
if (['slide', 'common:slide'].includes(normalizedCode)) {
return 'common:slide'
}
if (['curtain', 'curtain:button'].includes(normalizedCode)) {
return 'curtain:button'
}
if (normalizedCode === 'button') {
return isCurtainDeviceLike(device) ? 'curtain:button' : 'button'
}
return normalizedCode
}
function normalizeFunctionItem(item = {}, device = {}) {
const schema = item.commandSchema === undefined
? null
: (typeof item.commandSchema === 'string'
? (safeJsonParse(item.commandSchema, item.commandSchema))
: item.commandSchema)
const rawFunctionCode = pickFirstFilledValue(item.functionCode, item.controlType)
const normalizedCode = normalizeFunctionCode(rawFunctionCode, device)
return {
...item,
functionCode: pickFirstFilledValue(item.functionCode, item.controlType),
functionDes: pickFirstFilledValue(item.functionDes, item.description),
controlType: normalizedCode,
commandSchema: schema
}
}
function normalizeSchemaOption(option, index = 0) {
if (option && typeof option === 'object' && !Array.isArray(option)) {
const value = Object.prototype.hasOwnProperty.call(option, 'value')
? option.value
: (
Object.prototype.hasOwnProperty.call(option, 'code')
? option.code
: (Object.prototype.hasOwnProperty.call(option, 'key') ? option.key : index)
)
return {
label: pickFirstFilledValue(option.label, option.text, option.name, option.title, value),
value
}
}
return {
label: String(option),
value: option
}
}
function normalizeSchemaOptions(rawOptions) {
if (!Array.isArray(rawOptions)) {
return []
}
return rawOptions.map((item, index) => normalizeSchemaOption(item, index))
}
function isSchemaMetaKey(key = '') {
return [
'type',
'title',
'description',
'required',
'$schema',
'properties',
'fields',
'params',
'definitions'
].includes(String(key || '').trim())
}
function inferSchemaFieldType(fieldName = '', rawField) {
if (Array.isArray(rawField)) {
return 'enum'
}
if (typeof rawField === 'string') {
const normalizedType = rawField.trim().toLowerCase()
if (['boolean', 'bool'].includes(normalizedType)) {
return 'boolean'
}
if (['number', 'integer', 'int', 'float', 'double'].includes(normalizedType)) {
return 'number'
}
}
if (!rawField || typeof rawField !== 'object') {
return typeof rawField === 'boolean' ? 'boolean' : (typeof rawField === 'number' ? 'number' : 'string')
}
const explicitType = String(
rawField.type
|| rawField.valueType
|| rawField.inputType
|| rawField.dataType
|| ''
).trim().toLowerCase()
if (rawField.enum || rawField.options || rawField.oneOf || rawField.values) {
return 'enum'
}
if (['boolean', 'bool'].includes(explicitType)) {
return 'boolean'
}
if (['number', 'integer', 'int', 'float', 'double'].includes(explicitType)) {
return 'number'
}
if (['string', 'text'].includes(explicitType)) {
return 'string'
}
if (typeof rawField.default === 'boolean') {
return 'boolean'
}
if (typeof rawField.default === 'number') {
return 'number'
}
if (fieldName === 'value' && typeof rawField === 'object' && !Array.isArray(rawField)) {
return 'number'
}
return 'string'
}
function normalizeSchemaField(fieldName = '', rawField = {}) {
const normalizedType = inferSchemaFieldType(fieldName, rawField)
const fieldObject = rawField && typeof rawField === 'object' && !Array.isArray(rawField)
? rawField
: {}
const optionCandidates = fieldObject.enum || fieldObject.options || fieldObject.oneOf || fieldObject.values || rawField
return {
name: String(fieldName || '').trim(),
label: pickFirstFilledValue(
fieldObject.label,
fieldObject.title,
fieldObject.name,
fieldObject.description,
fieldName
),
type: normalizedType,
required: !!fieldObject.required,
min: Number.isFinite(Number(fieldObject.min))
? Number(fieldObject.min)
: (Number.isFinite(Number(fieldObject.minimum)) ? Number(fieldObject.minimum) : undefined),
max: Number.isFinite(Number(fieldObject.max))
? Number(fieldObject.max)
: (Number.isFinite(Number(fieldObject.maximum)) ? Number(fieldObject.maximum) : undefined),
step: Number.isFinite(Number(fieldObject.step))
? Number(fieldObject.step)
: (Number.isFinite(Number(fieldObject.multipleOf)) ? Number(fieldObject.multipleOf) : 1),
placeholder: pickFirstFilledValue(fieldObject.placeholder, fieldObject.hint, fieldObject.description),
defaultValue: Object.prototype.hasOwnProperty.call(fieldObject, 'default') ? fieldObject.default : undefined,
options: normalizeSchemaOptions(optionCandidates)
}
}
function parseCurrentStatus(value) {
if (value === undefined || value === null) {
return null
}
if (typeof value === 'object') {
return value
}
const parsedJson = safeJsonParse(value, null)
if (parsedJson !== null) {
return parsedJson
}
const text = String(value).trim()
if (!text) {
return null
}
if (text === 'true' || text === 'false') {
return {
value: text === 'true'
}
}
const numericValue = Number(text)
if (Number.isFinite(numericValue) && String(numericValue) === text) {
return {
value: numericValue
}
}
return {
text
}
}
function resolveFunctionCurrentStatus(device = {}, preferredControlType = '') {
const functionList = Array.isArray(device.functions) ? device.functions : []
if (!functionList.length) {
return null
}
const preferredCategory = getControlTypeCategory(preferredControlType)
const preferredFunction = functionList.find((item) => {
const itemControlType = pickFirstFilledValue(item && item.controlType, item && item.functionCode)
return getControlTypeCategory(itemControlType) === preferredCategory && parseCurrentStatus(item && item.currentStatus)
})
if (preferredFunction) {
return preferredFunction.currentStatus
}
const fallbackFunction = functionList.find(item => parseCurrentStatus(item && item.currentStatus))
return fallbackFunction ? fallbackFunction.currentStatus : null
}
function resolveDeviceCurrentStatus(device = {}, preferredControlType = '') {
const deviceStatus = parseCurrentStatus(device.currentStatus)
if (deviceStatus) {
return device.currentStatus
}
return resolveFunctionCurrentStatus(device, preferredControlType)
}
const CONTROL_TYPE_CATEGORY_MAP = {
light: 'switch',
switch: 'switch',
'common:switch': 'switch',
slide: 'slide',
'common:slide': 'slide',
curtain: 'curtain',
'curtain:button': 'curtain'
}
export const SUPPORTED_DEVICE_CONTROL_TYPES = ['common:switch', 'common:slide', 'curtain:button', 'light', 'switch', 'curtain']
export function getControlTypeCategory(controlType = '') {
const normalizedType = String(controlType || '').trim().toLowerCase()
return CONTROL_TYPE_CATEGORY_MAP[normalizedType] || normalizedType
}
export function isSupportedDeviceControlType(controlType = '') {
const normalizedType = String(controlType || '').trim().toLowerCase()
return SUPPORTED_DEVICE_CONTROL_TYPES.includes(normalizedType) || ['switch', 'slide', 'curtain'].includes(getControlTypeCategory(normalizedType))
}
export function getDeviceControlLabel(controlType = '') {
const category = getControlTypeCategory(controlType)
if (category === 'switch') {
return getSmartDeviceText('commonSwitch')
}
if (category === 'slide') {
return getSmartDeviceText('slideControl')
}
if (category === 'curtain') {
return getSmartDeviceText('curtainButton')
}
return String(controlType || '').trim() || getSmartDeviceText('unknownFunction')
}
export function inferDeviceControlType(device = {}) {
const numericDeviceType = Number(device.deviceType)
if (numericDeviceType === 2) {
return 'curtain:button'
}
const functionList = Array.isArray(device.functions) ? device.functions : []
const normalizedFunctionTypes = functionList
.map(item => normalizeFunctionCode(
pickFirstFilledValue(item && item.functionCode, item && item.controlType),
device
))
.filter(Boolean)
if (normalizedFunctionTypes.some(item => getControlTypeCategory(item) === 'switch')) {
return 'common:switch'
}
if (normalizedFunctionTypes.some(item => getControlTypeCategory(item) === 'slide')) {
return 'common:slide'
}
if (normalizedFunctionTypes.some(item => getControlTypeCategory(item) === 'curtain')) {
return 'curtain:button'
}
if (numericDeviceType === 1 || numericDeviceType === 0) {
return 'common:switch'
}
const name = pickFirstFilledValue(device.deviceName, device.deviceNo).toLowerCase()
if (name.includes('窗帘') || name.includes('curtain')) {
return 'curtain:button'
}
if (name.includes('开关') || name.includes('switch')) {
return 'common:switch'
}
if (name.includes('亮度') || name.includes('滑块') || name.includes('slider') || name.includes('slide')) {
return 'common:slide'
}
return 'common:switch'
}
export function getDeviceIconClass(controlType = '') {
const category = getControlTypeCategory(controlType)
if (category === 'curtain') {
return 'tn-icon-hardware-fill'
}
if (category === 'slide') {
return 'tn-icon-more-horizontal'
}
if (category === 'switch') {
return 'tn-icon-power'
}
return 'tn-icon-light-fill'
}
export function buildDeviceStateText(device = {}) {
const controlType = inferDeviceControlType(device)
const category = getControlTypeCategory(controlType)
const status = parseCurrentStatus(resolveDeviceCurrentStatus(device, controlType))
if (category === 'curtain' || Number(device.deviceType) === 2) {
const action = pickFirstFilledValue(
status && status.command,
status && status.action,
status && status.state,
status && status.text
).toLowerCase()
const percentValue = Number(
status && (
status.value !== undefined
? status.value
: (status.percent !== undefined ? status.percent : status.position)
)
)
if (action === 'open') {
return getSmartDeviceText('fullyOpen')
}
if (action === 'close') {
return getSmartDeviceText('fullyClosed')
}
if (action === 'stop') {
return getSmartDeviceText('paused')
}
if (Number.isFinite(percentValue)) {
return getSmartDeviceText('openness', { value: percentValue })
}
return getSmartDeviceText('controllable')
}
if (category === 'slide') {
const slideValue = Number(
status && (
status.value !== undefined
? status.value
: (status.percent !== undefined ? status.percent : status.position)
)
)
if (Number.isFinite(slideValue)) {
return getSmartDeviceText('currentValue', { value: slideValue })
}
return getSmartDeviceText('adjustable')
}
const switchValue = status && status.value
if (typeof switchValue === 'boolean') {
return switchValue ? getSmartDeviceText('enabled') : getSmartDeviceText('disabled')
}
const switchCommand = String(
(status && (
status.command !== undefined
? status.command
: (status.action !== undefined ? status.action : status.text)
)) || ''
).trim().toLowerCase()
if (switchCommand === 'on') {
return getSmartDeviceText('enabled')
}
if (switchCommand === 'off') {
return getSmartDeviceText('disabled')
}
if (status && status.text) {
return String(status.text)
}
return getSmartDeviceText('controllable')
}
export function isDeviceActive(device = {}) {
const controlType = inferDeviceControlType(device)
const category = getControlTypeCategory(controlType)
const status = parseCurrentStatus(resolveDeviceCurrentStatus(device, controlType))
if (!status) {
return false
}
if (category === 'curtain' || Number(device.deviceType) === 2) {
const action = pickFirstFilledValue(
status.command,
status.action,
status.state,
status.text
).toLowerCase()
const value = Number(
status.value !== undefined
? status.value
: (status.percent !== undefined ? status.percent : status.position)
)
if (action === 'open') {
return true
}
if (action === 'close') {
return false
}
if (Number.isFinite(value)) {
return value > 0
}
return false
}
if (category === 'slide') {
const value = Number(
status.value !== undefined
? status.value
: (status.percent !== undefined ? status.percent : status.position)
)
return Number.isFinite(value) ? value > 0 : false
}
const switchCommand = String(
(
status.command !== undefined
? status.command
: (status.action !== undefined ? status.action : status.text)
) || ''
).trim().toLowerCase()
if (switchCommand === 'on') {
return true
}
if (switchCommand === 'off') {
return false
}
return !!status.value
}
export function normalizeSmartDevice(item = {}) {
const sourceDevice = unwrapSmartDevicePayload(item)
const functionList = Array.isArray(sourceDevice.functions)
? sourceDevice.functions.filter(entry => entry && typeof entry === 'object').map(entry => normalizeFunctionItem(entry, sourceDevice))
: []
const normalizedDevice = {
...sourceDevice,
functions: functionList
}
const controlType = inferDeviceControlType(normalizedDevice)
const currentStatus = resolveDeviceCurrentStatus(normalizedDevice, controlType)
return {
...normalizedDevice,
currentStatus,
controlType,
iconClass: getDeviceIconClass(controlType),
active: isDeviceActive(normalizedDevice),
stateText: buildDeviceStateText(normalizedDevice),
roomName: pickFirstFilledValue(sourceDevice.roomName),
deviceName: pickFirstFilledValue(sourceDevice.deviceName, sourceDevice.deviceNo, sourceDevice.platformDeviceId, getSmartDeviceText('unnamedDevice'))
}
}
export function getDeviceFunctions(device = {}, includeFallback = true) {
const functionList = Array.isArray(device.functions)
? device.functions.filter(item => item && typeof item === 'object').map(item => normalizeFunctionItem(item, device))
: []
if (functionList.length) {
return functionList
}
if (!includeFallback) {
return []
}
const fallbackType = inferDeviceControlType(device)
return fallbackType
? [{
functionCode: fallbackType,
functionDes: getDeviceControlLabel(fallbackType),
controlType: fallbackType,
commandSchema: null,
isFallback: true
}]
: []
}
export function buildCommandSchemaFields(commandSchema = null) {
if (!commandSchema) {
return []
}
if (Array.isArray(commandSchema)) {
return commandSchema
.map((item, index) => normalizeSchemaField(
pickFirstFilledValue(item && item.name, item && item.key, `field${index + 1}`),
item
))
.filter(item => item.name)
}
if (typeof commandSchema !== 'object') {
return []
}
let schemaSource = null
if (commandSchema.properties && typeof commandSchema.properties === 'object') {
schemaSource = commandSchema.properties
} else if (commandSchema.fields && typeof commandSchema.fields === 'object') {
schemaSource = commandSchema.fields
} else if (commandSchema.params && typeof commandSchema.params === 'object') {
schemaSource = commandSchema.params
} else {
const candidateEntries = Object.keys(commandSchema)
.filter(key => !isSchemaMetaKey(key))
.filter(key => {
const value = commandSchema[key]
return value !== undefined && value !== null && typeof value !== 'function'
})
if (candidateEntries.length) {
schemaSource = candidateEntries.reduce((result, key) => {
result[key] = commandSchema[key]
return result
}, {})
}
}
if (!schemaSource || typeof schemaSource !== 'object') {
return []
}
return Object.keys(schemaSource)
.map(key => normalizeSchemaField(key, schemaSource[key]))
.filter(item => item.name)
}
export function normalizeSmartDeviceList(response) {
const payload = response && response.data !== undefined ? response.data : response
const deviceList = collectObjectArray(payload)
if (deviceList.length) {
return deviceList
.filter(item => item && typeof item === 'object' && !Array.isArray(item))
.map(item => normalizeSmartDevice(item))
}
const singleDevice = unwrapSmartDevicePayload(payload)
return Object.keys(singleDevice).length ? [normalizeSmartDevice(singleDevice)] : []
}
export function patchDeviceStatus(device = {}, command = {}, forcedControlType = '') {
const controlType = String(forcedControlType || '').trim().toLowerCase() || inferDeviceControlType(device)
const category = getControlTypeCategory(controlType)
if (category === 'curtain') {
const action = String(command.command || command.action || command.value || '').trim().toLowerCase()
if (!action) {
return normalizeSmartDevice(device)
}
return normalizeSmartDevice({
...device,
currentStatus: {
command: action
}
})
}
if (category === 'slide') {
const value = Number(
command.value !== undefined
? command.value
: (command.percent !== undefined ? command.percent : command.position)
)
return normalizeSmartDevice({
...device,
currentStatus: {
value: Number.isFinite(value) ? value : 0
}
})
}
const switchCommand = String(
(
command.command !== undefined
? command.command
: (
command.action !== undefined
? command.action
: (
command.value === true
? 'on'
: (command.value === false ? 'off' : command.value)
)
)
) || ''
).trim().toLowerCase()
if (switchCommand) {
return normalizeSmartDevice({
...device,
currentStatus: {
command: switchCommand
}
})
}
return normalizeSmartDevice(device)
}

170
utils/style-data.js Normal file
View File

@@ -0,0 +1,170 @@
export const STYLE_LIST = [
{
id: 'moroccan',
nameZh: '摩洛哥风',
nameEn: 'Moroccan Style',
descZh: '异域度假 · 热情松弛 · 拱门结构',
descEn: 'Exotic Vibe · Relaxing · Arch Design',
longDescZh: '摩洛哥风格以其大胆的色彩碰撞、繁复的几何图案和独特的拱门元素著称。在这里,你可以感受到浓郁的北非风情与地中海的慵懒气息。',
longDescEn: 'Moroccan style is known for its bold colors, intricate geometric patterns, and unique arch elements. Experience the rich North African flair and Mediterranean laziness here.',
tagsZh: ['拱门设计', '异域风情', '色彩碰撞'],
tagsEn: ['Arch Design', 'Exotic', 'Color Clash'],
cover: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_6.png',
plan: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_5.png',
planNameZh: '65方户型平面图',
planNameEn: '65m² Floor Plan',
buildings: ['B1', 'B2', 'B3'],
floors: ['1F', '2F', '3F', '4F', '5F'],
roomTypes: [
{
id: 'moroccan-onebed',
nameZh: '拱门一居套房',
nameEn: 'Arch One Bedroom',
area: '65㎡',
monthlyRent: '4200',
images: [
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_6.png',
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_8.png'
]
},
{
id: 'moroccan-twobed',
nameZh: '庭院双卧套房',
nameEn: 'Courtyard Two Bedroom',
area: '92㎡',
monthlyRent: '5600',
images: [
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_14.png',
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_20.png'
]
}
]
},
{
id: 'southeast',
nameZh: '东南亚风',
nameEn: 'Southeast Asian',
descZh: '自然禅意 · 开放空间 · 原木藤编',
descEn: 'Zen Nature · Open Space · Rattan & Wood',
longDescZh: '大量使用原木、藤编和棉麻等天然材质,结合宽敞的开放式空间设计,营造出一种回归自然、宁静致远的居住体验。',
longDescEn: 'Extensive use of natural materials like wood, rattan, and linen, combined with spacious open design, creates a living experience that returns to nature and tranquility.',
tagsZh: ['原木材质', '自然禅意', '通透采光'],
tagsEn: ['Wood Material', 'Zen', 'Natural Light'],
cover: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_13.png',
plan: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_12.png',
planNameZh: '123方户型平面图',
planNameEn: '123m² Floor Plan',
buildings: ['S1', 'S2'],
floors: ['1F', '2F', '3F'],
roomTypes: [
{
id: 'southeast-garden',
nameZh: '花园阳台房',
nameEn: 'Garden Balcony Suite',
area: '88㎡',
monthlyRent: '4600',
images: [
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_13.png',
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_14.png'
]
},
{
id: 'southeast-family',
nameZh: '藤编家庭套房',
nameEn: 'Rattan Family Suite',
area: '123㎡',
monthlyRent: '6200',
images: [
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_20.png',
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_12.png'
]
}
]
},
{
id: 'european',
nameZh: '简欧风格',
nameEn: 'Modern European',
descZh: '俄式简约 · 典雅通透 · 奶油底色',
descEn: 'Russian Minimalist · Elegant · Cream Tone',
longDescZh: '摒弃了传统欧式的繁琐,保留了经典的线条与对称美学。以低饱和度的奶油色为基调,展现出一种低调而不失优雅的生活态度。',
longDescEn: 'Discarding the cumbersomeness of traditional European style, it retains classic lines and symmetrical aesthetics. Based on low-saturation cream colors, it shows a low-key yet elegant life attitude.',
tagsZh: ['经典线条', '低调优雅', '奶油色调'],
tagsEn: ['Classic Lines', 'Elegant', 'Cream Tone'],
cover: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_19.png',
plan: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_18.png',
planNameZh: '143方户型平面图',
planNameEn: '143m² Floor Plan',
buildings: ['E1', 'E2', 'E3'],
floors: ['1F', '2F', '3F', '4F'],
roomTypes: [
{
id: 'european-suite',
nameZh: '奶油轻奢套房',
nameEn: 'Cream Luxury Suite',
area: '96㎡',
monthlyRent: '5200',
images: [
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_19.png',
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_18.png'
]
},
{
id: 'european-premium',
nameZh: '对称大平层',
nameEn: 'Symmetry Premium Suite',
area: '143㎡',
monthlyRent: '7600',
images: [
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_19.png',
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_14.png'
]
}
]
},
{
id: 'modern',
nameZh: '现代风',
nameEn: 'Modern Minimalist',
descZh: '极简高级 · 动线流畅 · 隐形收纳',
descEn: 'Premium Minimal · Smooth Flow · Hidden Storage',
longDescZh: '追求极致的简约与实用,强调空间的动线流畅与收纳的隐蔽性。黑白灰的经典搭配,彰显出都市精英的干练与品味。',
longDescEn: 'Pursuing extreme simplicity and practicality, emphasizing smooth spatial flow and hidden storage. The classic match of black, white, and gray highlights the capability and taste of urban elites.',
tagsZh: ['隐蔽收纳', '动线流畅', '极简主义'],
tagsEn: ['Hidden Storage', 'Smooth Flow', 'Minimalism'],
cover: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_25.png',
plan: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_24.png',
planNameZh: '65方户型平面图',
planNameEn: '65m² Floor Plan',
buildings: ['M1', 'M2'],
floors: ['1F', '2F', '3F', '4F', '5F', '6F'],
roomTypes: [
{
id: 'modern-studio',
nameZh: '极简单身公寓',
nameEn: 'Minimal Studio',
area: '65㎡',
monthlyRent: '4300',
images: [
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_25.png',
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_24.png'
]
},
{
id: 'modern-loft',
nameZh: '高层 loft 套房',
nameEn: 'High-rise Loft',
area: '98㎡',
monthlyRent: '5800',
images: [
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_24.png',
'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_25.png'
]
}
]
}
]
export function getStyleById(styleId) {
return STYLE_LIST.find(item => item.id === styleId) || STYLE_LIST[0]
}