feat: 新增工单中心、房间巡查、失物招领等业务模块

- 新增工单中心(ticket_center)与工单工作台(ticket_workbench)页面
- 新增房间巡查(room_inspection)与失物招领发布(lost_found_publish)页面
- 预约看样流程新增房型/楼栋/楼层选择器组件
- 新增工具模块: booking-entry、privacy、reservation-flow
- api/index.js 补充楼层列表、看样提交等接口
- locales/messages.js 补充多语言文案
- manifest.json 开启微信小程序隐私检查
- 完善登录、注册、申请详情、账单等相关页面交互
This commit is contained in:
liangjian
2026-07-24 09:35:46 +08:00
parent 9fea127de1
commit 445bd850f4
36 changed files with 9281 additions and 340 deletions

View File

@@ -67,6 +67,18 @@ function buildRequestCacheKey(prefix, params = {}, language) {
return `${prefix}:${language}:${JSON.stringify(normalizeRequestCacheValue(params || {}))}` return `${prefix}:${language}:${JSON.stringify(normalizeRequestCacheValue(params || {}))}`
} }
function createJsonPatchRequest(url, data = {}, language) {
return request({
url,
method: 'POST',
data,
header: {
'Content-Type': 'application/json-patch+json',
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
const notificationPageRequestCache = new Map() const notificationPageRequestCache = new Map()
const notificationPageResponseCache = new Map() const notificationPageResponseCache = new Map()
const NOTIFICATION_PAGE_CACHE_TTL = 1200 const NOTIFICATION_PAGE_CACHE_TTL = 1200
@@ -121,6 +133,17 @@ export function getEnums(params = {}, language) {
}) })
} }
export function getFloorList(params = {}, language) {
return request({
url: '/floor/list',
method: 'GET',
params,
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
// --- 用户与登录 --- // --- 用户与登录 ---
export function usersLogin(data = {}, language) { export function usersLogin(data = {}, language) {
return request({ return request({
@@ -169,6 +192,18 @@ export function submitApplicationForm(data = {}, language) {
}) })
} }
export function submitViewingApplicationForm(data = {}, language) {
return request({
url: '/applicationform/viewing/submit',
method: 'POST',
data,
header: {
'Content-Type': 'application/json-patch+json',
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function registrationApplicationMePage(params = {}, language) { export function registrationApplicationMePage(params = {}, language) {
return request({ return request({
url: '/applicationform/registration/me/page', url: '/applicationform/registration/me/page',
@@ -202,6 +237,110 @@ export function applicationFormMePage(params = {}, language) {
}) })
} }
// --- 工单 ---
export function ticketOrderPage(params = {}, language) {
return request({
url: '/ticketorder/page',
method: 'GET',
params,
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function ticketOrderMyPage(params = {}, language) {
return request({
url: '/ticketorder/my/page',
method: 'GET',
params,
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function ticketOrderDetail(flowNo, language) {
return request({
url: `/ticketorder/${flowNo}/flows`,
method: 'GET',
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function ticketOrderFlows(orderNo, language) {
return request({
url: `/ticketorder/${orderNo}/flows`,
method: 'GET',
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function ticketOrderTransitionOptions(flowNo, language) {
return request({
url: `/ticketorder/${flowNo}/transition-options`,
method: 'GET',
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function ticketFlowTransitionStepRuleSelectTypeItems(params = {}, language) {
return request({
url: '/ticketflowtransitionsteprule/selecttypeitems',
method: 'GET',
params,
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function ticketOrderFixonhold(data = {}, language) {
return createJsonPatchRequest('/ticketorder/fixonhold', data, language)
}
export function ticketOrderTransit(data = {}, language) {
return createJsonPatchRequest('/ticketorder/transit', data, language)
}
export function ticketOrderSubmitChangeroom(data = {}, language) {
return createJsonPatchRequest('/ticketordersubmit/changeroom', data, language)
}
export function ticketOrderSubmitCheckout(data = {}, language) {
return createJsonPatchRequest('/ticketordersubmit/checkout', data, language)
}
export function ticketOrderSubmitRepair(data = {}, language) {
return createJsonPatchRequest('/ticketordersubmit/repair', data, language)
}
export function ticketOrderSubmitSuggestion(data = {}, language) {
return createJsonPatchRequest('/ticketordersubmit/suggestion', data, language)
}
export function ticketOrderSubmitRenewal(data = {}, language) {
return createJsonPatchRequest('/ticketordersubmit/renewal', data, language)
}
export function ticketOrderSubmitWaterdelivery(data = {}, language) {
return createJsonPatchRequest('/ticketordersubmit/waterdelivery', data, language)
}
export function ticketOrderSubmitCleaning(data = {}, language) {
return createJsonPatchRequest('/ticketordersubmit/cleaning', data, language)
}
export function lostAndFoundPublish(data = {}, language) {
return createJsonPatchRequest('/lostandfound/publish', data, language)
}
// --- 订单与入住 --- // --- 订单与入住 ---
export function orderSearchedHistoryPage(params = {}, language) { export function orderSearchedHistoryPage(params = {}, language) {
return request({ return request({
@@ -225,6 +364,17 @@ export function orderSearchedMePage(params = {}, language) {
}) })
} }
export function orderSearchedMyPage(params = {}, language) {
return request({
url: '/order/searched/me/page',
method: 'GET',
params: params || {},
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function orderSearchedMeHistoryPage(params = {}, language) { export function orderSearchedMeHistoryPage(params = {}, language) {
return request({ return request({
url: '/order/searched/me/history/page', url: '/order/searched/me/history/page',
@@ -268,6 +418,64 @@ export function orderSearchedReservationPage(params = {}, language) {
}) })
} }
export function orderSearchedReservationFlow(data = {}, language) {
return request({
url: '/order/searched/reservation/flow',
method: 'POST',
data,
header: {
'Content-Type': 'application/json-patch+json',
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function roomInspectionMyPage(params = {}, language) {
return request({
url: '/roominspection/my/page',
method: 'GET',
params: params || {},
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function roomInspectionDetail(params = {}, language) {
return request({
url: '/roominspection/detail',
method: 'GET',
params: params || {},
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function roomInspectionUpdateItems(data = {}, language) {
return request({
url: '/roominspection/update/items',
method: 'POST',
data,
header: {
'Content-Type': 'application/json-patch+json',
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function roomInspectionSubmit(data = {}, language) {
return request({
url: '/roominspection/submit',
method: 'POST',
data,
header: {
'Content-Type': 'application/json-patch+json',
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function checkRecordByChecked(params = {}, language) { export function checkRecordByChecked(params = {}, language) {
const query = params || {} const query = params || {}
const orderNo = String(query.orderNo || '').trim() const orderNo = String(query.orderNo || '').trim()
@@ -300,6 +508,24 @@ export function accountQueryBills(params = {}, language) {
}) })
} }
export function accountQueryBill(params = {}, language) {
const query = params || {}
const billNo = String(query.billNo || query.BillNo || '').trim()
if (!billNo) {
return Promise.reject(new Error('账单号不能为空'))
}
return request({
url: '/accountquery/bill',
method: 'GET',
params: {
billNo
},
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function paymentBillPay(data = {}, language) { export function paymentBillPay(data = {}, language) {
const payload = data || {} const payload = data || {}
return request({ return request({
@@ -385,6 +611,17 @@ export function getMyEffectiveContracts(params = {}, language) {
}) })
} }
export function contractDetail(params = {}, language) {
return request({
url: '/contract/detail',
method: 'GET',
params,
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function contractPreview(data = {}, language) { export function contractPreview(data = {}, language) {
return request({ return request({
url: '/contract/preview', url: '/contract/preview',
@@ -484,6 +721,38 @@ export function facilityManagers(params = {}, language) {
}) })
} }
// --- 宿管与管理员联系人 ---
export function myDormitoryManagers(language) {
return request({
url: '/usercampus/my-dormitory-managers',
method: 'GET',
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function myCounselor(language) {
return request({
url: '/usercampus/my-counselor',
method: 'GET',
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function buildingPagination(params = {}, language) {
return request({
url: '/building/pagination',
method: 'GET',
params,
header: {
'X-Requested-Language': resolveLanguageHeader(language)
}
})
}
export function roomtypeList(params = {}, language) { export function roomtypeList(params = {}, language) {
return request({ return request({
url: '/roomtype/list', url: '/roomtype/list',

View File

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

View File

@@ -25,8 +25,12 @@ const messages = {
"pkgService/message_detail/message_detail": "Message Detail", "pkgService/message_detail/message_detail": "Message Detail",
"pkgService/my_application/my_application": "My Applications", "pkgService/my_application/my_application": "My Applications",
"pkgService/my_contract/my_contract": "My Contracts", "pkgService/my_contract/my_contract": "My Contracts",
"pkgService/room_inspection/room_inspection": "Inspection",
"pkgService/service_stub/service_stub": "Service Center", "pkgService/service_stub/service_stub": "Service Center",
"pkgService/smart_control/smart_control": "Smart Control", "pkgService/smart_control/smart_control": "Smart Control",
"pkgService/ticket_center/ticket_center": "Work Orders",
"pkgService/ticket_workbench/ticket_workbench": "Work Order",
"pkgService/lost_found_publish/lost_found_publish": "Lost and Found",
"pkgService/submit_request/submit_request": "Submit Request", "pkgService/submit_request/submit_request": "Submit Request",
"pkgService/utility_recharge/utility_recharge": "Utility Recharge" "pkgService/utility_recharge/utility_recharge": "Utility Recharge"
} }
@@ -92,6 +96,7 @@ const messages = {
cancelOrderSuccess: "Order canceled", cancelOrderSuccess: "Order canceled",
cancelOrderTitle: "Cancel Order", cancelOrderTitle: "Cancel Order",
continuePay: "Continue Pay", continuePay: "Continue Pay",
currentStepTag: "Current",
dateRangeJoiner: " to ", dateRangeJoiner: " to ",
depositNoticeTitle: "Rental Deposit Notice", depositNoticeTitle: "Rental Deposit Notice",
emptyDesc: "You have not submitted any application yet. Go select a room to start a stay or viewing application.", emptyDesc: "You have not submitted any application yet. Go select a room to start a stay or viewing application.",
@@ -102,11 +107,21 @@ const messages = {
loadingDesc: "Syncing your application records. Please wait.", loadingDesc: "Syncing your application records. Please wait.",
missingApplicationCode: "Missing application number", missingApplicationCode: "Missing application number",
missingBillCode: "Missing application bill number", missingBillCode: "Missing application bill number",
missingContractNo: "Missing contract number",
noPendingDepositBill: "No pending deposit bill available",
noMore: "No more applications", noMore: "No more applications",
occupantLabel: "Resident", occupantLabel: "Resident",
pageDesc: "Your stay and viewing application records", pageDesc: "Your stay and viewing application records",
pageTitle: "My Applications", pageTitle: "My Applications",
payDeposit: "Pay Deposit", payDeposit: "Pay Deposit",
fetchPaymentParamsFailed: "Failed to get payment parameters",
fetchWechatLoginCodeFailed: "Failed to get WeChat login code",
loadingBill: "Loading bill...",
loadingPayment: "Opening payment...",
depositPaymentDesc: "Deposit Payment",
paymentSuccess: "Payment succeeded",
paymentCanceled: "Payment canceled",
paymentFailed: "Payment failed",
pendingPaymentAmountLabel: "Order Amount", pendingPaymentAmountLabel: "Order Amount",
pendingPaymentDescLabel: "Description", pendingPaymentDescLabel: "Description",
pendingPaymentExpireLabel: "Time Left", pendingPaymentExpireLabel: "Time Left",
@@ -118,8 +133,20 @@ const messages = {
refreshEmptySuccess: "No application found. Reservation records synced.", refreshEmptySuccess: "No application found. Reservation records synced.",
refreshSuccess: "Application status refreshed", refreshSuccess: "Application status refreshed",
refreshing: "Refreshing...", refreshing: "Refreshing...",
refreshAction: "Refresh",
remarkLabel: "Remarks", remarkLabel: "Remarks",
reservationFlowDesc: "Progress of the current reservation order",
reservationFlowTitle: "Reservation Flow",
reservationOrderFallback: "Reservation",
reservationOrderNoLabel: "Order No.",
roomNoLabel: "Room No.",
reservationRoomLabel: "Room Type",
reservationStatusLabel: "Order Status",
contractPendingReviewHint: "Contract signed, waiting for review",
inspectionPendingReviewHint: "Inspection under review",
checkinPendingHint: "Move-in is being processed, please wait",
scrollToRead: "Scroll to bottom", scrollToRead: "Scroll to bottom",
refreshFlowAction: "Refresh Flow",
statusApproved: "Approved", statusApproved: "Approved",
statusFinished: "Closed", statusFinished: "Closed",
statusPending: "Pending", statusPending: "Pending",
@@ -249,9 +276,14 @@ const messages = {
loadingManagerInfo: "Loading butler info", loadingManagerInfo: "Loading butler info",
loadingShort: "Syncing", loadingShort: "Syncing",
managerContactSummary: "{count} butlers are available in this area", managerContactSummary: "{count} butlers are available in this area",
managementContacts: "Dorm Managers & Admins",
dormitoryManagerTag: "Dorm Mgr",
counselorTag: "Admin",
noManagerContacts: "No management contacts",
managerFallback: "Community Butler", managerFallback: "Community Butler",
managerInitialFallback: "B", managerInitialFallback: "B",
managerWechatCopied: "Butler Wechat copied", managerWechatCopied: "Butler Wechat copied",
mealDeliveryJumpFailed: "Failed to open the ordering mini-program",
multiButlerName: "{name} +{count}", multiButlerName: "{name} +{count}",
myBill: "Monthly Bill", myBill: "Monthly Bill",
noButlerInfo: "No butler information yet", noButlerInfo: "No butler information yet",
@@ -268,7 +300,7 @@ const messages = {
pendingContractFound: "Pending contract found", pendingContractFound: "Pending contract found",
pendingSync: "Pending", pendingSync: "Pending",
primaryButlerTag: "Lead", primaryButlerTag: "Lead",
publicService: "Facility Booking", publicService: "Premium Services",
refreshFailed: "Refresh failed, please try again later", refreshFailed: "Refresh failed, please try again later",
refreshStatus: "Refresh Status", refreshStatus: "Refresh Status",
refreshing: "Refreshing...", refreshing: "Refreshing...",
@@ -366,6 +398,7 @@ const messages = {
}, },
register: { register: {
register: { register: {
admissionNoticeAttachmentLabel: "Admission Notice (Employment Certificate)",
accountLabel: "Account", accountLabel: "Account",
accountPlaceholder: "Enter account name", accountPlaceholder: "Enter account name",
accountSection: "Account Information", accountSection: "Account Information",
@@ -436,6 +469,7 @@ const messages = {
submitRegister: "Submit Registration", submitRegister: "Submit Registration",
submitting: "Submitting...", submitting: "Submitting...",
uploadFailed: "Upload failed, please retry", uploadFailed: "Upload failed, please retry",
uploadAdmissionNotice: "Upload admission notice or employment certificate",
uploadIdBack: "Upload ID card back", uploadIdBack: "Upload ID card back",
uploadIdFront: "Upload ID card front", uploadIdFront: "Upload ID card front",
uploadImageOption: "Upload Image", uploadImageOption: "Upload Image",
@@ -521,6 +555,7 @@ const messages = {
duration5: "Over 1 year", duration5: "Over 1 year",
durationLabel: "Expected Stay Duration", durationLabel: "Expected Stay Duration",
navTitle: "On-site Viewing", navTitle: "On-site Viewing",
roomTypeLabel: "Preferred Room Type",
remarkArea: "Preferred Area: {area}", remarkArea: "Preferred Area: {area}",
remarkBudget: "Budget: {budget} CNY/month", remarkBudget: "Budget: {budget} CNY/month",
remarkDuration: "Expected Stay: {duration}", remarkDuration: "Expected Stay: {duration}",
@@ -795,6 +830,10 @@ const messages = {
leaseDurationKey: "Expected Stay Duration", leaseDurationKey: "Expected Stay Duration",
leaseTermLabel: "Lease Term", leaseTermLabel: "Lease Term",
missingBillCode: "Missing application bill number", missingBillCode: "Missing application bill number",
missingContractNo: "Missing contract number",
inspectionPendingReviewHint: "Inspection under review",
checkinPendingHint: "Move-in is being processed, please wait",
refreshAction: "Refresh",
navTitle: "Application Detail", navTitle: "Application Detail",
paymentMethodKey: "Payment Method", paymentMethodKey: "Payment Method",
preferredAreaKey: "Preferred Area", preferredAreaKey: "Preferred Area",
@@ -1147,8 +1186,62 @@ const messages = {
unsupportedSwitch: "This device does not support quick switching" unsupportedSwitch: "This device does not support quick switching"
} }
}, },
lost_found_publish: {
lost_found_publish: {
navTitle: "Lost and Found",
heroBadge: "Community Service",
heroTitle: "Publish lost or found item details",
heroDesc: "Share lost or found information with the community so that follow-up and matching can happen faster.",
formTitle: "Item Information",
formDesc: "Please complete the key details below. Required fields help others contact you faster.",
typeLabel: "Publish Type",
typePlaceholder: "Select lost or found",
typeLost: "Lost Item",
typeFound: "Found Item",
itemTypeLabel: "Item Category",
itemTypePlaceholder: "For example: documents, keys, electronics",
itemNameLabel: "Item Name",
itemNamePlaceholder: "Please enter the item name",
placeLabel: "Location",
placePlaceholder: "Please enter where it was lost or found",
whenLabel: "Time",
selectDate: "Select date",
selectTime: "Select time",
whenTip: "Optional. Fill in the approximate time if available.",
contractLabel: "Contact Person",
contractPlaceholder: "Please enter the contact person",
contractPhoneLabel: "Contact Phone",
contractPhonePlaceholder: "Please enter the contact phone",
remarkLabel: "Remarks",
remarkPlaceholder: "Add more details such as appearance, claim notes, or identifying features",
imagesLabel: "Images",
uploadImage: "Upload Image",
imagesTip: "Optional. Upload on-site or item photos to help identify the item.",
submitTip: "Please verify the information before publishing. We will submit it with the current language.",
submitting: "Submitting...",
submitButton: "Publish Now",
uploadFailed: "Image upload failed",
uploadOversize: "Each image must be within 5 MB",
uploadExceed: "You can upload up to 6 images",
validateItemType: "Please enter the item category",
validateItemName: "Please enter the item name",
validatePlace: "Please enter the location",
validateContract: "Please enter the contact person",
validateContractPhone: "Please enter the contact phone",
submitSuccess: "Published successfully",
submitFailed: "Failed to publish"
}
},
submit_request: { submit_request: {
components: { components: {
BuildingSelector: {
emptyText: "No buildings available",
loadFailed: "Failed to load buildings",
loadingText: "Loading buildings...",
popupTitle: "Select Building",
selectAreaFirst: "Please select an area first",
selectBuilding: "Select building"
},
DateRangeSelector: { DateRangeSelector: {
dateDivider: "to", dateDivider: "to",
endLabel: "Expected Check-out", endLabel: "Expected Check-out",
@@ -1156,6 +1249,14 @@ const messages = {
startLabel: "Expected Check-in", startLabel: "Expected Check-in",
startPlaceholder: "Select check-in date" startPlaceholder: "Select check-in date"
}, },
FloorSelector: {
clearSelection: "Not specified",
emptyText: "No floors available",
loadFailed: "Failed to load floor list",
loadingText: "Loading floors...",
popupTitle: "Select Floor",
selectFloor: "Select floor"
},
TagSelector: { TagSelector: {
emptyText: "No tags available", emptyText: "No tags available",
loadingText: "Loading tags..." loadingText: "Loading tags..."
@@ -1167,12 +1268,14 @@ const messages = {
applyRemarkPlaceholder: "Enter your additional notes", applyRemarkPlaceholder: "Enter your additional notes",
applyRoomType: "Requested Room Type", applyRoomType: "Requested Room Type",
areaLabel: "Area", areaLabel: "Area",
buildingLabel: "Preferred Building",
currentRoom: "Current Room", currentRoom: "Current Room",
dateCannotBefore: "{endLabel} cannot be earlier than {startLabel}", dateCannotBefore: "{endLabel} cannot be earlier than {startLabel}",
emptyHobbyTags: "No hobby tags", emptyHobbyTags: "No hobby tags",
emptyLifestyleTags: "No lifestyle tags", emptyLifestyleTags: "No lifestyle tags",
expectedCheckin: "Expected Check-in", expectedCheckin: "Expected Check-in",
expectedCheckout: "Expected Checkout", expectedCheckout: "Expected Checkout",
floorLabel: "Preferred Floor",
hobbyLabel: "Hobbies", hobbyLabel: "Hobbies",
leaseRange: "Lease Period", leaseRange: "Lease Period",
lifestyleLabel: "Lifestyle Habits", lifestyleLabel: "Lifestyle Habits",
@@ -1184,6 +1287,10 @@ const messages = {
roomTypeApplyBlockTitle: "Application Details", roomTypeApplyBlockTitle: "Application Details",
roomTypeApplyDesc: "Confirm the room type, area, preference tags, and lease period before submitting.", roomTypeApplyDesc: "Confirm the room type, area, preference tags, and lease period before submitting.",
roomTypeApplyTitle: "Room Type Application", roomTypeApplyTitle: "Room Type Application",
switchRoomType: "Switch",
summaryTitle: "Application Summary",
expandSummary: "Expand",
collapseSummary: "Collapse",
selectCheckinDate: "Select check-in date", selectCheckinDate: "Select check-in date",
selectCheckoutDate: "Select checkout date", selectCheckoutDate: "Select checkout date",
selectDateEnd: "Please select {label}", selectDateEnd: "Please select {label}",
@@ -1231,8 +1338,12 @@ const messages = {
"pkgService/message_detail/message_detail": "消息详情", "pkgService/message_detail/message_detail": "消息详情",
"pkgService/my_application/my_application": "我的申请", "pkgService/my_application/my_application": "我的申请",
"pkgService/my_contract/my_contract": "我的合同", "pkgService/my_contract/my_contract": "我的合同",
"pkgService/room_inspection/room_inspection": "验房",
"pkgService/service_stub/service_stub": "业务办理", "pkgService/service_stub/service_stub": "业务办理",
"pkgService/smart_control/smart_control": "智能控制", "pkgService/smart_control/smart_control": "智能控制",
"pkgService/ticket_center/ticket_center": "工单中心",
"pkgService/ticket_workbench/ticket_workbench": "工单处理",
"pkgService/lost_found_publish/lost_found_publish": "失物拾物",
"pkgService/submit_request/submit_request": "我要提交", "pkgService/submit_request/submit_request": "我要提交",
"pkgService/utility_recharge/utility_recharge": "水电充值" "pkgService/utility_recharge/utility_recharge": "水电充值"
} }
@@ -1298,6 +1409,7 @@ const messages = {
cancelOrderSuccess: "订单已取消", cancelOrderSuccess: "订单已取消",
cancelOrderTitle: "取消订单", cancelOrderTitle: "取消订单",
continuePay: "继续支付", continuePay: "继续支付",
currentStepTag: "当前",
dateRangeJoiner: " 至 ", dateRangeJoiner: " 至 ",
depositNoticeTitle: "租房定金须知", depositNoticeTitle: "租房定金须知",
emptyDesc: "您还没有提交任何申请,去选房后可发起入住申请或预约看房。", emptyDesc: "您还没有提交任何申请,去选房后可发起入住申请或预约看房。",
@@ -1308,11 +1420,21 @@ const messages = {
loadingDesc: "正在同步您的入住申请记录,请稍候。", loadingDesc: "正在同步您的入住申请记录,请稍候。",
missingApplicationCode: "申请编号缺失", missingApplicationCode: "申请编号缺失",
missingBillCode: "申请单编号缺失", missingBillCode: "申请单编号缺失",
missingContractNo: "合同编号缺失",
noPendingDepositBill: "暂无待支付定金账单",
noMore: "没有更多申请了", noMore: "没有更多申请了",
occupantLabel: "入住人", occupantLabel: "入住人",
pageDesc: "您的入住和预约看房申请记录", pageDesc: "您的入住和预约看房申请记录",
pageTitle: "我的申请", pageTitle: "我的申请",
payDeposit: "支付定金", payDeposit: "支付定金",
fetchPaymentParamsFailed: "获取支付参数失败",
fetchWechatLoginCodeFailed: "获取微信登录凭证失败",
loadingBill: "正在获取账单...",
loadingPayment: "正在拉起支付...",
depositPaymentDesc: "支付定金",
paymentSuccess: "支付成功",
paymentCanceled: "已取消支付",
paymentFailed: "支付失败",
pendingPaymentAmountLabel: "订单金额", pendingPaymentAmountLabel: "订单金额",
pendingPaymentDescLabel: "支付说明", pendingPaymentDescLabel: "支付说明",
pendingPaymentExpireLabel: "剩余支付时间", pendingPaymentExpireLabel: "剩余支付时间",
@@ -1324,8 +1446,20 @@ const messages = {
refreshEmptySuccess: "未查到申请单,已同步预订单", refreshEmptySuccess: "未查到申请单,已同步预订单",
refreshSuccess: "申请状态已刷新", refreshSuccess: "申请状态已刷新",
refreshing: "刷新中...", refreshing: "刷新中...",
refreshAction: "刷新",
remarkLabel: "补充说明", remarkLabel: "补充说明",
reservationFlowDesc: "当前预定单的流程进度",
reservationFlowTitle: "预定流程",
reservationOrderFallback: "预定单",
reservationOrderNoLabel: "预定单号",
roomNoLabel: "房间号",
reservationRoomLabel: "房型",
reservationStatusLabel: "订单状态",
contractPendingReviewHint: "合同已签,等待宿管审核",
inspectionPendingReviewHint: "验房审批中",
checkinPendingHint: "宿管正在办理入住,请耐心等待",
scrollToRead: "请滑动阅读至底部", scrollToRead: "请滑动阅读至底部",
refreshFlowAction: "刷新流程",
statusApproved: "已通过", statusApproved: "已通过",
statusFinished: "已结束", statusFinished: "已结束",
statusPending: "待审核", statusPending: "待审核",
@@ -1455,9 +1589,14 @@ const messages = {
loadingManagerInfo: "正在加载管家信息", loadingManagerInfo: "正在加载管家信息",
loadingShort: "同步中", loadingShort: "同步中",
managerContactSummary: "当前区域共有 {count} 位管家可联系", managerContactSummary: "当前区域共有 {count} 位管家可联系",
managementContacts: "宿管与管理员",
dormitoryManagerTag: "宿管",
counselorTag: "管理员",
noManagerContacts: "暂无管理联系人",
managerFallback: "社区管家", managerFallback: "社区管家",
managerInitialFallback: "管", managerInitialFallback: "管",
managerWechatCopied: "管家微信已复制", managerWechatCopied: "管家微信已复制",
mealDeliveryJumpFailed: "打开点餐小程序失败",
multiButlerName: "{name} 等{count}位管家", multiButlerName: "{name} 等{count}位管家",
myBill: "本月账单", myBill: "本月账单",
noButlerInfo: "暂未获取到管家信息", noButlerInfo: "暂未获取到管家信息",
@@ -1474,7 +1613,7 @@ const messages = {
pendingContractFound: "发现待签合同", pendingContractFound: "发现待签合同",
pendingSync: "待同步", pendingSync: "待同步",
primaryButlerTag: "主责", primaryButlerTag: "主责",
publicService: "社区设施预约", publicService: "公寓精选服务",
refreshFailed: "刷新失败,请稍后重试", refreshFailed: "刷新失败,请稍后重试",
refreshStatus: "刷新状态", refreshStatus: "刷新状态",
refreshing: "刷新中...", refreshing: "刷新中...",
@@ -1572,6 +1711,7 @@ const messages = {
}, },
register: { register: {
register: { register: {
admissionNoticeAttachmentLabel: "入学通知书(在职证明)附件",
accountLabel: "登录账号", accountLabel: "登录账号",
accountPlaceholder: "请输入登录账号", accountPlaceholder: "请输入登录账号",
accountSection: "账号信息", accountSection: "账号信息",
@@ -1642,6 +1782,7 @@ const messages = {
submitRegister: "提交注册申请", submitRegister: "提交注册申请",
submitting: "提交中...", submitting: "提交中...",
uploadFailed: "上传失败,请重试", uploadFailed: "上传失败,请重试",
uploadAdmissionNotice: "点击上传入学通知书/在职证明",
uploadIdBack: "点击上传身份证反面", uploadIdBack: "点击上传身份证反面",
uploadIdFront: "点击上传身份证正面", uploadIdFront: "点击上传身份证正面",
uploadImageOption: "上传图片", uploadImageOption: "上传图片",
@@ -1727,6 +1868,7 @@ const messages = {
duration5: "1年以上", duration5: "1年以上",
durationLabel: "预计租住时长", durationLabel: "预计租住时长",
navTitle: "预约现场带看", navTitle: "预约现场带看",
roomTypeLabel: "意向房型",
remarkArea: "意向区域:{area}", remarkArea: "意向区域:{area}",
remarkBudget: "租金预算:{budget} 元/月", remarkBudget: "租金预算:{budget} 元/月",
remarkDuration: "预计租住时长:{duration}", remarkDuration: "预计租住时长:{duration}",
@@ -1745,7 +1887,7 @@ const messages = {
selectViewingTimeDesc: "请选择您方便的时间段", selectViewingTimeDesc: "请选择您方便的时间段",
submitBooking: "提交预约", submitBooking: "提交预约",
submitFailed: "预约提交失败", submitFailed: "预约提交失败",
submitSuccessContent: "您的预约看房申请已提交。\n系统会自动转发给对应区域有房间权限的宿管,宿管确认带看后可直接完结订单,或选定房型房号后转为预定单发送给租客支付。", submitSuccessContent: "您的预约看房申请已提交,请等待管家联系",
submitSuccessTitle: "预约成功", submitSuccessTitle: "预约成功",
submitting: "提交中...", submitting: "提交中...",
today: "今天", today: "今天",
@@ -2001,6 +2143,10 @@ const messages = {
leaseDurationKey: "预计租住时长", leaseDurationKey: "预计租住时长",
leaseTermLabel: "预计租期", leaseTermLabel: "预计租期",
missingBillCode: "申请单编号缺失", missingBillCode: "申请单编号缺失",
missingContractNo: "合同编号缺失",
inspectionPendingReviewHint: "验房审批中",
checkinPendingHint: "宿管正在办理入住,请耐心等待",
refreshAction: "刷新",
navTitle: "申请详情", navTitle: "申请详情",
paymentMethodKey: "支付方式", paymentMethodKey: "支付方式",
preferredAreaKey: "意向区域", preferredAreaKey: "意向区域",
@@ -2353,8 +2499,62 @@ const messages = {
unsupportedSwitch: "该设备不支持快捷开关" unsupportedSwitch: "该设备不支持快捷开关"
} }
}, },
lost_found_publish: {
lost_found_publish: {
navTitle: "失物拾物",
heroBadge: "社区服务",
heroTitle: "发布失物或招领信息",
heroDesc: "将失物或拾获信息同步给社区,方便后续核实与快速匹配。",
formTitle: "物品信息",
formDesc: "请补充关键字段,必填项越完整,越方便他人联系与认领。",
typeLabel: "发布类型",
typePlaceholder: "请选择失物或招领",
typeLost: "失物",
typeFound: "招领",
itemTypeLabel: "物品类型",
itemTypePlaceholder: "例如证件、钥匙、电子设备",
itemNameLabel: "物品名称",
itemNamePlaceholder: "请输入物品名称",
placeLabel: "地点",
placePlaceholder: "请输入丢失或拾取地点",
whenLabel: "时间",
selectDate: "选择日期",
selectTime: "选择时间",
whenTip: "可选,若已知大致时间可一并填写。",
contractLabel: "联系人",
contractPlaceholder: "请输入联系人",
contractPhoneLabel: "联系电话",
contractPhonePlaceholder: "请输入联系电话",
remarkLabel: "备注",
remarkPlaceholder: "可补充外观特征、认领说明或其他辅助信息",
imagesLabel: "现场图片",
uploadImage: "上传图片",
imagesTip: "可选,上传现场图或物品图可提升识别效率。",
submitTip: "发布前请确认信息准确,系统会按当前语言提交接口。",
submitting: "发布中...",
submitButton: "立即发布",
uploadFailed: "图片上传失败",
uploadOversize: "单张图片不能超过 5 MB",
uploadExceed: "最多上传 6 张图片",
validateItemType: "请输入物品类型",
validateItemName: "请输入物品名称",
validatePlace: "请输入地点",
validateContract: "请输入联系人",
validateContractPhone: "请输入联系电话",
submitSuccess: "发布成功",
submitFailed: "发布失败"
}
},
submit_request: { submit_request: {
components: { components: {
BuildingSelector: {
emptyText: "暂无可选楼栋",
loadFailed: "楼栋列表加载失败",
loadingText: "楼栋加载中...",
popupTitle: "选择意向楼栋",
selectAreaFirst: "请先选择区域",
selectBuilding: "请选择楼栋"
},
DateRangeSelector: { DateRangeSelector: {
dateDivider: "至", dateDivider: "至",
endLabel: "预计离开", endLabel: "预计离开",
@@ -2362,6 +2562,14 @@ const messages = {
startLabel: "预计入住", startLabel: "预计入住",
startPlaceholder: "请选择入住日期" startPlaceholder: "请选择入住日期"
}, },
FloorSelector: {
clearSelection: "不选择",
emptyText: "暂无可选楼层",
loadFailed: "楼层列表加载失败",
loadingText: "楼层加载中...",
popupTitle: "选择意向楼层",
selectFloor: "请选择楼层"
},
TagSelector: { TagSelector: {
emptyText: "暂无标签", emptyText: "暂无标签",
loadingText: "标签加载中..." loadingText: "标签加载中..."
@@ -2373,12 +2581,14 @@ const messages = {
applyRemarkPlaceholder: "请输入您的申请补充说明", applyRemarkPlaceholder: "请输入您的申请补充说明",
applyRoomType: "申请房型", applyRoomType: "申请房型",
areaLabel: "所属区域", areaLabel: "所属区域",
buildingLabel: "意向楼栋",
currentRoom: "当前房间", currentRoom: "当前房间",
dateCannotBefore: "{endLabel}不能早于{startLabel}", dateCannotBefore: "{endLabel}不能早于{startLabel}",
emptyHobbyTags: "暂无兴趣爱好标签", emptyHobbyTags: "暂无兴趣爱好标签",
emptyLifestyleTags: "暂无生活习惯标签", emptyLifestyleTags: "暂无生活习惯标签",
expectedCheckin: "预计入住", expectedCheckin: "预计入住",
expectedCheckout: "预计离开", expectedCheckout: "预计离开",
floorLabel: "意向楼层",
hobbyLabel: "兴趣爱好", hobbyLabel: "兴趣爱好",
leaseRange: "租期时间", leaseRange: "租期时间",
lifestyleLabel: "生活习惯", lifestyleLabel: "生活习惯",
@@ -2390,6 +2600,10 @@ const messages = {
roomTypeApplyBlockTitle: "房型申请信息", roomTypeApplyBlockTitle: "房型申请信息",
roomTypeApplyDesc: "请确认房型、区域、偏好标签和租期信息后提交申请。", roomTypeApplyDesc: "请确认房型、区域、偏好标签和租期信息后提交申请。",
roomTypeApplyTitle: "房型申请", roomTypeApplyTitle: "房型申请",
switchRoomType: "切换",
summaryTitle: "申请摘要",
expandSummary: "展开",
collapseSummary: "收起",
selectCheckinDate: "请选择入住日期", selectCheckinDate: "请选择入住日期",
selectCheckoutDate: "请选择离开日期", selectCheckoutDate: "请选择离开日期",
selectDateEnd: "请选择{label}", selectDateEnd: "请选择{label}",

View File

@@ -51,11 +51,15 @@
/* 小程序特有相关 */ /* 小程序特有相关 */
"mp-weixin" : { "mp-weixin" : {
"appid" : "wx92fd9a1cd56f1185", "appid" : "wx92fd9a1cd56f1185",
"__usePrivacyCheck__" : true,
"setting" : { "setting" : {
"urlCheck" : false "urlCheck" : false,
"minified" : true,
"postcss" : true
}, },
"usingComponents" : true, "usingComponents" : true,
"requiredPrivateInfos" : [ "chooseAddress" ] "requiredPrivateInfos" : [ "chooseAddress" ],
"permission" : {}
}, },
"mp-alipay" : { "mp-alipay" : {
"usingComponents" : true "usingComponents" : true

View File

@@ -96,6 +96,12 @@
"navigationBarTitleText": "My Contracts" "navigationBarTitleText": "My Contracts"
} }
}, },
{
"path": "room_inspection/room_inspection",
"style": {
"navigationBarTitleText": "Inspection"
}
},
{ {
"path": "contract_detail/contract_detail", "path": "contract_detail/contract_detail",
"style": { "style": {
@@ -114,6 +120,24 @@
"navigationBarTitleText": "Message Detail" "navigationBarTitleText": "Message Detail"
} }
}, },
{
"path": "ticket_center/ticket_center",
"style": {
"navigationBarTitleText": "Work Orders"
}
},
{
"path": "ticket_workbench/ticket_workbench",
"style": {
"navigationBarTitleText": "Work Order"
}
},
{
"path": "lost_found_publish/lost_found_publish",
"style": {
"navigationBarTitleText": "Lost and Found"
}
},
{ {
"path": "submit_request/submit_request", "path": "submit_request/submit_request",
"style": { "style": {

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
<template> <template>
<view class="profile-page tn-safe-area-inset-bottom"> <view class="profile-page tn-safe-area-inset-bottom">
<!-- 头部背景与用户信息 (悬浮质感设计) --> <!-- 头部背景与用户信息 (悬浮质感设计) -->
@@ -574,6 +574,11 @@ export default {
url: '/pkgService/utility_recharge/utility_recharge' url: '/pkgService/utility_recharge/utility_recharge'
}); });
}, },
goToTicketCenter() {
uni.navigateTo({
url: '/pkgService/ticket_center/ticket_center'
})
},
goToSubmitRequest(title, desc, payload = {}, titleZh = '', titleEn = '', descZh = '', descEn = '') { goToSubmitRequest(title, desc, payload = {}, titleZh = '', titleEn = '', descZh = '', descEn = '') {
uni.navigateTo({ uni.navigateTo({
url: `/pkgService/submit_request/submit_request?title=${encodeURIComponent(title || this.t('submitRequest'))}&titleZh=${encodeURIComponent(titleZh || title || this.t('submitRequest'))}&titleEn=${encodeURIComponent(titleEn || title || this.t('submitRequest'))}&desc=${encodeURIComponent(desc || this.t('completeFormBeforeSubmit'))}&descZh=${encodeURIComponent(descZh || desc || this.t('completeFormBeforeSubmit'))}&descEn=${encodeURIComponent(descEn || desc || this.t('completeFormBeforeSubmit'))}&payload=${encodeURIComponent(JSON.stringify(payload || {}))}` url: `/pkgService/submit_request/submit_request?title=${encodeURIComponent(title || this.t('submitRequest'))}&titleZh=${encodeURIComponent(titleZh || title || this.t('submitRequest'))}&titleEn=${encodeURIComponent(titleEn || title || this.t('submitRequest'))}&desc=${encodeURIComponent(desc || this.t('completeFormBeforeSubmit'))}&descZh=${encodeURIComponent(descZh || desc || this.t('completeFormBeforeSubmit'))}&descEn=${encodeURIComponent(descEn || desc || this.t('completeFormBeforeSubmit'))}&payload=${encodeURIComponent(JSON.stringify(payload || {}))}`
@@ -608,7 +613,7 @@ export default {
return return
} }
if (item.key === 'serviceOrder') { if (item.key === 'serviceOrder') {
this.handleApplicationEntry() this.goToTicketCenter()
return return
} }
if (item.key === 'registrationRecord') { if (item.key === 'registrationRecord') {

View File

@@ -1,10 +1,10 @@
<template> <template>
<view class="service-page tn-safe-area-inset-bottom"> <view class="service-page tn-safe-area-inset-bottom">
<tn-nav-bar :isBack="false" :bottomShadow="false" backgroundColor="transparent" :customBackStyle="{ transition: 'all 0.3s ease' }"> <tn-nav-bar :isBack="false" :bottomShadow="false" :backgroundColor="navBarBackgroundColor" :customBackStyle="{ transition: 'all 0.3s ease' }">
<view class="tn-text-bold tn-text-xl tn-color-black">{{ t('title') }}</view> <view class="tn-text-bold tn-text-xl tn-color-black">{{ t('title') }}</view>
</tn-nav-bar> </tn-nav-bar>
<scroll-view class="service-scroll" scroll-y :style="{paddingTop: vuex_custom_bar_height + 'px'}"> <scroll-view class="service-scroll" scroll-y :style="{paddingTop: vuex_custom_bar_height + 'px'}" @scroll="handleScroll">
<view v-if="!hasCheckedIn" class="empty-state tn-padding animation-fade-in"> <view v-if="!hasCheckedIn" class="empty-state tn-padding animation-fade-in">
<!-- 无预订:去选房 --> <!-- 无预订:去选房 -->
<view v-if="!currentBooking" class="no-booking-card"> <view v-if="!currentBooking" class="no-booking-card">
@@ -112,21 +112,6 @@
<!-- 已入住状态下的服务大厅内容 --> <!-- 已入住状态下的服务大厅内容 -->
<view v-else class="animation-fade-in tn-padding-bottom"> <view v-else class="animation-fade-in tn-padding-bottom">
<view class="tn-margin-top tn-margin-left tn-margin-right">
<view class="tn-text-bold tn-text tn-color-black tn-margin-bottom">{{ t('stayService') }}</view>
<view class="tn-flex tn-flex-row-between">
<view
v-for="item in stayActions"
:key="item.key"
class="stay-action-card"
@click="openServicePage(item)"
>
<view class="stay-action-icon"><text :class="item.icon" class="stay-action-icon-symbol"></text></view>
<view class="stay-action-title tn-margin-top-sm">{{ pickItemText(item, 'label', 'labelEn') }}</view>
</view>
</view>
</view>
<!-- 融合房卡与智能控制的新卡片 --> <!-- 融合房卡与智能控制的新卡片 -->
<view class="integrated-room-card tn-margin-left tn-margin-right tn-margin-top"> <view class="integrated-room-card tn-margin-left tn-margin-right tn-margin-top">
<!-- 房卡背景 --> <!-- 房卡背景 -->
@@ -181,6 +166,32 @@
</view> </view>
<view class="tn-margin-top tn-margin-left tn-margin-right"> <view class="tn-margin-top tn-margin-left tn-margin-right">
<view class="tn-text-bold tn-text tn-color-black tn-margin-bottom">{{ t('stayService') }}</view>
<view class="tn-flex tn-flex-wrap tn-flex-row-between">
<view class="service-item tn-margin-bottom-sm" v-for="item in stayActions" :key="item.key" @click="openServicePage(item)">
<view class="service-card-visual">
<image :src="item.image" mode="aspectFill" class="service-card-bg"></image>
<view class="service-card-mask"></view>
<view class="service-card-content tn-padding-sm tn-flex tn-flex-direction-column tn-flex-row-between" style="height: 100%;">
<view class="tn-flex tn-flex-row-between tn-flex-col-center">
<view class="service-icon-box tn-flex tn-flex-col-center tn-flex-row-center">
<text :class="item.icon" class="service-icon-symbol"></text>
</view>
<view class="status-tag tn-text-xs" :class="item.isBusy ? 'tag-busy' : 'tag-free'">
{{ pickItemText(item, 'statusZh', 'statusEn') }}
</view>
</view>
<view>
<view class="tn-text-bold tn-text tn-color-white">{{ pickItemText(item, 'label', 'labelEn') }}</view>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="tn-margin-top-sm tn-margin-left tn-margin-right">
<view class="tn-flex tn-flex-row-between tn-flex-col-bottom tn-margin-bottom"> <view class="tn-flex tn-flex-row-between tn-flex-col-bottom tn-margin-bottom">
<view class="tn-text-bold tn-text tn-color-black">{{ t('publicService') }}</view> <view class="tn-text-bold tn-text tn-color-black">{{ t('publicService') }}</view>
<view class="tn-text-sm tn-color-gray">{{ t('viewAll') }} <text class="tn-icon-right section-arrow-icon"></text></view> <view class="tn-text-sm tn-color-gray">{{ t('viewAll') }} <text class="tn-icon-right section-arrow-icon"></text></view>
@@ -238,36 +249,38 @@
</view> </view>
</view> </view>
<!-- 专属管家 (原活动位置) --> <!-- 宿管与管理员列表 -->
<view class="butler-card-new tn-margin-top tn-margin-left tn-margin-right"> <view class="butler-card-new tn-margin-top tn-margin-left tn-margin-right">
<view class="tn-text-bold tn-text tn-color-black tn-margin-bottom">{{ t('butler') }}</view> <view class="tn-text-bold tn-text tn-color-black tn-margin-bottom">{{ t('managementContacts') }}</view>
<view class="butler-card-new__inner"> <view class="butler-card-new__inner">
<view v-if="areaManagers.length" class="butler-card-new__list"> <view v-if="managerContacts.length" class="butler-card-new__list">
<view <view
v-for="(manager, index) in areaManagers" v-for="(contact, index) in managerContacts"
:key="manager.userCode || manager.userName || manager.name || index" :key="contact.contactKey || index"
class="butler-member-new" class="butler-member-new"
> >
<view class="butler-member-new__identity"> <view class="butler-member-new__identity">
<view class="butler-avatar-new"> <view class="butler-avatar-new">
<image v-if="getManagerAvatar(manager)" :src="getManagerAvatar(manager)" mode="aspectFill"></image> <image v-if="getManagerAvatar(contact)" :src="getManagerAvatar(contact)" mode="aspectFill"></image>
<view v-else class="butler-avatar-new-fallback tn-flex tn-flex-col-center tn-flex-row-center"> <view v-else class="butler-avatar-new-fallback tn-flex tn-flex-col-center tn-flex-row-center">
<text class="butler-avatar-new-fallback-text">{{ getManagerInitial(manager) }}</text> <text class="butler-avatar-new-fallback-text">{{ getManagerInitial(contact) }}</text>
</view> </view>
<view class="online-dot"></view> <view class="online-dot"></view>
</view> </view>
<view class="butler-member-new__info"> <view class="butler-member-new__info">
<view class="butler-member-new__name"> <view class="butler-member-new__name">
{{ getManagerName(manager) }} {{ getManagerName(contact) }}
<text v-if="index === 0" class="butler-member-new__tag">{{ t('primaryButlerTag') }}</text> <text class="butler-member-new__tag butler-member-new__tag--type" :class="contact.contactType === 'counselor' ? 'tag-counselor' : 'tag-dormitory'">
{{ contact.contactType === 'counselor' ? t('counselorTag') : t('dormitoryManagerTag') }}
</text>
</view> </view>
<view class="butler-member-new__phone">{{ getManagerPhone(manager) || t('noContactPhone') }}</view> <view class="butler-member-new__phone">{{ getManagerPhone(contact) || t('noContactPhone') }}</view>
</view> </view>
</view> </view>
<view <view
class="butler-member-new__action" class="butler-member-new__action"
:class="{ 'disabled': !getManagerPhone(manager) }" :class="{ 'disabled': !getManagerPhone(contact) }"
@click.stop="handleAreaManagerCall(manager)" @click.stop="handleAreaManagerCall(contact)"
> >
<text class="tn-icon-phone-fill"></text> <text class="tn-icon-phone-fill"></text>
</view> </view>
@@ -278,12 +291,12 @@
<view class="butler-member-new__identity"> <view class="butler-member-new__identity">
<view class="butler-avatar-new"> <view class="butler-avatar-new">
<view class="butler-avatar-new-fallback tn-flex tn-flex-col-center tn-flex-row-center"> <view class="butler-avatar-new-fallback tn-flex tn-flex-col-center tn-flex-row-center">
<text class="tn-icon-service-fill" style="font-size: 32rpx; color: #fff;"></text> <text class="tn-icon-my-fill" style="font-size: 32rpx; color: #fff;"></text>
</view> </view>
</view> </view>
<view class="butler-member-new__info"> <view class="butler-member-new__info">
<view class="butler-member-new__name">{{ t('butler') }}</view> <view class="butler-member-new__name">{{ t('managementContacts') }}</view>
<view class="butler-member-new__phone">{{ isAreaManagerLoading ? t('loadingButlerInfo') : t('noButlerInfo') }}</view> <view class="butler-member-new__phone">{{ isManagementContactsLoading ? t('loadingButlerInfo') : t('noManagerContacts') }}</view>
</view> </view>
</view> </view>
</view> </view>
@@ -296,7 +309,7 @@
</template> </template>
<script> <script>
import { accountQueryBills, facilityManagers, gatewayRoomDeviceList, orderSearchedDetail } from '../../../api/index.js' import { accountQueryBills, facilityManagers, myDormitoryManagers, myCounselor, gatewayRoomDeviceList, orderSearchedDetail } from '../../../api/index.js'
import { getSessionSnapshot, saveMyRoomData } from '../../../utils/app-state' import { getSessionSnapshot, saveMyRoomData } from '../../../utils/app-state'
import { ACCOUNT_BILL_PAYMENT_STATUS, ACCOUNT_BILL_STATUS, fetchEnums, getAccountReferenceTypeLabel, getApplicationDetailStatusLabel } from '../../../utils/enum-store' import { ACCOUNT_BILL_PAYMENT_STATUS, ACCOUNT_BILL_STATUS, fetchEnums, getAccountReferenceTypeLabel, getApplicationDetailStatusLabel } from '../../../utils/enum-store'
import { getBookingStatusText } from '../../../utils/flow' import { getBookingStatusText } from '../../../utils/flow'
@@ -400,14 +413,62 @@ export default {
areaManager: null, areaManager: null,
areaManagers: [], areaManagers: [],
isAreaManagerLoading: false, isAreaManagerLoading: false,
// 宿管与管理员联系人
dormitoryManagers: [],
counselor: null,
isManagementContactsLoading: false,
// 导航栏滚动背景色
navBarBackgroundColor: 'transparent',
unpaidBillList: [], unpaidBillList: [],
smartDevices: [], smartDevices: [],
isSmartDevicesLoading: false, isSmartDevicesLoading: false,
smartDeviceError: '', smartDeviceError: '',
stayActions: [ stayActions: [
{ key: 'change-room', label: '换房申请', labelEn: 'Change Room', icon: 'tn-icon-home-smile-fill', title: '换房申请', titleEn: 'Change Room Request', titleEnShort: 'Room Change', desc: '提交换房原因、期望房型和管家审核说明。', descEn: 'Submit your reason, expected room type, and butler review notes.' }, {
{ key: 'renew-stay', label: '续住申请', labelEn: 'Renew Stay', icon: 'tn-icon-calendar-fill', title: '续住申请', titleEn: 'Renew Stay Request', titleEnShort: 'Renew Stay', desc: '提交续住时长、续住原因和新的租期需求。', descEn: 'Submit extension duration, reason, and new lease request.' }, key: 'change-room',
{ key: 'checkout', label: '退房申请', labelEn: 'Check Out', icon: 'tn-icon-home-in-fill', title: '退房申请', titleEn: 'Checkout Request', titleEnShort: 'Check Out', desc: '提交退房时间、离场说明和验房申请。', descEn: 'Submit checkout time, leave notes, and inspection request.' } label: '换房申请',
labelEn: 'Change Room',
icon: 'tn-icon-home-smile-fill',
statusZh: '可提交',
statusEn: 'Available',
isBusy: false,
image: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_11.png',
title: '换房申请',
titleEn: 'Change Room Request',
titleEnShort: 'Room Change',
desc: '提交换房原因、期望房型和管家审核说明。',
descEn: 'Submit your reason, expected room type, and butler review notes.'
},
{
key: 'renew-stay',
label: '续住申请',
labelEn: 'Renew Stay',
icon: 'tn-icon-calendar-fill',
statusZh: '可提交',
statusEn: 'Available',
isBusy: false,
image: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_16.png',
title: '续住申请',
titleEn: 'Renew Stay Request',
titleEnShort: 'Renew Stay',
desc: '提交续住时长、续住原因和新的租期需求。',
descEn: 'Submit extension duration, reason, and new lease request.'
},
{
key: 'checkout',
label: '退房申请',
labelEn: 'Check Out',
icon: 'tn-icon-home-in-fill',
statusZh: '可提交',
statusEn: 'Available',
isBusy: false,
image: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_17.png',
title: '退房申请',
titleEn: 'Checkout Request',
titleEnShort: 'Check Out',
desc: '提交退房时间、离场说明和验房申请。',
descEn: 'Submit checkout time, leave notes, and inspection request.'
}
], ],
services: [ services: [
{ {
@@ -440,6 +501,21 @@ export default {
desc: '填写备注说明需要几桶水后提交申请。', desc: '填写备注说明需要几桶水后提交申请。',
descEn: 'Submit the quantity of water needed and any notes.' descEn: 'Submit the quantity of water needed and any notes.'
}, },
{
key: 'meal-delivery',
icon: 'tn-icon-cart-fill',
nameZh: '送餐服务',
nameEn: 'Food Delivery',
statusZh: '可下单',
statusEn: 'Order Now',
isBusy: false,
image: 'https://hi.aizhuhotel.com/%E7%95%99%E5%AD%A6%E7%94%9F%E5%85%AC%E5%AF%93%20-%200320%281%29_converted_15.png',
title: '送餐服务',
titleEn: 'Food Delivery',
titleEnShort: 'Food',
desc: '一键跳转点餐小程序,挑选餐品并下单配送。',
descEn: 'Open the ordering mini-program to pick meals and get delivery.'
},
{ {
key: 'cleaning', key: 'cleaning',
icon: 'tn-icon-clean', icon: 'tn-icon-clean',
@@ -503,7 +579,7 @@ export default {
butler: '专属社区管家', butler: '专属社区管家',
online: '当前在线 · 响应时间 < 5分钟', online: '当前在线 · 响应时间 < 5分钟',
callNow: '拨打', callNow: '拨打',
publicService: '社区设施预约', publicService: '公寓精选服务',
viewAll: '全部', viewAll: '全部',
leaseTerm: '租期', leaseTerm: '租期',
activeStatus: '使用中', activeStatus: '使用中',
@@ -525,6 +601,10 @@ export default {
noContactPhone: '暂无联系电话', noContactPhone: '暂无联系电话',
loadingButlerInfo: '正在同步管家信息...', loadingButlerInfo: '正在同步管家信息...',
noButlerInfo: '暂未获取到管家信息', noButlerInfo: '暂未获取到管家信息',
managementContacts: '宿管与管理员',
dormitoryManagerTag: '宿管',
counselorTag: '管理员',
noManagerContacts: '暂无管理联系人',
managerContactSummary: '当前区域共有 {count} 位管家可联系', managerContactSummary: '当前区域共有 {count} 位管家可联系',
contactPhonePrefix: '联系电话 ', contactPhonePrefix: '联系电话 ',
loadingShort: '同步中', loadingShort: '同步中',
@@ -583,7 +663,7 @@ export default {
butler: 'Community Butler', butler: 'Community Butler',
online: 'Online · Response < 5 mins', online: 'Online · Response < 5 mins',
callNow: 'Call', callNow: 'Call',
publicService: 'Facility Booking', publicService: 'Premium Services',
viewAll: 'View All', viewAll: 'View All',
leaseTerm: 'Lease', leaseTerm: 'Lease',
activeStatus: 'Active', activeStatus: 'Active',
@@ -605,6 +685,10 @@ export default {
noContactPhone: 'No contact number', noContactPhone: 'No contact number',
loadingButlerInfo: 'Syncing butler info...', loadingButlerInfo: 'Syncing butler info...',
noButlerInfo: 'No butler information yet', noButlerInfo: 'No butler information yet',
managementContacts: 'Dorm Managers & Admins',
dormitoryManagerTag: 'Dorm Mgr',
counselorTag: 'Admin',
noManagerContacts: 'No management contacts',
managerContactSummary: '{count} butlers are available in this area', managerContactSummary: '{count} butlers are available in this area',
contactPhonePrefix: 'Phone ', contactPhonePrefix: 'Phone ',
loadingShort: 'Syncing', loadingShort: 'Syncing',
@@ -884,6 +968,25 @@ export default {
butlerPhoneNumber() { butlerPhoneNumber() {
const manager = this.primaryAreaManager || {} const manager = this.primaryAreaManager || {}
return this.getManagerPhone(manager) return this.getManagerPhone(manager)
},
// 合并宿管列表与管理员(辅导员)为统一联系人列表
managerContacts() {
const contacts = Array.isArray(this.dormitoryManagers)
? this.dormitoryManagers.map((item, index) => ({
...this.normalizeManager(item),
contactType: 'dormitory-manager',
contactKey: String(item.code || item.userCode || item.phone || index)
}))
: []
const counselor = this.counselor
if (counselor && typeof counselor === 'object' && (counselor.name || counselor.phone || counselor.code)) {
contacts.push({
...this.normalizeManager(counselor),
contactType: 'counselor',
contactKey: String(counselor.code || counselor.userCode || counselor.phone || 'counselor')
})
}
return contacts
} }
}, },
mounted() { mounted() {
@@ -1123,6 +1226,58 @@ export default {
return this._loadAreaManagerPromise return this._loadAreaManagerPromise
}, },
// 获取宿管列表与管理员(辅导员)联系人
fetchManagementContacts() {
if (!this.isCurrentLogin()) {
this.dormitoryManagers = []
this.counselor = null
return Promise.resolve()
}
if (this._fetchManagementContactsPromise) {
return this._fetchManagementContactsPromise
}
this.isManagementContactsLoading = true
this._fetchManagementContactsPromise = Promise.allSettled([
myDormitoryManagers(this.language),
myCounselor(this.language)
])
.then(([dormitoryManagersResult, counselorResult]) => {
this.dormitoryManagers = dormitoryManagersResult.status === 'fulfilled' && Array.isArray(dormitoryManagersResult.value && dormitoryManagersResult.value.data)
? dormitoryManagersResult.value.data
: []
this.counselor = counselorResult.status === 'fulfilled' && counselorResult.value && counselorResult.value.data && typeof counselorResult.value.data === 'object'
? counselorResult.value.data
: null
if (dormitoryManagersResult.status === 'rejected') {
console.warn('获取宿管联系人失败', dormitoryManagersResult.reason)
}
if (counselorResult.status === 'rejected') {
console.warn('获取管理员信息失败', counselorResult.reason)
}
})
.catch((error) => {
this.dormitoryManagers = []
this.counselor = null
console.warn('获取管理联系人失败', error)
})
.finally(() => {
this.isManagementContactsLoading = false
this._fetchManagementContactsPromise = null
})
return this._fetchManagementContactsPromise
},
// 滚动事件处理:顶部导航栏加背景色
handleScroll(e) {
const scrollTop = (e && e.detail && e.detail.scrollTop) || 0
if (scrollTop > 20) {
this.navBarBackgroundColor = '#F8FAFF'
} else {
this.navBarBackgroundColor = 'transparent'
}
},
fetchCurrentOrderDetail() { fetchCurrentOrderDetail() {
if (!this.hasCheckedIn) { if (!this.hasCheckedIn) {
return Promise.resolve() return Promise.resolve()
@@ -1165,10 +1320,14 @@ export default {
this.hasCheckedIn = false this.hasCheckedIn = false
this.currentBooking = null this.currentBooking = null
this.myRoomData = null this.myRoomData = null
this.dormitoryManagers = []
this.counselor = null
return return
} }
this.currentBooking = storeUser.currentBooking || session.currentBooking || null this.currentBooking = storeUser.currentBooking || session.currentBooking || null
// 已登录即获取宿管与管理员联系人
this.fetchManagementContacts()
if (this.getStoreCheckinStatus() || session.hasCheckedIn) { if (this.getStoreCheckinStatus() || session.hasCheckedIn) {
this.hasCheckedIn = true this.hasCheckedIn = true
this.myRoomData = buildRoomDataFromOrder(storeCurrentOrder) || buildRoomDataFromOrder(session.myRoomData) || session.myRoomData || null this.myRoomData = buildRoomDataFromOrder(storeCurrentOrder) || buildRoomDataFromOrder(session.myRoomData) || session.myRoomData || null
@@ -1294,17 +1453,65 @@ export default {
} }
return `/pkgService/submit_request/submit_request?title=${encodeURIComponent(title || this.t('submitRequest'))}&titleZh=${encodeURIComponent(titleZh)}&titleEn=${encodeURIComponent(titleEn)}&desc=${encodeURIComponent(desc || this.t('completeFormBeforeSubmit'))}&descZh=${encodeURIComponent(descZh)}&descEn=${encodeURIComponent(descEn)}&payload=${encodeURIComponent(JSON.stringify(payload))}` return `/pkgService/submit_request/submit_request?title=${encodeURIComponent(title || this.t('submitRequest'))}&titleZh=${encodeURIComponent(titleZh)}&titleEn=${encodeURIComponent(titleEn)}&desc=${encodeURIComponent(desc || this.t('completeFormBeforeSubmit'))}&descZh=${encodeURIComponent(descZh)}&descEn=${encodeURIComponent(descEn)}&payload=${encodeURIComponent(JSON.stringify(payload))}`
}, },
buildTicketWorkbenchUrl(item = {}) {
const serviceTypeMap = {
repair: 'TicketRepairApply',
complaint: 'TicketSuggestionApply',
'change-room': 'TicketChangeApply',
checkout: 'TicketCheckOutApply',
'renew-stay': 'TicketRenewalApply',
'water-delivery': 'TicketWaterDeliveryApply',
cleaning: 'TicketCleaningApply'
}
const serviceType = serviceTypeMap[item.key]
if (!serviceType) {
return ''
}
return `/pkgService/ticket_workbench/ticket_workbench?serviceType=${encodeURIComponent(serviceType)}`
},
openServicePage(item) { openServicePage(item) {
if (item.key === 'meal-delivery') {
this.openMealDeliveryMiniProgram()
return
}
if (item.key === 'utility') { if (item.key === 'utility') {
uni.navigateTo({ uni.navigateTo({
url: '/pkgService/utility_recharge/utility_recharge' url: '/pkgService/utility_recharge/utility_recharge'
}); });
return return
} }
if (item.key === 'lost-found') {
uni.navigateTo({
url: '/pkgService/lost_found_publish/lost_found_publish'
});
return
}
const ticketUrl = this.buildTicketWorkbenchUrl(item)
if (ticketUrl) {
uni.navigateTo({
url: ticketUrl
});
return
}
uni.navigateTo({ uni.navigateTo({
url: this.buildServiceSubmitUrl(item) url: this.buildServiceSubmitUrl(item)
}); });
}, },
// 送餐服务:跳转外部点餐小程序
openMealDeliveryMiniProgram() {
uni.navigateToMiniProgram({
appId: 'wxf221ee13f2abd417',
path: 'pages/scan/index?scene=1005&toMenuList=5&entityId=00684887&multiMenuId=006848879dd2e038019e017ba6e330d5',
success: () => {},
fail: (error) => {
console.error('跳转送餐小程序失败', error)
uni.showToast({
title: this.t('mealDeliveryJumpFailed'),
icon: 'none'
})
}
})
},
getManagerName(manager = {}) { getManagerName(manager = {}) {
return manager.displayName || manager.name || manager.userName || manager.nickname || manager.userCode || manager.phone || '' return manager.displayName || manager.name || manager.userName || manager.nickname || manager.userCode || manager.phone || ''
}, },
@@ -1400,6 +1607,10 @@ export default {
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%); background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
min-height: 100vh; min-height: 100vh;
} }
/* 导航栏背景色过渡 */
.service-page ::v-deep .tn-custom-nav-bar__bar {
transition: background-color 0.3s ease;
}
.service-scroll { .service-scroll {
height: calc(100vh - 100rpx); height: calc(100vh - 100rpx);
} }
@@ -1729,43 +1940,6 @@ export default {
box-shadow: 0 6rpx 14rpx rgba($brand-primary, 0.16); box-shadow: 0 6rpx 14rpx rgba($brand-primary, 0.16);
} }
.stay-action-card {
width: 31%;
background: #ffffff;
border-radius: 24rpx;
padding: 24rpx 12rpx;
text-align: center;
box-shadow: 0 10rpx 26rpx rgba(0, 0, 0, 0.03);
}
.stay-action-icon {
width: 72rpx;
height: 72rpx;
margin: 0 auto;
border-radius: 50%;
background: linear-gradient(135deg, $icon-surface-brand-soft 0%, rgba($brand-primary, 0.04) 100%);
color: $icon-tone-brand-deep;
display: flex;
align-items: center;
justify-content: center;
font-size: 34rpx;
box-shadow: inset 0 0 0 1rpx rgba($brand-primary, 0.08);
}
.stay-action-icon-symbol {
font-size: 32rpx;
line-height: 1;
}
.stay-action-title {
font-size: 24rpx;
font-weight: bold;
color: #222222;
line-height: 1.3;
text-align: center;
word-break: break-word;
}
.half-btn { .half-btn {
width: 48%; width: 48%;
height: 92rpx; height: 92rpx;
@@ -2044,6 +2218,18 @@ export default {
margin-left: 10rpx; margin-left: 10rpx;
} }
/* 宿管标签 - 绿色 */
.butler-member-new__tag--type.tag-dormitory {
background: rgba(16, 185, 129, 0.1);
color: #10B981;
}
/* 管理员标签 - 琥珀色 */
.butler-member-new__tag--type.tag-counselor {
background: rgba(245, 158, 11, 0.1);
color: #F59E0B;
}
.butler-member-new__phone { .butler-member-new__phone {
font-size: 24rpx; font-size: 24rpx;
color: #888888; color: #888888;

View File

@@ -12,6 +12,30 @@
</view> </view>
</view> </view>
<!-- #ifdef MP-WEIXIN -->
<view v-if="showPrivacyPopup" class="privacy-mask">
<view class="privacy-dialog">
<view class="privacy-title">{{ t('privacyTitle') }}</view>
<view class="privacy-content">
{{ t('privacyContentPrefix') }}
<text class="privacy-link" @click="openPrivacyContract">{{ t('privacyContractName') }}</text>
{{ t('privacyContentSuffix') }}
</view>
<view class="privacy-action-row">
<view class="privacy-action-btn" @click="handlePrivacyDisagree">{{ t('privacyDisagree') }}</view>
<button
id="index-privacy-agree-btn"
class="privacy-action-btn is-primary"
open-type="agreePrivacyAuthorization"
@agreeprivacyauthorization="handleAgreePrivacyAuthorization"
>
{{ t('privacyAgree') }}
</button>
</view>
</view>
</view>
<!-- #endif -->
<!-- 二级页面 --> <!-- 二级页面 -->
<view v-if="hasPage('select-room')" v-show="currentPageId === 'select-room'"> <view v-if="hasPage('select-room')" v-show="currentPageId === 'select-room'">
<scroll-view class="custom-tabbar-page" scroll-y enable-back-to-top @scrolltolower="tabbarPageScrollLower"> <scroll-view class="custom-tabbar-page" scroll-y enable-back-to-top @scrolltolower="tabbarPageScrollLower">
@@ -87,9 +111,24 @@
import MyApplicationTab from './component/MyApplicationTab.vue' import MyApplicationTab from './component/MyApplicationTab.vue'
import ContractAlert from '../../components/contract-alert/contract-alert.vue' import ContractAlert from '../../components/contract-alert/contract-alert.vue'
import { clearLegacyDemoCache, getHomePageTabId, getSessionSnapshot, saveHomePageTabId } from '../../utils/app-state' import { clearLegacyDemoCache, getHomePageTabId, getSessionSnapshot, saveHomePageTabId } from '../../utils/app-state'
import { checkRecordByChecked, notificationPage, applicationFormMePage } from '@/api/index.js' import { applicationFormMePage, checkRecordByChecked, notificationPage, orderSearchedMePage, orderSearchedReservationFlow } from '@/api/index.js'
import { parseOrderListResponse, getOrderStatusCode } from '@/utils/booking-helper'
import { bindPrivacyAuthorizationListener, getPrivacyAuthorizationStatus, openWechatPrivacyContract, unbindPrivacyAuthorizationListener } from '@/utils/privacy.js'
import { countPendingInspectionRecords, extractInspectionList, resolveNotificationUnreadTotal } from '../../utils/message-helper' import { countPendingInspectionRecords, extractInspectionList, resolveNotificationUnreadTotal } from '../../utils/message-helper'
import { syncLoginStayState } from '../../utils/session-sync' import { syncLoginStayState } from '../../utils/session-sync'
import { getReservationOrderNo, getReservationCurrentNode, parseReservationFlowResponse } from '@/utils/reservation-flow'
function formatPageResponse(response) {
const payload = response && response.data ? response.data : response
return payload && typeof payload === 'object' ? payload : {}
}
function normalizeApplicationList(list = []) {
if (!Array.isArray(list)) {
return []
}
return list.filter(item => item && typeof item === 'object')
}
export default { export default {
components: { components: {
@@ -113,6 +152,7 @@
hasCheckedIn: false, hasCheckedIn: false,
hasStayOrder: false, hasStayOrder: false,
hasPendingApplication: false, hasPendingApplication: false,
hasActiveReservationFlow: false,
pendingContracts: [] pendingContracts: []
}, },
tabDefinitions: [ tabDefinitions: [
@@ -125,6 +165,9 @@
loadedPages: ['select-room'], loadedPages: ['select-room'],
isAppInitLoading: true, isAppInitLoading: true,
isTabSwitching: false, isTabSwitching: false,
showPrivacyPopup: false,
resolvePrivacyAuthorization: null,
isCheckingPrivacyAuthorization: false,
i18n: { i18n: {
zh: { zh: {
loadingBrand: '绿居公寓', loadingBrand: '绿居公寓',
@@ -133,7 +176,13 @@
tabStayService: '入 住', tabStayService: '入 住',
tabMyApplication: '申请单', tabMyApplication: '申请单',
tabMessageCenter: '消 息', tabMessageCenter: '消 息',
tabProfileCenter: '我 的' tabProfileCenter: '我 的',
privacyTitle: '隐私授权提示',
privacyContentPrefix: '进入小程序前,请先阅读并同意',
privacyContractName: '《用户隐私保护指引》',
privacyContentSuffix: ',同意后才能继续使用相关功能。',
privacyAgree: '同意并继续',
privacyDisagree: '暂不授权'
}, },
en: { en: {
loadingBrand: 'Luju Apartments', loadingBrand: 'Luju Apartments',
@@ -142,7 +191,13 @@
tabStayService: 'Stay', tabStayService: 'Stay',
tabMyApplication: 'Applications', tabMyApplication: 'Applications',
tabMessageCenter: 'Messages', tabMessageCenter: 'Messages',
tabProfileCenter: 'Profile' tabProfileCenter: 'Profile',
privacyTitle: 'Privacy Authorization',
privacyContentPrefix: 'Before entering the mini program, please read and agree to the ',
privacyContractName: 'Privacy Protection Guide',
privacyContentSuffix: ' to continue using related features.',
privacyAgree: 'Agree and Continue',
privacyDisagree: 'Not Now'
} }
} }
} }
@@ -160,12 +215,12 @@
const tabs = [] const tabs = []
if (this.sessionState.hasStayOrder) { if (this.sessionState.hasStayOrder) {
tabs.push('stay-service') tabs.push('stay-service')
} else if (this.sessionState.hasPendingApplication) { } else if (this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow) {
tabs.push('my-application') tabs.push('my-application')
} else { } else {
tabs.push('select-room') tabs.push('select-room')
} }
if (this.sessionState.hasPendingApplication && !tabs.includes('my-application')) { if ((this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow) && !tabs.includes('my-application')) {
tabs.push('my-application') tabs.push('my-application')
} }
tabs.push('message-center', 'profile-center') tabs.push('message-center', 'profile-center')
@@ -186,14 +241,20 @@
}, },
onShow() { onShow() {
this.handlePageShow() this.handlePageShow()
this.checkEntryPrivacyAuthorization()
}, },
onLoad(options) { onLoad(options) {
clearLegacyDemoCache() clearLegacyDemoCache()
uni.$on('messageBadgeCountChange', this.handleMessageBadgeCountChange) uni.$on('messageBadgeCountChange', this.handleMessageBadgeCountChange)
uni.$on('reservation-flow-force-refresh', this.handleReservationFlowForceRefresh)
this.initPrivacyAuthorization()
this.checkEntryPrivacyAuthorization()
this.handlePageLoad(options) this.handlePageLoad(options)
}, },
onUnload() { onUnload() {
uni.$off('messageBadgeCountChange', this.handleMessageBadgeCountChange) uni.$off('messageBadgeCountChange', this.handleMessageBadgeCountChange)
uni.$off('reservation-flow-force-refresh', this.handleReservationFlowForceRefresh)
this.releasePrivacyAuthorization()
}, },
onReady() { onReady() {
this.$nextTick(() => { this.$nextTick(() => {
@@ -204,6 +265,76 @@
t(key) { t(key) {
return this.$tm('pages.index.index', key) return this.$tm('pages.index.index', key)
}, },
initPrivacyAuthorization() {
// #ifdef MP-WEIXIN
if (typeof wx === 'undefined' || !wx.onNeedPrivacyAuthorization) {
return
}
this.handlePrivacyAuthorizationRequest = (resolve) => {
this.resolvePrivacyAuthorization = resolve
this.showPrivacyPopup = true
}
bindPrivacyAuthorizationListener(this.handlePrivacyAuthorizationRequest)
// #endif
},
releasePrivacyAuthorization() {
// #ifdef MP-WEIXIN
if (!this.handlePrivacyAuthorizationRequest) {
return
}
unbindPrivacyAuthorizationListener(this.handlePrivacyAuthorizationRequest)
this.handlePrivacyAuthorizationRequest = null
this.resolvePrivacyAuthorization = null
this.showPrivacyPopup = false
// #endif
},
checkEntryPrivacyAuthorization() {
// #ifdef MP-WEIXIN
if (this.isCheckingPrivacyAuthorization) {
return
}
this.isCheckingPrivacyAuthorization = true
getPrivacyAuthorizationStatus()
.then((res) => {
if (!this.resolvePrivacyAuthorization) {
this.showPrivacyPopup = !!(res && res.needAuthorization)
}
})
.finally(() => {
this.isCheckingPrivacyAuthorization = false
})
// #endif
},
openPrivacyContract() {
// #ifdef MP-WEIXIN
openWechatPrivacyContract().catch((error) => {
console.error('open privacy contract failed', error)
})
// #endif
},
handleAgreePrivacyAuthorization() {
this.showPrivacyPopup = false
if (!this.resolvePrivacyAuthorization) {
return
}
const resolvePrivacyAuthorization = this.resolvePrivacyAuthorization
this.resolvePrivacyAuthorization = null
resolvePrivacyAuthorization({
buttonId: 'index-privacy-agree-btn',
event: 'agree'
})
},
handlePrivacyDisagree() {
this.showPrivacyPopup = false
if (!this.resolvePrivacyAuthorization) {
return
}
const resolvePrivacyAuthorization = this.resolvePrivacyAuthorization
this.resolvePrivacyAuthorization = null
resolvePrivacyAuthorization({
event: 'disagree'
})
},
getStoreUser() { getStoreUser() {
return (this.$store && this.$store.state && this.$store.state.vuex_user) || {} return (this.$store && this.$store.state && this.$store.state.vuex_user) || {}
}, },
@@ -238,6 +369,58 @@
console.error('sync login stay state failed', error) console.error('sync login stay state failed', error)
}) })
}, },
resolveApplicationState() {
return applicationFormMePage({
Status: 0,
PageIndex: 1,
PageSize: 1
}, this.$getLanguage())
.then((response) => {
const pageData = formatPageResponse(response)
const list = normalizeApplicationList(pageData.data || [])
const total = Number(pageData.total || 0)
const hasPendingApplication = list.some(item => Number(item && item.status) === 0)
return {
hasPendingApplication: total > 0 || hasPendingApplication
}
})
.catch((error) => {
console.error('resolve application state failed', error)
return {
hasPendingApplication: false
}
})
},
resolveApplicationFlowState(list = []) {
const orderList = normalizeApplicationList(list)
const reservationOrderList = orderList.filter(item => getOrderStatusCode(item) === 0)
const orderNos = [...new Set(reservationOrderList.map(item => getReservationOrderNo(item)).filter(Boolean))]
const result = {
hasPendingApplication: false,
hasActiveReservationFlow: false
}
if (!orderNos.length) {
return Promise.resolve(result)
}
return orderSearchedReservationFlow({
orderNos
}, this.$getLanguage())
.then((response) => {
const flowList = parseReservationFlowResponse(response)
const hasActiveReservationFlow = flowList.some(item => !!getReservationCurrentNode(item))
return {
hasPendingApplication: result.hasPendingApplication || hasActiveReservationFlow,
hasActiveReservationFlow
}
})
.catch((error) => {
console.error('resolve application flow state failed', error)
return result
})
},
refreshShellState(force = false, options = {}) { refreshShellState(force = false, options = {}) {
const { skipMessageBadge = false } = options const { skipMessageBadge = false } = options
const tasks = [ const tasks = [
@@ -249,18 +432,30 @@
} }
if (this.isCurrentLogin()) { if (this.isCurrentLogin()) {
tasks.push( tasks.push(
applicationFormMePage({ Status: 0, PageIndex: 1, PageSize: 1 }) this.resolveApplicationState()
.then(res => { .then((applicationState = {}) => {
const payload = res && res.data ? res.data : res return orderSearchedMePage({ PageIndex: 1, PageSize: 100, IsExport: false })
const total = payload && payload.total ? Number(payload.total) : (payload && payload.data && payload.data.length ? payload.data.length : 0) .then(res => {
this.sessionState.hasPendingApplication = total > 0 const orderList = parseOrderListResponse(res)
return this.resolveApplicationFlowState(orderList)
.then((flowState = {}) => ({
hasPendingApplication: !!applicationState.hasPendingApplication,
hasActiveReservationFlow: !!flowState.hasActiveReservationFlow
}))
})
})
.then((flowState = {}) => {
this.sessionState.hasPendingApplication = !!flowState.hasPendingApplication
this.sessionState.hasActiveReservationFlow = !!flowState.hasActiveReservationFlow
}) })
.catch(() => { .catch(() => {
this.sessionState.hasPendingApplication = false this.sessionState.hasPendingApplication = false
this.sessionState.hasActiveReservationFlow = false
}) })
) )
} else { } else {
this.sessionState.hasPendingApplication = false this.sessionState.hasPendingApplication = false
this.sessionState.hasActiveReservationFlow = false
} }
return Promise.all(tasks).finally(() => { return Promise.all(tasks).finally(() => {
this.refreshSessionState() this.refreshSessionState()
@@ -314,6 +509,7 @@
hasCheckedIn: storeHasCheckedIn || session.hasCheckedIn, hasCheckedIn: storeHasCheckedIn || session.hasCheckedIn,
hasStayOrder: hasStayOrder || pendingContracts.length > 0, hasStayOrder: hasStayOrder || pendingContracts.length > 0,
hasPendingApplication: !!this.sessionState.hasPendingApplication, hasPendingApplication: !!this.sessionState.hasPendingApplication,
hasActiveReservationFlow: !!this.sessionState.hasActiveReservationFlow,
pendingContracts pendingContracts
} }
}, },
@@ -396,6 +592,13 @@
const stayServiceTab = list.find(item => item.id === 'stay-service') const stayServiceTab = list.find(item => item.id === 'stay-service')
if (stayServiceTab) return stayServiceTab if (stayServiceTab) return stayServiceTab
} }
const shouldPreferApplicationFlow = this.sessionState.isLogin
&& !this.sessionState.hasStayOrder
&& (this.sessionState.hasPendingApplication || this.sessionState.hasActiveReservationFlow)
if (shouldPreferApplicationFlow && (!preferredPageId || preferredPageId === 'select-room')) {
const applicationTab = list.find(item => item.id === 'my-application')
if (applicationTab) return applicationTab
}
if (preferredPageId) { if (preferredPageId) {
const matchedTab = list.find(item => item.id === preferredPageId) const matchedTab = list.find(item => item.id === preferredPageId)
if (matchedTab) return matchedTab if (matchedTab) return matchedTab
@@ -438,6 +641,22 @@
this.getTabbarItemInfo() this.getTabbarItemInfo()
}) })
}, },
async handleReservationFlowForceRefresh() {
await this.refreshShellState(true)
this.ensurePageLoaded('my-application')
this.$nextTick(() => {
const myApplicationTab = this.getPageRef('myApplicationTab')
if (myApplicationTab && typeof myApplicationTab.load === 'function') {
myApplicationTab.load(true)
} else {
this.refreshTabPageState('my-application')
}
if (this.currentPageId) {
this.refreshTabPageState(this.currentPageId)
}
this.getTabbarItemInfo()
})
},
tabbarPageScrollLower(e) { tabbarPageScrollLower(e) {
if (this.currentPageId === 'select-room') { if (this.currentPageId === 'select-room') {
const selectRoomTab = this.getPageRef('selectRoomTab') const selectRoomTab = this.getPageRef('selectRoomTab')
@@ -588,6 +807,86 @@
z-index: 99999; z-index: 99999;
} }
.privacy-mask {
position: fixed;
inset: 0;
z-index: 100000;
background: rgba(17, 24, 39, 0.56);
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
box-sizing: border-box;
}
.privacy-dialog {
width: 100%;
max-width: 640rpx;
background: #FFFFFF;
border-radius: 28rpx;
padding: 40rpx 32rpx 32rpx;
box-sizing: border-box;
box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, 0.16);
}
.privacy-title {
font-size: 34rpx;
font-weight: 700;
color: #1A2233;
text-align: center;
}
.privacy-content {
margin-top: 24rpx;
font-size: 28rpx;
line-height: 1.7;
color: #5C6B8A;
}
.privacy-link {
color: $brand-primary;
font-weight: 700;
}
.privacy-action-row {
display: flex;
margin-top: 32rpx;
}
.privacy-action-btn {
flex: 1;
height: 88rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 700;
color: $brand-primary;
background: rgba($brand-primary, 0.08);
border: 1rpx solid rgba($brand-primary, 0.12);
box-sizing: border-box;
}
.privacy-action-btn + .privacy-action-btn {
margin-left: 20rpx;
}
button.privacy-action-btn {
padding: 0;
line-height: 88rpx;
}
button.privacy-action-btn::after {
border: none;
}
.privacy-action-btn.is-primary {
color: #FFFFFF;
background: linear-gradient(135deg, rgba($brand-primary, 0.92) 0%, $brand-primary 100%);
border-color: transparent;
}
.loading-logo { .loading-logo {
animation: pulse 2s infinite ease-in-out; animation: pulse 2s infinite ease-in-out;
} }

View File

@@ -82,6 +82,8 @@ import {
pickLatestRegistration, pickLatestRegistration,
resolveLoginPreparation resolveLoginPreparation
} from '../../utils/login-helper' } from '../../utils/login-helper'
import { resolveBookingRedirectAfterLogin } from '../../utils/booking-entry'
import { syncLoginStayState } from '../../utils/session-sync'
import { registrationMyorder, userMe, usersLogin } from '@/api/index.js' import { registrationMyorder, userMe, usersLogin } from '@/api/index.js'
export default { export default {
@@ -161,8 +163,7 @@ export default {
isHomePageUrl(url = '') { isHomePageUrl(url = '') {
return /^\/pages\/index\/index(?:\?|$)/.test(String(url || '')) return /^\/pages\/index\/index(?:\?|$)/.test(String(url || ''))
}, },
navigateAfterLogin() { navigateAfterLogin(targetUrl = this.redirectUrl) {
const targetUrl = this.redirectUrl
const pages = getCurrentPages() const pages = getCurrentPages()
if (targetUrl) { if (targetUrl) {
if (this.isHomePageUrl(targetUrl)) { if (this.isHomePageUrl(targetUrl)) {
@@ -346,6 +347,18 @@ export default {
saveRegisteredUser(finalUser) saveRegisteredUser(finalUser)
startAuthorizedLoginSession(finalUser, token, expires) startAuthorizedLoginSession(finalUser, token, expires)
let nextRedirectUrl = this.redirectUrl
try {
const stayState = await syncLoginStayState({
language: this.$getLanguage(),
force: true
})
nextRedirectUrl = resolveBookingRedirectAfterLogin(this.redirectUrl, stayState)
} catch (error) {
console.error('sync stay state after login failed', error)
}
uni.hideLoading() uni.hideLoading()
uni.showToast({ uni.showToast({
title: this.t('loginSuccess'), title: this.t('loginSuccess'),
@@ -353,7 +366,7 @@ export default {
}) })
setTimeout(() => { setTimeout(() => {
this.isSubmitting = false this.isSubmitting = false
this.navigateAfterLogin() this.navigateAfterLogin(nextRedirectUrl)
}, 600) }, 600)
}, },
handleLogin() { handleLogin() {

View File

@@ -53,10 +53,12 @@
<view class="role-switch-wrap"> <view class="role-switch-wrap">
<view class="role-switch tn-flex"> <view class="role-switch tn-flex">
<view class="role-tab tn-flex-1" :class="form.identityType === 'ForeignStudents' ? 'role-active' : ''" @click="switchIdentityType('ForeignStudents')"> <view class="role-tab tn-flex-1" :class="form.identityType === 'ForeignStudents' ? 'role-active' : ''" @click="switchIdentityType('ForeignStudents')">
{{ t('foreignIdentity') }} <view>{{ t('foreignIdentity') }}</view>
<view v-if="language !== 'en'" class="role-tab-subtitle">{{ t('foreignIdentityHint') }}</view>
</view> </view>
<view class="role-tab tn-flex-1" :class="form.identityType === 'DomesticStudents' ? 'role-active' : ''" @click="switchIdentityType('DomesticStudents')"> <view class="role-tab tn-flex-1" :class="form.identityType === 'DomesticStudents' ? 'role-active' : ''" @click="switchIdentityType('DomesticStudents')">
{{ t('domesticIdentity') }} <view>{{ t('domesticIdentity') }}</view>
<view v-if="language !== 'en'" class="role-tab-subtitle">{{ t('domesticIdentityHint') }}</view>
</view> </view>
</view> </view>
</view> </view>
@@ -112,6 +114,36 @@
<input class="form-input" :placeholder="t('workUnitPlaceholder')" placeholder-class="input-placeholder" v-model="form.workUnit" /> <input class="form-input" :placeholder="t('workUnitPlaceholder')" placeholder-class="input-placeholder" v-model="form.workUnit" />
</view> </view>
<view class="form-item">
<view class="form-label">{{ t('admissionNoticeAttachmentLabel') }}</view>
<view v-if="!form.admissionNoticeAttachment.serverValue" class="upload-box" @click="uploadAttachment('admissionNoticeAttachment')">
<view class="upload-placeholder">
<view class="upload-icon-wrap"><text class="tn-icon-upload"></text></view>
<view class="tn-margin-top-sm upload-text">{{ t('uploadAdmissionNotice') }}</view>
</view>
</view>
<view v-else class="attachment-file-card">
<image
class="attachment-preview-image"
:src="getAttachmentPreviewUrl(form.admissionNoticeAttachment)"
mode="aspectFill"
@click.stop="previewAttachment('admissionNoticeAttachment')"
/>
<view class="attachment-file-info">
<view class="attachment-name">{{ form.admissionNoticeAttachment.name }}</view>
<view class="attachment-type tn-margin-top-xs">{{ t('imageUploadedTip') }}</view>
</view>
<view class="attachment-action-row">
<view class="attachment-action-btn" @click.stop="previewAttachment('admissionNoticeAttachment')">
{{ t('viewImage') }}
</view>
<view class="attachment-action-btn is-primary" @click.stop="uploadAttachment('admissionNoticeAttachment')">
{{ t('reupload') }}
</view>
</view>
</view>
</view>
<view class="form-item"> <view class="form-item">
<view class="form-label">{{ t('hangzhouPropertyLabel') }} <text class="required-mark">*</text></view> <view class="form-label">{{ t('hangzhouPropertyLabel') }} <text class="required-mark">*</text></view>
<view class="tn-flex tn-flex-wrap"> <view class="tn-flex tn-flex-wrap">
@@ -120,6 +152,20 @@
</view> </view>
</view> </view>
<view class="form-item">
<view class="form-label">{{ t('idCardTypeLabel') }} <text class="required-mark">*</text></view>
<view v-if="isDomesticIdentity" class="form-input picker-input picker-input-disabled">
<text class="text-black">{{ selectedIdCardTypeLabel || (language === 'en' ? 'ID Card' : '身份证') }}</text>
<text class="picker-fixed-text">{{ t('idCardTypeFixedText') }}</text>
</view>
<picker v-else mode="selector" :range="idCardTypeRange" @change="onIdCardTypeChange">
<view class="form-input picker-input">
<text :class="selectedIdCardTypeLabel ? 'text-black' : 'text-gray'">{{ selectedIdCardTypeLabel || t('idCardTypePlaceholder') }}</text>
<text class="tn-icon-right tn-color-gray"></text>
</view>
</picker>
</view>
<template v-if="form.identityType === 'DomesticStudents'"> <template v-if="form.identityType === 'DomesticStudents'">
<view class="form-item"> <view class="form-item">
<view class="form-label">{{ t('idCardLabel') }} <text class="required-mark">*</text></view> <view class="form-label">{{ t('idCardLabel') }} <text class="required-mark">*</text></view>
@@ -189,12 +235,12 @@
<template v-else> <template v-else>
<view class="form-item"> <view class="form-item">
<view class="form-label">{{ t('passportLabel') }} <text class="required-mark">*</text></view> <view class="form-label">{{ foreignDocumentNumberLabel }} <text class="required-mark">*</text></view>
<input class="form-input" :placeholder="t('passportPlaceholder')" placeholder-class="input-placeholder" v-model="form.passportNumber" /> <input class="form-input" :placeholder="foreignDocumentNumberPlaceholder" placeholder-class="input-placeholder" v-model="form.passportNumber" />
</view> </view>
<view class="form-item"> <view class="form-item">
<view class="form-label">{{ t('passportExpiryLabel') }} <text class="required-mark">*</text></view> <view class="form-label">{{ foreignDocumentExpiryLabel }} <text class="required-mark">*</text></view>
<picker mode="date" @change="onPassportDateChange"> <picker mode="date" @change="onPassportDateChange">
<view class="form-input picker-input"> <view class="form-input picker-input">
<text :class="form.passportExpiry ? 'text-black' : 'text-gray'">{{ form.passportExpiry || t('selectDate') }}</text> <text :class="form.passportExpiry ? 'text-black' : 'text-gray'">{{ form.passportExpiry || t('selectDate') }}</text>
@@ -209,11 +255,11 @@
</view> </view>
<view class="form-item" style="margin-bottom: 0;"> <view class="form-item" style="margin-bottom: 0;">
<view class="form-label">{{ t('passportAttachmentLabel') }} <text class="required-mark">*</text></view> <view class="form-label">{{ foreignDocumentAttachmentLabel }} <text class="required-mark">*</text></view>
<view v-if="!form.passportAttachment.serverValue" class="upload-box" @click="uploadAttachment('passportAttachment')"> <view v-if="!form.passportAttachment.serverValue" class="upload-box" @click="uploadAttachment('passportAttachment')">
<view v-if="!form.passportAttachment.serverValue" class="upload-placeholder"> <view v-if="!form.passportAttachment.serverValue" class="upload-placeholder">
<view class="upload-icon-wrap"><text class="tn-icon-upload"></text></view> <view class="upload-icon-wrap"><text class="tn-icon-upload"></text></view>
<view class="tn-margin-top-sm upload-text">{{ t('uploadPassport') }}</view> <view class="tn-margin-top-sm upload-text">{{ foreignDocumentUploadText }}</view>
</view> </view>
</view> </view>
<view v-else class="attachment-file-card"> <view v-else class="attachment-file-card">
@@ -230,7 +276,7 @@
<view class="attachment-file-info"> <view class="attachment-file-info">
<view class="attachment-name">{{ form.passportAttachment.name }}</view> <view class="attachment-name">{{ form.passportAttachment.name }}</view>
<view class="attachment-type tn-margin-top-xs"> <view class="attachment-type tn-margin-top-xs">
{{ isImageAttachment(form.passportAttachment) ? t('imageUploadedTip') : t('passportImageOnlyTip') }} {{ isImageAttachment(form.passportAttachment) ? t('imageUploadedTip') : foreignDocumentImageOnlyTip }}
</view> </view>
</view> </view>
<view class="attachment-action-row"> <view class="attachment-action-row">
@@ -308,6 +354,30 @@
</view> </view>
</view> </view>
</view> </view>
<!-- #ifdef MP-WEIXIN -->
<view v-if="showPrivacyPopup" class="privacy-mask">
<view class="privacy-dialog">
<view class="privacy-title">{{ t('privacyTitle') }}</view>
<view class="privacy-content">
{{ t('privacyContentPrefix') }}
<text class="privacy-link" @click="openPrivacyContract">{{ t('privacyContractName') }}</text>
{{ t('privacyContentSuffix') }}
</view>
<view class="privacy-action-row">
<view class="privacy-action-btn" @click="handlePrivacyDisagree">{{ t('privacyDisagree') }}</view>
<button
id="register-privacy-agree-btn"
class="privacy-action-btn is-primary"
open-type="agreePrivacyAuthorization"
@agreeprivacyauthorization="handleAgreePrivacyAuthorization"
>
{{ t('privacyAgree') }}
</button>
</view>
</view>
</view>
<!-- #endif -->
</view> </view>
</template> </template>
@@ -320,7 +390,8 @@ import {
} from '../../utils/app-state' } from '../../utils/app-state'
import { getCountryList, submitRegistrationApplication, sysdictPage, uploadCommonFile, registrationMyorder } from '@/api/index.js' import { getCountryList, submitRegistrationApplication, sysdictPage, uploadCommonFile, registrationMyorder } from '@/api/index.js'
import { addFilePrefix } from '@/utils/common.js' import { addFilePrefix } from '@/utils/common.js'
import { getGenderLabel } from '../../utils/enum-store' import { bindPrivacyAuthorizationListener, openWechatPrivacyContract, unbindPrivacyAuthorizationListener } from '@/utils/privacy.js'
import { fetchEnums, getEnumOptions, getGenderLabel } from '../../utils/enum-store'
import { resolveRegistrationApplicationCode } from '../../utils/flow' import { resolveRegistrationApplicationCode } from '../../utils/flow'
function createAttachmentState() { function createAttachmentState() {
@@ -342,6 +413,7 @@ function createDefaultForm() {
gender: 1, gender: 1,
phone: '', phone: '',
country: '', country: '',
idCardType: 2,
idCard: '', idCard: '',
workUnit: '', workUnit: '',
hasHangzhouProperty: null, hasHangzhouProperty: null,
@@ -351,6 +423,7 @@ function createDefaultForm() {
passportExpiry: '', passportExpiry: '',
school: '', school: '',
religion: '', religion: '',
admissionNoticeAttachment: createAttachmentState(),
passportAttachment: createAttachmentState(), passportAttachment: createAttachmentState(),
idCardFrontAttachment: createAttachmentState(), idCardFrontAttachment: createAttachmentState(),
idCardBackAttachment: createAttachmentState() idCardBackAttachment: createAttachmentState()
@@ -374,7 +447,9 @@ function normalizeRegistrationDraftForm(draft) {
...createDefaultForm(), ...createDefaultForm(),
...source, ...source,
gender: source.gender === 0 || source.gender === 1 ? Number(source.gender) : 1, gender: source.gender === 0 || source.gender === 1 ? Number(source.gender) : 1,
idCardType: Number(source.idCardType || source.IdCardType || 0) || 2,
hasHangzhouProperty: typeof source.hasHangzhouProperty === 'boolean' ? source.hasHangzhouProperty : null, hasHangzhouProperty: typeof source.hasHangzhouProperty === 'boolean' ? source.hasHangzhouProperty : null,
admissionNoticeAttachment: normalizeAttachmentDraft(source.admissionNoticeAttachment),
passportAttachment: normalizeAttachmentDraft(source.passportAttachment), passportAttachment: normalizeAttachmentDraft(source.passportAttachment),
idCardFrontAttachment: normalizeAttachmentDraft(source.idCardFrontAttachment), idCardFrontAttachment: normalizeAttachmentDraft(source.idCardFrontAttachment),
idCardBackAttachment: normalizeAttachmentDraft(source.idCardBackAttachment) idCardBackAttachment: normalizeAttachmentDraft(source.idCardBackAttachment)
@@ -391,21 +466,27 @@ export default {
draftPersistTimer: null, draftPersistTimer: null,
draftTipText: '', draftTipText: '',
lastForeignCountry: '', lastForeignCountry: '',
lastForeignIdCardType: 2,
showPrivacyPopup: false,
resolvePrivacyAuthorization: null,
form: createDefaultForm(), form: createDefaultForm(),
rawCountryList: [], rawCountryList: [],
rawHobbyList: [], rawHobbyList: [],
rawDietaryHabitList: [], rawDietaryHabitList: [],
rawReligionList: [], rawReligionList: [],
rawIdCardTypeList: [],
countryOptions: [], countryOptions: [],
hobbyOptions: [], hobbyOptions: [],
dietaryHabitOptions: [], dietaryHabitOptions: [],
religionOptions: [], religionOptions: [],
idCardTypeOptions: [],
i18n: { i18n: {
zh: { zh: {
navTitle: '注册账号', navTitle: '注册账号',
pageTitle: '注册并提交实名资料', pageTitle: '注册并提交实名资料',
pageDesc: '账号信息和实名认证资料需要一起填写完成,提交的是注册申请单,审批通过后才算注册成功。', pageDesc: '账号信息和实名认证资料需要一起填写完成,提交的是注册申请单,审批通过后才算注册成功。',
accountSection: '账号信息', accountSection: '账号信息',
admissionNoticeAttachmentLabel: '入学通知书(在职证明)附件',
accountLabel: '登录账号', accountLabel: '登录账号',
accountPlaceholder: '请输入登录账号', accountPlaceholder: '请输入登录账号',
passwordLabel: '登录密码', passwordLabel: '登录密码',
@@ -416,7 +497,9 @@ export default {
emailCodePlaceholder: '请输入邮箱验证码', emailCodePlaceholder: '请输入邮箱验证码',
basicSection: '基础实名信息', basicSection: '基础实名信息',
foreignIdentity: '外籍人员', foreignIdentity: '外籍人员',
foreignIdentityHint: 'Foreign',
domesticIdentity: '中国籍', domesticIdentity: '中国籍',
domesticIdentityHint: 'Chinese',
nameLabel: '姓名', nameLabel: '姓名',
namePlaceholder: '请输入真实姓名', namePlaceholder: '请输入真实姓名',
genderLabel: '性别', genderLabel: '性别',
@@ -434,6 +517,9 @@ export default {
hangzhouPropertyLabel: '杭州有无房产', hangzhouPropertyLabel: '杭州有无房产',
yesText: '有', yesText: '有',
noText: '无', noText: '无',
idCardTypeLabel: '证件类型',
idCardTypePlaceholder: '请选择证件类型',
idCardTypeFixedText: '中国籍固定',
idCardLabel: '身份证号', idCardLabel: '身份证号',
idCardPlaceholder: '请输入身份证号', idCardPlaceholder: '请输入身份证号',
idCardFrontAttachmentLabel: '身份证正面附件', idCardFrontAttachmentLabel: '身份证正面附件',
@@ -446,12 +532,18 @@ export default {
passportLabel: '护照号', passportLabel: '护照号',
passportPlaceholder: '请输入护照号', passportPlaceholder: '请输入护照号',
passportExpiryLabel: '护照到期时间', passportExpiryLabel: '护照到期时间',
documentNumberLabel: '证件号',
documentNumberPlaceholder: '请输入证件号',
documentExpiryLabel: '证件到期时间',
selectDate: '请选择日期', selectDate: '请选择日期',
schoolLabel: '就读学校', schoolLabel: '就读学校',
schoolPlaceholder: '请输入就读学校(选填)', schoolPlaceholder: '请输入就读学校(选填)',
passportAttachmentLabel: '护照附件', passportAttachmentLabel: '护照附件',
documentAttachmentLabel: '证件附件',
uploadPassport: '点击上传护照图片', uploadPassport: '点击上传护照图片',
uploadDocument: '点击上传证件图片',
passportImageOnlyTip: '护照附件仅支持图片,请重新上传', passportImageOnlyTip: '护照附件仅支持图片,请重新上传',
documentImageOnlyTip: '证件附件仅支持图片,请重新上传',
preferenceSection: '偏好信息', preferenceSection: '偏好信息',
dietaryLabel: '饮食习惯', dietaryLabel: '饮食习惯',
hobbyLabel: '兴趣爱好', hobbyLabel: '兴趣爱好',
@@ -478,20 +570,32 @@ export default {
uploading: '上传中...', uploading: '上传中...',
uploadSuccess: '上传成功', uploadSuccess: '上传成功',
uploadFailed: '上传失败,请重试', uploadFailed: '上传失败,请重试',
privacyTitle: '隐私授权提示',
privacyContentPrefix: '上传证件图片前,请先阅读并同意',
privacyContractName: '《用户隐私保护指引》',
privacyContentSuffix: ',同意后才能继续选择图片。',
privacyAgree: '同意并继续',
privacyDisagree: '暂不授权',
privacyAuthorizeFailed: '未完成隐私授权,暂时无法选择图片',
attachmentNotImage: '当前附件不是图片', attachmentNotImage: '当前附件不是图片',
uploadImageOption: '上传图片', uploadImageOption: '上传图片',
submitPending: '申请已提交,待审批', submitPending: '申请已提交,待审批',
submitFailed: '提交失败,请稍后重试', submitFailed: '提交失败,请稍后重试',
draftTipDefault: '未完成内容会自动保存,可随时返回继续填写', draftTipDefault: '未完成内容会自动保存,可随时返回继续填写',
draftTipSaved: '当前填写内容已自动保存', draftTipSaved: '当前填写内容已自动保存',
uploadAdmissionNotice: '点击上传入学通知书/在职证明',
validateRequiredBasic: '请先填写账号、密码、邮箱和姓名', validateRequiredBasic: '请先填写账号、密码、邮箱和姓名',
validatePasswordLength: '登录密码至少 6 位', validatePasswordLength: '登录密码至少 6 位',
validateEmail: '请输入正确的邮箱地址', validateEmail: '请输入正确的邮箱地址',
validateIdentityBasic: '请完善性别、手机号和国籍', validateIdentityBasic: '请完善性别、手机号和国籍',
validatePhone: '请输入正确的手机号', validatePhone: '请输入正确的手机号',
validateProfile: '请完善工作单位和杭州房产情况', validateProfile: '请完善工作单位和杭州房产情况',
validateIdCardType: '请选择证件类型',
validateIdCard: '请填写身份证号', validateIdCard: '请填写身份证号',
validateIdCardAttachments: '请上传身份证正反面附件', validateIdCardAttachments: '请上传身份证正反面附件',
validateDocumentBasic: '请填写证件号和证件到期时间',
validateDocumentAttachment: '请上传证件图片',
validateDocumentAttachmentImage: '证件附件仅支持图片格式',
validatePassportBasic: '请填写护照号和护照到期时间', validatePassportBasic: '请填写护照号和护照到期时间',
validatePassportAttachment: '请上传护照图片', validatePassportAttachment: '请上传护照图片',
validatePassportAttachmentImage: '护照附件仅支持图片格式' validatePassportAttachmentImage: '护照附件仅支持图片格式'
@@ -501,6 +605,7 @@ export default {
pageTitle: 'Register and Submit Verification', pageTitle: 'Register and Submit Verification',
pageDesc: 'Account information and identity verification materials must be completed together. Registration succeeds only after approval.', pageDesc: 'Account information and identity verification materials must be completed together. Registration succeeds only after approval.',
accountSection: 'Account Information', accountSection: 'Account Information',
admissionNoticeAttachmentLabel: 'Admission Notice (Employment Certificate)',
accountLabel: 'Account', accountLabel: 'Account',
accountPlaceholder: 'Enter account name', accountPlaceholder: 'Enter account name',
passwordLabel: 'Password', passwordLabel: 'Password',
@@ -511,7 +616,9 @@ export default {
emailCodePlaceholder: 'Enter email verification code', emailCodePlaceholder: 'Enter email verification code',
basicSection: 'Basic Verification', basicSection: 'Basic Verification',
foreignIdentity: 'Foreign', foreignIdentity: 'Foreign',
foreignIdentityHint: 'Foreign',
domesticIdentity: 'Chinese', domesticIdentity: 'Chinese',
domesticIdentityHint: 'Chinese',
nameLabel: 'Name', nameLabel: 'Name',
namePlaceholder: 'Enter your real name', namePlaceholder: 'Enter your real name',
genderLabel: 'Gender', genderLabel: 'Gender',
@@ -529,6 +636,9 @@ export default {
hangzhouPropertyLabel: 'Property in Hangzhou', hangzhouPropertyLabel: 'Property in Hangzhou',
yesText: 'Yes', yesText: 'Yes',
noText: 'No', noText: 'No',
idCardTypeLabel: 'Document Type',
idCardTypePlaceholder: 'Select document type',
idCardTypeFixedText: 'Fixed for Chinese users',
idCardLabel: 'ID Card Number', idCardLabel: 'ID Card Number',
idCardPlaceholder: 'Enter ID card number', idCardPlaceholder: 'Enter ID card number',
idCardFrontAttachmentLabel: 'ID Card Front', idCardFrontAttachmentLabel: 'ID Card Front',
@@ -541,12 +651,18 @@ export default {
passportLabel: 'Passport Number', passportLabel: 'Passport Number',
passportPlaceholder: 'Enter passport number', passportPlaceholder: 'Enter passport number',
passportExpiryLabel: 'Passport Expiry Date', passportExpiryLabel: 'Passport Expiry Date',
documentNumberLabel: 'Document Number',
documentNumberPlaceholder: 'Enter document number',
documentExpiryLabel: 'Document Expiry Date',
selectDate: 'Select date', selectDate: 'Select date',
schoolLabel: 'School', schoolLabel: 'School',
schoolPlaceholder: 'Enter school (optional)', schoolPlaceholder: 'Enter school (optional)',
passportAttachmentLabel: 'Passport Attachment', passportAttachmentLabel: 'Passport Attachment',
documentAttachmentLabel: 'Document Attachment',
uploadPassport: 'Upload passport image', uploadPassport: 'Upload passport image',
uploadDocument: 'Upload document image',
passportImageOnlyTip: 'Passport attachment only supports images, please re-upload', passportImageOnlyTip: 'Passport attachment only supports images, please re-upload',
documentImageOnlyTip: 'Document attachment only supports images, please re-upload',
preferenceSection: 'Preferences', preferenceSection: 'Preferences',
dietaryLabel: 'Dietary Habit', dietaryLabel: 'Dietary Habit',
hobbyLabel: 'Hobby', hobbyLabel: 'Hobby',
@@ -573,20 +689,32 @@ export default {
uploading: 'Uploading...', uploading: 'Uploading...',
uploadSuccess: 'Upload successful', uploadSuccess: 'Upload successful',
uploadFailed: 'Upload failed, please retry', uploadFailed: 'Upload failed, please retry',
privacyTitle: 'Privacy Authorization',
privacyContentPrefix: 'Before uploading document images, please read and agree to the ',
privacyContractName: 'Privacy Protection Guide',
privacyContentSuffix: ' to continue selecting images.',
privacyAgree: 'Agree and Continue',
privacyDisagree: 'Not Now',
privacyAuthorizeFailed: 'Privacy authorization is required before selecting images',
attachmentNotImage: 'The current attachment is not an image', attachmentNotImage: 'The current attachment is not an image',
uploadImageOption: 'Upload Image', uploadImageOption: 'Upload Image',
submitPending: 'Application submitted, pending approval', submitPending: 'Application submitted, pending approval',
submitFailed: 'Submission failed, please try again later', submitFailed: 'Submission failed, please try again later',
draftTipDefault: 'Unfinished content is auto-saved and can be continued later', draftTipDefault: 'Unfinished content is auto-saved and can be continued later',
draftTipSaved: 'Current content has been auto-saved', draftTipSaved: 'Current content has been auto-saved',
uploadAdmissionNotice: 'Upload admission notice or employment certificate',
validateRequiredBasic: 'Please complete the account, password, email, and name fields first', validateRequiredBasic: 'Please complete the account, password, email, and name fields first',
validatePasswordLength: 'Password must be at least 6 characters', validatePasswordLength: 'Password must be at least 6 characters',
validateEmail: 'Please enter a valid email address', validateEmail: 'Please enter a valid email address',
validateIdentityBasic: 'Please complete gender, phone number, and country', validateIdentityBasic: 'Please complete gender, phone number, and country',
validatePhone: 'Please enter a valid phone number', validatePhone: 'Please enter a valid phone number',
validateProfile: 'Please complete work unit and property status in Hangzhou', validateProfile: 'Please complete work unit and property status in Hangzhou',
validateIdCardType: 'Please select a document type',
validateIdCard: 'Please enter your ID card number', validateIdCard: 'Please enter your ID card number',
validateIdCardAttachments: 'Please upload both sides of the ID card', validateIdCardAttachments: 'Please upload both sides of the ID card',
validateDocumentBasic: 'Please enter the document number and expiry date',
validateDocumentAttachment: 'Please upload the document image',
validateDocumentAttachmentImage: 'Document attachment only supports image files',
validatePassportBasic: 'Please enter passport number and expiry date', validatePassportBasic: 'Please enter passport number and expiry date',
validatePassportAttachment: 'Please upload the passport image', validatePassportAttachment: 'Please upload the passport image',
validatePassportAttachmentImage: 'Passport attachment only supports image files' validatePassportAttachmentImage: 'Passport attachment only supports image files'
@@ -607,6 +735,9 @@ export default {
countryRange() { countryRange() {
return this.countryOptions.length ? this.countryOptions.map(item => item.value) : [this.t('noSelectableOptions')] return this.countryOptions.length ? this.countryOptions.map(item => item.value) : [this.t('noSelectableOptions')]
}, },
idCardTypeRange() {
return this.idCardTypeOptions.length ? this.idCardTypeOptions.map(item => item.value) : [this.t('noSelectableOptions')]
},
dietaryHabitRange() { dietaryHabitRange() {
return this.dietaryHabitOptions.length ? this.dietaryHabitOptions.map(item => item.value) : [this.t('noSelectableOptions')] return this.dietaryHabitOptions.length ? this.dietaryHabitOptions.map(item => item.value) : [this.t('noSelectableOptions')]
}, },
@@ -616,6 +747,31 @@ export default {
religionRange() { religionRange() {
return this.religionOptions.length ? this.religionOptions.map(item => item.value) : [this.t('noSelectableOptions')] return this.religionOptions.length ? this.religionOptions.map(item => item.value) : [this.t('noSelectableOptions')]
}, },
selectedIdCardTypeLabel() {
const matched = this.idCardTypeOptions.find(item => String(item.key) === String(this.form.idCardType))
return matched ? matched.value : ''
},
isPassportType() {
return String(this.form.idCardType) === '2'
},
foreignDocumentNumberLabel() {
return this.isPassportType ? this.t('passportLabel') : this.t('documentNumberLabel')
},
foreignDocumentNumberPlaceholder() {
return this.isPassportType ? this.t('passportPlaceholder') : this.t('documentNumberPlaceholder')
},
foreignDocumentExpiryLabel() {
return this.isPassportType ? this.t('passportExpiryLabel') : this.t('documentExpiryLabel')
},
foreignDocumentAttachmentLabel() {
return this.isPassportType ? this.t('passportAttachmentLabel') : this.t('documentAttachmentLabel')
},
foreignDocumentUploadText() {
return this.isPassportType ? this.t('uploadPassport') : this.t('uploadDocument')
},
foreignDocumentImageOnlyTip() {
return this.isPassportType ? this.t('passportImageOnlyTip') : this.t('documentImageOnlyTip')
},
genderOptions() { genderOptions() {
return [ return [
{ key: 1, value: this.t('genderMale') }, { key: 1, value: this.t('genderMale') },
@@ -637,6 +793,7 @@ export default {
}, },
async onLoad(options) { async onLoad(options) {
this.draftTipText = this.t('draftTipDefault') this.draftTipText = this.t('draftTipDefault')
this.initPrivacyAuthorization()
const bypass = options && options.bypass === '1' const bypass = options && options.bypass === '1'
const hasRedirected = await this.checkPendingRegistration(bypass) const hasRedirected = await this.checkPendingRegistration(bypass)
if (hasRedirected) return if (hasRedirected) return
@@ -645,7 +802,6 @@ export default {
this.loadDictOptions(), this.loadDictOptions(),
this.loadCountryOptions() this.loadCountryOptions()
]) ])
this.tryRestoreFailedDraft() this.tryRestoreFailedDraft()
}, },
onHide() { onHide() {
@@ -654,6 +810,8 @@ export default {
onUnload() { onUnload() {
this.persistRegistrationDraft() this.persistRegistrationDraft()
this.clearDraftPersistTimer() this.clearDraftPersistTimer()
this.resolvePrivacyAuthorization = null
this.releasePrivacyAuthorization()
}, },
onPageScroll(e) { onPageScroll(e) {
let opacity = e.scrollTop / 80 let opacity = e.scrollTop / 80
@@ -733,6 +891,10 @@ export default {
if (!this.isDomesticIdentity && this.form.country && !this.isChinaCountry(this.form.country)) { if (!this.isDomesticIdentity && this.form.country && !this.isChinaCountry(this.form.country)) {
this.lastForeignCountry = this.form.country this.lastForeignCountry = this.form.country
} }
if (!this.isDomesticIdentity && String(this.form.idCardType || '') !== '1') {
this.lastForeignIdCardType = Number(this.form.idCardType) || 2
}
this.applyIdentityDocumentTypeRule()
this.applyIdentityCountryRule() this.applyIdentityCountryRule()
uni.showToast({ uni.showToast({
title: this.t('restoreDraftSuccess'), title: this.t('restoreDraftSuccess'),
@@ -760,6 +922,7 @@ export default {
}, },
hasDraftContent() { hasDraftContent() {
const attachmentFields = [ const attachmentFields = [
'admissionNoticeAttachment',
'passportAttachment', 'passportAttachment',
'idCardFrontAttachment', 'idCardFrontAttachment',
'idCardBackAttachment' 'idCardBackAttachment'
@@ -820,6 +983,7 @@ export default {
this.lastForeignCountry = this.form.country this.lastForeignCountry = this.form.country
} }
this.form.identityType = type this.form.identityType = type
this.applyIdentityDocumentTypeRule()
this.applyIdentityCountryRule() this.applyIdentityCountryRule()
}, },
onPassportDateChange(e) { onPassportDateChange(e) {
@@ -846,15 +1010,19 @@ export default {
return Promise.all([ return Promise.all([
sysdictPage({ Type: 'UserHobbiesAndInterestsTag', PageIndex: 1, PageSize: 100 }, this.$getLanguage()), sysdictPage({ Type: 'UserHobbiesAndInterestsTag', PageIndex: 1, PageSize: 100 }, this.$getLanguage()),
sysdictPage({ Type: 'UserDietaryHabitsTag', PageIndex: 1, PageSize: 100 }, this.$getLanguage()), sysdictPage({ Type: 'UserDietaryHabitsTag', PageIndex: 1, PageSize: 100 }, this.$getLanguage()),
sysdictPage({ Type: 'UserReligiousBeliefTag', PageIndex: 1, PageSize: 100 }, this.$getLanguage()) sysdictPage({ Type: 'UserReligiousBeliefTag', PageIndex: 1, PageSize: 100 }, this.$getLanguage()),
fetchEnums(false, this.$getLanguage())
]) ])
.then(([hobbyRes, dietaryRes, religionRes]) => { .then(([hobbyRes, dietaryRes, religionRes]) => {
this.rawHobbyList = this.extractResponseList(hobbyRes) this.rawHobbyList = this.extractResponseList(hobbyRes)
this.rawDietaryHabitList = this.extractResponseList(dietaryRes) this.rawDietaryHabitList = this.extractResponseList(dietaryRes)
this.rawReligionList = this.extractResponseList(religionRes) this.rawReligionList = this.extractResponseList(religionRes)
this.rawIdCardTypeList = getEnumOptions('IdCardTypeEnum', this.$getLanguage())
this.hobbyOptions = this.normalizeDictList(this.rawHobbyList) this.hobbyOptions = this.normalizeDictList(this.rawHobbyList)
this.dietaryHabitOptions = this.normalizeDictList(this.rawDietaryHabitList) this.dietaryHabitOptions = this.normalizeDictList(this.rawDietaryHabitList)
this.religionOptions = this.normalizeDictList(this.rawReligionList) this.religionOptions = this.normalizeDictList(this.rawReligionList)
this.idCardTypeOptions = this.normalizeDictList(this.rawIdCardTypeList)
this.applyIdentityDocumentTypeRule()
}) })
.catch((error) => { .catch((error) => {
console.error('load dict options failed', error) console.error('load dict options failed', error)
@@ -933,13 +1101,27 @@ export default {
this.hobbyOptions = this.normalizeDictList(this.rawHobbyList) this.hobbyOptions = this.normalizeDictList(this.rawHobbyList)
this.dietaryHabitOptions = this.normalizeDictList(this.rawDietaryHabitList) this.dietaryHabitOptions = this.normalizeDictList(this.rawDietaryHabitList)
this.religionOptions = this.normalizeDictList(this.rawReligionList) this.religionOptions = this.normalizeDictList(this.rawReligionList)
this.idCardTypeOptions = this.normalizeDictList(this.rawIdCardTypeList)
this.form.country = this.remapSelectedValue(this.form.country, this.rawCountryList) this.form.country = this.remapSelectedValue(this.form.country, this.rawCountryList)
this.lastForeignCountry = this.remapSelectedValue(this.lastForeignCountry, this.rawCountryList) this.lastForeignCountry = this.remapSelectedValue(this.lastForeignCountry, this.rawCountryList)
this.form.hobby = this.remapSelectedValue(this.form.hobby, this.rawHobbyList) this.form.hobby = this.remapSelectedValue(this.form.hobby, this.rawHobbyList)
this.form.dietaryHabit = this.remapSelectedValue(this.form.dietaryHabit, this.rawDietaryHabitList) this.form.dietaryHabit = this.remapSelectedValue(this.form.dietaryHabit, this.rawDietaryHabitList)
this.form.religion = this.remapSelectedValue(this.form.religion, this.rawReligionList) this.form.religion = this.remapSelectedValue(this.form.religion, this.rawReligionList)
this.applyIdentityDocumentTypeRule()
this.applyIdentityCountryRule() this.applyIdentityCountryRule()
}, },
applyIdentityDocumentTypeRule() {
if (this.isDomesticIdentity) {
if (String(this.form.idCardType || '') !== '1' && String(this.form.idCardType || '')) {
this.lastForeignIdCardType = Number(this.form.idCardType) || 2
}
this.form.idCardType = 1
return
}
if (!this.form.idCardType || String(this.form.idCardType) === '1') {
this.form.idCardType = this.lastForeignIdCardType || 2
}
},
getChinaCountryOption() { getChinaCountryOption() {
return this.countryOptions.find(item => String(item.key || '').toUpperCase() === 'CHN' || this.isChinaCountry(item.value)) || null return this.countryOptions.find(item => String(item.key || '').toUpperCase() === 'CHN' || this.isChinaCountry(item.value)) || null
}, },
@@ -970,6 +1152,21 @@ export default {
const selected = options[event.detail.value] const selected = options[event.detail.value]
this.form[field] = selected ? selected.value : '' this.form[field] = selected ? selected.value : ''
}, },
onIdCardTypeChange(event) {
if (!this.idCardTypeOptions.length) {
uni.showToast({
title: this.t('noSelectableOptions'),
icon: 'none'
})
return
}
const selected = this.idCardTypeOptions[event.detail.value]
const nextValue = selected ? Number(selected.key) : 0
this.form.idCardType = nextValue || this.form.idCardType
if (!this.isDomesticIdentity && nextValue && nextValue !== 1) {
this.lastForeignIdCardType = nextValue
}
},
onCountryChange(event) { onCountryChange(event) {
if (this.isDomesticIdentity) return if (this.isDomesticIdentity) return
if (!this.countryOptions.length) { if (!this.countryOptions.length) {
@@ -983,8 +1180,106 @@ export default {
this.form.country = selected ? selected.value : '' this.form.country = selected ? selected.value : ''
this.lastForeignCountry = this.form.country || '' this.lastForeignCountry = this.form.country || ''
}, },
initPrivacyAuthorization() {
// #ifdef MP-WEIXIN
if (typeof wx === 'undefined' || !wx.onNeedPrivacyAuthorization) {
return
}
this.handlePrivacyAuthorizationRequest = (resolve) => {
this.resolvePrivacyAuthorization = resolve
this.showPrivacyPopup = true
}
bindPrivacyAuthorizationListener(this.handlePrivacyAuthorizationRequest)
// #endif
},
releasePrivacyAuthorization() {
// #ifdef MP-WEIXIN
if (!this.handlePrivacyAuthorizationRequest) {
return
}
unbindPrivacyAuthorizationListener(this.handlePrivacyAuthorizationRequest)
this.handlePrivacyAuthorizationRequest = null
this.showPrivacyPopup = false
// #endif
},
ensurePrivacyAuthorized() {
// #ifdef MP-WEIXIN
if (typeof wx === 'undefined' || !wx.requirePrivacyAuthorize) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const triggerPrivacyAuthorize = () => {
wx.requirePrivacyAuthorize({
success: () => {
this.showPrivacyPopup = false
resolve()
},
fail: (error) => {
reject({
...error,
privacyDenied: true
})
}
})
}
if (!wx.getPrivacySetting) {
triggerPrivacyAuthorize()
return
}
wx.getPrivacySetting({
success: (res) => {
if (!res.needAuthorization) {
resolve()
return
}
triggerPrivacyAuthorize()
},
fail: () => {
triggerPrivacyAuthorize()
}
})
})
// #endif
// #ifndef MP-WEIXIN
return Promise.resolve()
// #endif
},
openPrivacyContract() {
// #ifdef MP-WEIXIN
openWechatPrivacyContract().catch((error) => {
console.error('open privacy contract failed', error)
})
// #endif
},
handleAgreePrivacyAuthorization() {
this.showPrivacyPopup = false
if (!this.resolvePrivacyAuthorization) {
return
}
const resolvePrivacyAuthorization = this.resolvePrivacyAuthorization
this.resolvePrivacyAuthorization = null
resolvePrivacyAuthorization({
buttonId: 'register-privacy-agree-btn',
event: 'agree'
})
},
handlePrivacyDisagree() {
this.showPrivacyPopup = false
if (!this.resolvePrivacyAuthorization) {
return
}
const resolvePrivacyAuthorization = this.resolvePrivacyAuthorization
this.resolvePrivacyAuthorization = null
resolvePrivacyAuthorization({
event: 'disagree'
})
},
uploadAttachment(field) { uploadAttachment(field) {
return this.chooseImageFile() return this.ensurePrivacyAuthorized()
.then(() => this.chooseImageFile())
.then((file) => { .then((file) => {
if (!file || !file.path) return if (!file || !file.path) return
uni.showLoading({ uni.showLoading({
@@ -1008,6 +1303,13 @@ export default {
}) })
.catch((error) => { .catch((error) => {
if (error && error.cancel) return if (error && error.cancel) return
if (error && error.privacyDenied) {
uni.showToast({
title: this.t('privacyAuthorizeFailed'),
icon: 'none'
})
return
}
console.error('upload attachment failed', error) console.error('upload attachment failed', error)
uni.showToast({ uni.showToast({
title: this.t('uploadFailed'), title: this.t('uploadFailed'),
@@ -1104,8 +1406,6 @@ export default {
title: this.t('submitting') title: this.t('submitting')
}) })
const authProfile = this.buildAuthProfile()
return this.getWechatCode().then((providerValue) => { return this.getWechatCode().then((providerValue) => {
const submitPayload = this.buildSubmitPayload(providerValue) const submitPayload = this.buildSubmitPayload(providerValue)
return submitRegistrationApplication(submitPayload) return submitRegistrationApplication(submitPayload)
@@ -1179,6 +1479,7 @@ export default {
if (field === 'passportAttachment') return 'passport-attachment' if (field === 'passportAttachment') return 'passport-attachment'
if (field === 'idCardFrontAttachment') return 'id-card-front' if (field === 'idCardFrontAttachment') return 'id-card-front'
if (field === 'idCardBackAttachment') return 'id-card-back' if (field === 'idCardBackAttachment') return 'id-card-back'
if (field === 'admissionNoticeAttachment') return 'admission-notice-attachment'
return this.getFileNameFromPath(filePath) return this.getFileNameFromPath(filePath)
}, },
getFileNameFromPath(filePath = '') { getFileNameFromPath(filePath = '') {
@@ -1204,6 +1505,9 @@ export default {
if (!this.form.workUnit || this.form.hasHangzhouProperty === null) { if (!this.form.workUnit || this.form.hasHangzhouProperty === null) {
return this.t('validateProfile') return this.t('validateProfile')
} }
if (!this.form.idCardType) {
return this.t('validateIdCardType')
}
if (this.form.identityType === 'DomesticStudents') { if (this.form.identityType === 'DomesticStudents') {
if (!this.form.idCard) { if (!this.form.idCard) {
@@ -1216,13 +1520,13 @@ export default {
} }
if (!this.form.passportNumber || !this.form.passportExpiry) { if (!this.form.passportNumber || !this.form.passportExpiry) {
return this.t('validatePassportBasic') return this.isPassportType ? this.t('validatePassportBasic') : this.t('validateDocumentBasic')
} }
if (!this.form.passportAttachment.serverValue) { if (!this.form.passportAttachment.serverValue) {
return this.t('validatePassportAttachment') return this.isPassportType ? this.t('validatePassportAttachment') : this.t('validateDocumentAttachment')
} }
if (!this.isImageAttachment(this.form.passportAttachment)) { if (!this.isImageAttachment(this.form.passportAttachment)) {
return this.t('validatePassportAttachmentImage') return this.isPassportType ? this.t('validatePassportAttachmentImage') : this.t('validateDocumentAttachmentImage')
} }
return '' return ''
}, },
@@ -1246,6 +1550,8 @@ export default {
genderText: getGenderLabel(this.form.gender, this.form.gender === 1 ? this.t('genderMale') : this.form.gender === 0 ? this.t('genderFemale') : ''), genderText: getGenderLabel(this.form.gender, this.form.gender === 1 ? this.t('genderMale') : this.form.gender === 0 ? this.t('genderFemale') : ''),
phone: this.form.phone, phone: this.form.phone,
country: this.form.country, country: this.form.country,
idCardType: Number(this.form.idCardType) || null,
idCardTypeText: this.selectedIdCardTypeLabel || '',
idCard: this.form.identityType === 'DomesticStudents' ? this.form.idCard : '', idCard: this.form.identityType === 'DomesticStudents' ? this.form.idCard : '',
workUnit: this.form.workUnit, workUnit: this.form.workUnit,
hasHangzhouProperty: this.form.hasHangzhouProperty, hasHangzhouProperty: this.form.hasHangzhouProperty,
@@ -1260,6 +1566,7 @@ export default {
school: this.form.identityType === 'ForeignStudents' ? this.form.school : '', school: this.form.identityType === 'ForeignStudents' ? this.form.school : '',
religion: this.form.religion, religion: this.form.religion,
religionTags: this.buildSingleTag(this.form.religion, this.religionOptions), religionTags: this.buildSingleTag(this.form.religion, this.religionOptions),
admissionNoticeAttachment: this.form.admissionNoticeAttachment.serverValue || '',
passportAttachment: this.form.identityType === 'ForeignStudents' ? this.form.passportAttachment.serverValue : '', passportAttachment: this.form.identityType === 'ForeignStudents' ? this.form.passportAttachment.serverValue : '',
idCardFrontAttachment: this.form.identityType === 'DomesticStudents' ? this.form.idCardFrontAttachment.serverValue : '', idCardFrontAttachment: this.form.identityType === 'DomesticStudents' ? this.form.idCardFrontAttachment.serverValue : '',
idCardBackAttachment: this.form.identityType === 'DomesticStudents' ? this.form.idCardBackAttachment.serverValue : '' idCardBackAttachment: this.form.identityType === 'DomesticStudents' ? this.form.idCardBackAttachment.serverValue : ''
@@ -1278,6 +1585,8 @@ export default {
gender: profile.gender, gender: profile.gender,
phone: profile.phone, phone: profile.phone,
country: profile.country, country: profile.country,
idCardType: profile.idCardType || null,
IdCardType: profile.idCardType || null,
idCard: profile.idCard || null, idCard: profile.idCard || null,
workUnit: profile.workUnit, workUnit: profile.workUnit,
hasHangzhouProperty: profile.hasHangzhouProperty, hasHangzhouProperty: profile.hasHangzhouProperty,
@@ -1287,6 +1596,7 @@ export default {
passportExpiry: profile.passportExpiry || null, passportExpiry: profile.passportExpiry || null,
school: profile.school || null, school: profile.school || null,
religionTags: profile.religionTags.length ? profile.religionTags : null, religionTags: profile.religionTags.length ? profile.religionTags : null,
admissionNoticeAttachment: profile.admissionNoticeAttachment || null,
passportAttachment: profile.passportAttachment || null, passportAttachment: profile.passportAttachment || null,
idCardFrontAttachment: profile.idCardFrontAttachment || null, idCardFrontAttachment: profile.idCardFrontAttachment || null,
idCardBackAttachment: profile.idCardBackAttachment || null idCardBackAttachment: profile.idCardBackAttachment || null
@@ -1469,11 +1779,21 @@ export default {
font-weight: bold; font-weight: bold;
font-size: 28rpx; font-size: 28rpx;
} }
.role-tab-subtitle {
margin-top: 6rpx;
font-size: 20rpx;
font-weight: 500;
line-height: 1.2;
color: #8A99B3;
}
.role-active { .role-active {
background-color: #FFFFFF; background-color: #FFFFFF;
color: $brand-primary; color: $brand-primary;
box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.15); box-shadow: 0 6rpx 20rpx rgba($brand-primary, 0.15);
} }
.role-active .role-tab-subtitle {
color: rgba($brand-primary, 0.82);
}
/* 性别按钮 */ /* 性别按钮 */
.gender-btn { .gender-btn {
@@ -1736,4 +2056,84 @@ export default {
.login-link:active { .login-link:active {
opacity: 0.7; opacity: 0.7;
} }
.privacy-mask {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(17, 24, 39, 0.56);
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
box-sizing: border-box;
}
.privacy-dialog {
width: 100%;
max-width: 640rpx;
background: #FFFFFF;
border-radius: 28rpx;
padding: 40rpx 32rpx 32rpx;
box-sizing: border-box;
box-shadow: 0 24rpx 80rpx rgba(15, 23, 42, 0.16);
}
.privacy-title {
font-size: 34rpx;
font-weight: 700;
color: #1A2233;
text-align: center;
}
.privacy-content {
margin-top: 24rpx;
font-size: 28rpx;
line-height: 1.7;
color: #5C6B8A;
}
.privacy-link {
color: $brand-primary;
font-weight: 700;
}
.privacy-action-row {
display: flex;
margin-top: 32rpx;
}
.privacy-action-btn {
flex: 1;
height: 88rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 700;
color: $brand-primary;
background: rgba($brand-primary, 0.08);
border: 1rpx solid rgba($brand-primary, 0.12);
box-sizing: border-box;
}
.privacy-action-btn + .privacy-action-btn {
margin-left: 20rpx;
}
button.privacy-action-btn {
padding: 0;
line-height: 88rpx;
}
button.privacy-action-btn::after {
border: none;
}
.privacy-action-btn.is-primary {
color: #FFFFFF;
background: linear-gradient(135deg, rgba($brand-primary, 0.92) 0%, $brand-primary 100%);
border-color: transparent;
}
</style> </style>

View File

@@ -87,6 +87,9 @@
</template> </template>
<script> <script>
import { resolveExistingBookingNavigationTarget } from '../../utils/booking-entry'
import { getSessionSnapshot } from '../../utils/app-state'
import { syncLoginStayState } from '../../utils/session-sync'
import { compactEnglishText } from '../../utils/select-room-normalizers' import { compactEnglishText } from '../../utils/select-room-normalizers'
export default { export default {
@@ -194,11 +197,41 @@ export default {
console.error('Failed to parse apply data', e); console.error('Failed to parse apply data', e);
}) })
} }
this.redirectIfBookingUnavailable()
}, },
methods: { methods: {
t(key) { t(key) {
return this.$tm('pkgBooking.book.book', key) return this.$tm('pkgBooking.book.book', key)
}, },
redirectIfBookingUnavailable() {
const session = getSessionSnapshot()
if (!session.isLogin) {
return Promise.resolve(false)
}
return syncLoginStayState({
language: this.$getLanguage(),
force: true
})
.then((stayState) => {
const targetUrl = resolveExistingBookingNavigationTarget(stayState)
if (!targetUrl) {
return false
}
if (/^\/pages\/index\/index(?:\?|$)/.test(targetUrl)) {
uni.reLaunch({ url: targetUrl })
return true
}
uni.redirectTo({ url: targetUrl })
return true
})
.catch((error) => {
console.error('check booking entry failed', error)
return false
})
},
onStartDateChange(e) { onStartDateChange(e) {
this.form.startDate = e.detail.value; this.form.startDate = e.detail.value;
}, },

View File

@@ -41,17 +41,19 @@
</view> </view>
</scroll-view> </scroll-view>
<view class="time-grid"> <scroll-view class="time-scroll" scroll-x :show-scrollbar="false">
<view <view class="time-flex">
v-for="(item, index) in timeSlots" <view
:key="index" v-for="(item, index) in timeSlots"
class="time-slot" :key="index"
:class="{ 'time-slot--active': currentTimeIndex === index }" class="time-slot"
@click="selectTimeSlot(index)" :class="{ 'time-slot--active': currentTimeIndex === index }"
> @click="selectTimeSlot(index)"
{{ item }} >
{{ item }}
</view>
</view> </view>
</view> </scroll-view>
</view> </view>
<!-- 租住需求卡片 --> <!-- 租住需求卡片 -->
@@ -63,6 +65,15 @@
</view> </view>
</view> </view>
<view class="field-item">
<view class="field-label">{{ t('roomTypeLabel') }}</view>
<room-type-selector
v-model="formData.intendedRoomTypeCode"
:initial-area-code="formData.intendedAreaCode"
@change="handleRoomTypeChange"
/>
</view>
<view class="field-item"> <view class="field-item">
<view class="field-label">{{ t('durationLabel') }}</view> <view class="field-label">{{ t('durationLabel') }}</view>
<view class="tag-grid"> <view class="tag-grid">
@@ -119,8 +130,12 @@
</template> </template>
<script> <script>
import { submitApplicationForm } from '@/api/index.js' import { submitViewingApplicationForm } from '@/api/index.js'
import { resolveExistingBookingNavigationTarget } from '../../utils/booking-entry'
import { getSessionSnapshot } from '../../utils/app-state'
import { syncLoginStayState } from '../../utils/session-sync'
import { compactEnglishText, localizePayMethodText } from '../../utils/select-room-normalizers' import { compactEnglishText, localizePayMethodText } from '../../utils/select-room-normalizers'
import RoomTypeSelector from './components/RoomTypeSelector.vue'
function buildViewingDateTime(date = '', time = '', isEnd = false) { function buildViewingDateTime(date = '', time = '', isEnd = false) {
if (!date || !time) { if (!date || !time) {
@@ -134,7 +149,44 @@ function buildViewingDateTime(date = '', time = '', isEnd = false) {
return `${date}T${selected}:00` return `${date}T${selected}:00`
} }
function formatDatePart(value) {
return String(value).padStart(2, '0')
}
function shiftDate(date = '', days = 0) {
if (!date) {
return ''
}
const next = new Date(`${date}T00:00:00`)
if (!Number.isFinite(next.getTime())) {
return ''
}
next.setDate(next.getDate() + days)
return `${next.getFullYear()}-${formatDatePart(next.getMonth() + 1)}-${formatDatePart(next.getDate())}`
}
function buildExpectedStayRange(date = '', durationIndex = 0) {
if (!date) {
return {
start: null,
end: null
}
}
const durationDays = [90, 180, 270, 365, 365]
const normalizedIndex = durationIndex >= 0 ? durationIndex : 0
const endDate = shiftDate(date, durationDays[normalizedIndex] || durationDays[0])
return {
start: `${date}T00:00:00`,
end: endDate ? `${endDate}T23:59:59` : null
}
}
export default { export default {
components: {
RoomTypeSelector
},
computed: { computed: {
language() { language() {
return this.$getLanguage() return this.$getLanguage()
@@ -177,6 +229,8 @@ export default {
currentDateIndex: 0, currentDateIndex: 0,
currentTimeIndex: -1, currentTimeIndex: -1,
formData: { formData: {
intendedAreaCode: '',
intendedRoomTypeCode: '',
budget: '', budget: '',
duration: '', duration: '',
workplace: '' workplace: ''
@@ -191,6 +245,7 @@ export default {
selectViewingTimeDesc: '请选择您方便的时间段', selectViewingTimeDesc: '请选择您方便的时间段',
rentalNeeds: '您的租住需求', rentalNeeds: '您的租住需求',
rentalNeedsDesc: '帮助管家更好地为您推荐房源', rentalNeedsDesc: '帮助管家更好地为您推荐房源',
roomTypeLabel: '意向房型',
durationLabel: '预计租住时长', durationLabel: '预计租住时长',
budgetLabel: '租金预算 (元/月)', budgetLabel: '租金预算 (元/月)',
budgetPlaceholder: '请输入您的心理价位', budgetPlaceholder: '请输入您的心理价位',
@@ -226,7 +281,7 @@ export default {
selectDurationRequired: '请选择预计租住时长', selectDurationRequired: '请选择预计租住时长',
submitting: '提交中...', submitting: '提交中...',
submitSuccessTitle: '预约成功', submitSuccessTitle: '预约成功',
submitSuccessContent: '您的预约看房申请已提交。\n系统会自动转发给对应区域有房间权限的宿管,宿管确认带看后可直接完结订单,或选定房型房号后转为预定单发送给租客支付。', submitSuccessContent: '您的预约看房申请已提交,请等待管家联系',
submitFailed: '预约提交失败' submitFailed: '预约提交失败'
}, },
en: { en: {
@@ -236,6 +291,7 @@ export default {
selectViewingTimeDesc: 'Choose a time slot that works for you', selectViewingTimeDesc: 'Choose a time slot that works for you',
rentalNeeds: 'Rental Needs', rentalNeeds: 'Rental Needs',
rentalNeedsDesc: 'Help the manager recommend a suitable room', rentalNeedsDesc: 'Help the manager recommend a suitable room',
roomTypeLabel: 'Preferred Room Type',
durationLabel: 'Expected Stay Duration', durationLabel: 'Expected Stay Duration',
budgetLabel: 'Budget (CNY/month)', budgetLabel: 'Budget (CNY/month)',
budgetPlaceholder: 'Enter your expected budget', budgetPlaceholder: 'Enter your expected budget',
@@ -283,15 +339,47 @@ export default {
if (options.payload) { if (options.payload) {
try { try {
this.sourceData = JSON.parse(decodeURIComponent(options.payload)) this.sourceData = JSON.parse(decodeURIComponent(options.payload))
this.formData.intendedAreaCode = this.sourceData && this.sourceData.areaCode ? this.sourceData.areaCode : ''
this.formData.intendedRoomTypeCode = this.sourceData && this.sourceData.roomTypeCode ? this.sourceData.roomTypeCode : ''
} catch (e) { } catch (e) {
console.error('Failed to parse payload', e) console.error('Failed to parse payload', e)
} }
} }
this.redirectIfBookingUnavailable()
}, },
methods: { methods: {
t(key, params) { t(key, params) {
return this.$tm('pkgBooking.book_viewing.book_viewing', key, params) return this.$tm('pkgBooking.book_viewing.book_viewing', key, params)
}, },
redirectIfBookingUnavailable() {
const session = getSessionSnapshot()
if (!session.isLogin) {
return Promise.resolve(false)
}
return syncLoginStayState({
language: this.$getLanguage(),
force: true
})
.then((stayState) => {
const targetUrl = resolveExistingBookingNavigationTarget(stayState)
if (!targetUrl) {
return false
}
if (/^\/pages\/index\/index(?:\?|$)/.test(targetUrl)) {
uni.reLaunch({ url: targetUrl })
return true
}
uni.redirectTo({ url: targetUrl })
return true
})
.catch((error) => {
console.error('check viewing booking entry failed', error)
return false
})
},
syncDurationOptions() { syncDurationOptions() {
this.durationOptions = [ this.durationOptions = [
this.t('duration1'), this.t('duration1'),
@@ -339,50 +427,26 @@ export default {
selectTimeSlot(index) { selectTimeSlot(index) {
this.currentTimeIndex = index this.currentTimeIndex = index
}, },
handleRoomTypeChange({ area, roomType }) {
this.formData.intendedAreaCode = area && area.code ? area.code : ''
this.formData.intendedRoomTypeCode = roomType && roomType.code ? roomType.code : ''
if (!this.sourceData) {
this.sourceData = {}
}
if (area) {
this.sourceData.areaCode = area.code || ''
this.sourceData.areaName = area.name || ''
this.sourceData.areaNameEn = area.nameEn || ''
}
if (roomType) {
this.sourceData.roomTypeCode = roomType.code || ''
this.sourceData.roomTypeName = roomType.name || ''
this.sourceData.roomTypeNameEn = roomType.nameEn || ''
}
},
selectDuration(item) { selectDuration(item) {
this.formData.duration = item this.formData.duration = item
}, },
buildRemark(selectedDate, selectedTime) {
const lines = [
this.t('remarkTitle'),
this.t('remarkTime', { date: selectedDate, time: selectedTime }),
this.t('remarkDuration', { duration: this.formData.duration })
]
if (this.formData.budget) {
lines.push(this.t('remarkBudget', { budget: this.formData.budget }))
}
if (this.formData.workplace) {
lines.push(this.t('remarkWorkplace', { workplace: this.formData.workplace }))
}
if (this.sourceData && (this.sourceData.areaName || this.sourceData.areaNameEn)) {
lines.push(this.t('remarkArea', {
area: this.language === 'en'
? (this.sourceData.areaNameEnDisplay || this.sourceData.areaLabelEnDisplay || this.sourceData.areaNameEn || this.sourceData.areaLabelEn || this.sourceData.areaName || this.sourceData.areaLabel || '')
: (this.sourceData.areaName || this.sourceData.areaLabel || this.sourceData.areaNameEn || this.sourceData.areaLabelEn || '')
}))
}
if (this.sourceData && (this.sourceData.roomTypeName || this.sourceData.roomTypeNameEn)) {
lines.push(this.t('remarkRoomType', {
roomType: this.language === 'en'
? (this.sourceData.roomTypeNameEnDisplay || this.sourceData.roomTypeNameEn || this.sourceData.roomTypeName || '')
: (this.sourceData.roomTypeName || this.sourceData.roomTypeNameEn || '')
}))
}
if (this.sourceData && (this.sourceData.roomName || this.sourceData.roomNameEn)) {
lines.push(this.t('remarkSource', {
room: this.language === 'en'
? (this.sourceData.roomNameEnDisplay || this.sourceData.roomNameEn || this.sourceData.roomName || '')
: (this.sourceData.roomName || this.sourceData.roomNameEn || '')
}))
}
if (this.sourceData && this.sourceData.payMethod) {
lines.push(this.t('remarkPayMethod', { payMethod: localizePayMethodText(this.sourceData.payMethod, this.language) }))
}
lines.push(this.t('remarkTail'))
return lines.join('\n')
},
handleSubmit() { handleSubmit() {
if (this.isSubmitting) { if (this.isSubmitting) {
return return
@@ -398,20 +462,24 @@ export default {
const selectedDate = this.dateList[this.currentDateIndex].date const selectedDate = this.dateList[this.currentDateIndex].date
const selectedTime = this.timeSlots[this.currentTimeIndex] const selectedTime = this.timeSlots[this.currentTimeIndex]
const appointmentViewingTime = buildViewingDateTime(selectedDate, selectedTime, false)
const durationIndex = this.durationOptions.findIndex(item => item === this.formData.duration)
const expectedStayRange = buildExpectedStayRange(selectedDate, durationIndex)
const payload = { const payload = {
intendedAreaCode: this.sourceData && this.sourceData.areaCode ? this.sourceData.areaCode : null, intendedAreaCode: this.formData.intendedAreaCode || (this.sourceData && this.sourceData.areaCode ? this.sourceData.areaCode : null),
intendedRoomTypeCode: this.sourceData && this.sourceData.roomTypeCode ? this.sourceData.roomTypeCode : null, intendedRoomTypeCode: this.formData.intendedRoomTypeCode || (this.sourceData && this.sourceData.roomTypeCode ? this.sourceData.roomTypeCode : null),
roomSlotHeight: 1, roomSlotHeight: 1,
hobbiesAndInterestsTagKeys: null, hobbiesAndInterestsTagKeys: null,
lifestyleHabitsTagKeys: null, lifestyleHabitsTagKeys: null,
remark: this.buildRemark(selectedDate, selectedTime), remark: null,
start: buildViewingDateTime(selectedDate, selectedTime, false), start: expectedStayRange.start,
end: buildViewingDateTime(selectedDate, selectedTime, true) end: expectedStayRange.end,
}; appointmentViewingTime
}
this.isSubmitting = true this.isSubmitting = true
uni.showLoading({ title: this.t('submitting'), mask: true }) uni.showLoading({ title: this.t('submitting'), mask: true })
return submitApplicationForm(payload) return submitViewingApplicationForm(payload)
.then(() => { .then(() => {
uni.hideLoading() uni.hideLoading()
uni.showModal({ uni.showModal({
@@ -582,14 +650,18 @@ export default {
} }
/* 时间段选择 */ /* 时间段选择 */
.time-grid { .time-scroll {
display: flex; width: 100%;
flex-wrap: wrap; }
.time-flex {
display: inline-flex;
gap: 20rpx; gap: 20rpx;
padding-bottom: 8rpx;
} }
.time-slot { .time-slot {
width: calc(33.33% - 14rpx); width: 220rpx;
height: 72rpx; height: 72rpx;
line-height: 72rpx; line-height: 72rpx;
text-align: center; text-align: center;
@@ -599,6 +671,7 @@ export default {
color: #4E5969; color: #4E5969;
border: 2rpx solid transparent; border: 2rpx solid transparent;
transition: all 0.3s ease; transition: all 0.3s ease;
flex-shrink: 0;
} }
.time-slot--active { .time-slot--active {

View File

@@ -0,0 +1,363 @@
<template>
<view class="room-type-selector">
<view class="selector-header tn-flex tn-flex-row-between tn-flex-col-center" @click="openSelector">
<view class="selected-info">
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }">
{{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : t('selectRoomType') }}
</view>
<view class="selected-area tn-text-ellipsis" v-if="selectedArea">
{{ getAreaDisplayName(selectedArea) }}
</view>
</view>
<text class="tn-icon-right tn-color-gray"></text>
</view>
<tn-popup v-model="showPopup" mode="bottom" length="80%" :borderRadius="32" :closeBtn="true">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">{{ t('popupTitle') }}</text>
</view>
<scroll-view scroll-y class="area-list-scroll">
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
<tn-loading mode="circle"></tn-loading>
<view class="tn-margin-top-sm tn-color-gray">{{ t('loadingRoomTypes') }}</view>
</view>
<view v-else-if="!areaList.length" class="empty-state tn-padding-xl tn-text-center">
<text class="tn-color-gray">{{ t('emptyRoomTypes') }}</text>
</view>
<view v-else class="area-group" v-for="(area, index) in areaList" :key="area.code">
<view class="area-title tn-flex tn-flex-row-between tn-flex-col-center" @click="toggleArea(index)">
<view class="tn-flex tn-flex-col-center">
<text class="area-dot"></text>
<text>{{ getAreaDisplayName(area) }}</text>
</view>
<text :class="collapsedAreas[index] ? 'tn-icon-down' : 'tn-icon-up'" class="tn-color-gray"></text>
</view>
<view class="room-type-grid" v-show="!collapsedAreas[index]">
<view
class="room-type-item"
v-for="room in area.roomTypes"
:key="room.code"
:class="{ 'is-active': selectedRoomType && selectedRoomType.code === room.code }"
@click="handleSelect(area, room)"
>
<view class="room-name tn-text-ellipsis">{{ getRoomTypeDisplayName(room) }}</view>
<view class="room-price" v-if="room.price">¥{{ getPriceValue(room.price) }}{{ t('priceUnit') }}</view>
</view>
</view>
</view>
</scroll-view>
</view>
</tn-popup>
</view>
</template>
<script>
import { areaPagination } from '@/api/index.js'
import { normalizeAreaItem } from '../../../utils/select-room-normalizers'
const I18N = {
zh: {
emptyRoomTypes: '暂无可选房型',
loadFailed: '房型加载失败',
loadingRoomTypes: '房型加载中...',
popupTitle: '选择意向房型',
priceUnit: '/月',
selectRoomType: '请选择房型'
},
en: {
emptyRoomTypes: 'No room types available',
loadFailed: 'Failed to load room types',
loadingRoomTypes: 'Loading room types...',
popupTitle: 'Select Room Type',
priceUnit: '/month',
selectRoomType: 'Select room type'
}
}
export default {
name: 'BookViewingRoomTypeSelector',
props: {
value: {
type: String,
default: ''
},
initialAreaCode: {
type: String,
default: ''
}
},
data() {
return {
showPopup: false,
isLoading: false,
areaList: [],
selectedArea: null,
selectedRoomType: null,
collapsedAreas: {}
}
},
computed: {
language() {
return this.$getLanguage ? this.$getLanguage() : 'zh'
}
},
watch: {
value() {
this.syncSelection()
},
language() {
this.loadData(true)
}
},
mounted() {
this.loadData()
},
methods: {
t(key) {
const lang = I18N[this.language] ? this.language : 'zh'
return I18N[lang][key] || I18N.zh[key] || key
},
openSelector() {
this.showPopup = true
this.loadData()
},
loadData(force = false) {
if (!force && this.areaList.length > 0) {
this.syncSelection()
return Promise.resolve()
}
this.isLoading = true
return areaPagination({ PageIndex: 1, PageSize: 100 }, this.language)
.then((res) => {
const payload = res && res.data ? res.data : res
const rawList = Array.isArray(payload && payload.data) ? payload.data : (Array.isArray(payload) ? payload : [])
this.areaList = rawList
.map(item => normalizeAreaItem(item))
.filter(area => Array.isArray(area.roomTypes) && area.roomTypes.length > 0)
const collapsed = {}
this.areaList.forEach((_, index) => {
collapsed[index] = false
})
this.collapsedAreas = collapsed
this.syncSelection()
})
.catch((error) => {
console.error('load room types error', error)
uni.showToast({ title: this.t('loadFailed'), icon: 'none' })
})
.finally(() => {
this.isLoading = false
})
},
syncSelection() {
if (!this.areaList.length) {
this.selectedArea = null
this.selectedRoomType = null
return
}
let foundArea = null
let foundRoom = null
const previousAreaCode = this.selectedArea && this.selectedArea.code ? this.selectedArea.code : ''
const previousRoomCode = this.selectedRoomType && this.selectedRoomType.code ? this.selectedRoomType.code : ''
if (this.value) {
for (const area of this.areaList) {
const room = Array.isArray(area.roomTypes) ? area.roomTypes.find(item => item.code === this.value) : null
if (room) {
foundArea = area
foundRoom = room
break
}
}
}
if (!foundRoom && this.initialAreaCode) {
foundArea = this.areaList.find(area => area.code === this.initialAreaCode) || null
}
if (!foundArea) {
this.selectedArea = null
this.selectedRoomType = null
return
}
this.selectedArea = foundArea
this.selectedRoomType = foundRoom || null
if (foundRoom) {
if (this.value !== foundRoom.code) {
this.$emit('input', foundRoom.code)
}
if (previousAreaCode !== foundArea.code || previousRoomCode !== foundRoom.code) {
this.$emit('change', { area: foundArea, roomType: foundRoom })
}
}
},
handleSelect(area, roomType) {
this.selectedArea = area
this.selectedRoomType = roomType
this.$emit('input', roomType.code)
this.$emit('change', { area, roomType })
this.showPopup = false
},
getAreaDisplayName(area = {}) {
if (this.language === 'en') {
return area.nameEnDisplay || area.nameEn || area.name || ''
}
return area.name || area.nameEn || ''
},
getRoomTypeDisplayName(roomType = {}) {
if (this.language === 'en') {
return roomType.nameEnDisplay || roomType.nameEn || roomType.name || ''
}
return roomType.name || roomType.nameEn || ''
},
toggleArea(index) {
this.$set(this.collapsedAreas, index, !this.collapsedAreas[index])
},
getPriceValue(priceText) {
if (!priceText) {
return '--'
}
const matched = String(priceText).match(/(\d+(\.\d+)?)/)
return matched ? matched[1] : '--'
}
}
}
</script>
<style scoped lang="scss">
.room-type-selector {
width: 100%;
}
.selector-header {
background: #FFFFFF;
border-radius: 20rpx;
padding: 26rpx 32rpx;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
min-height: 100rpx;
}
.selected-info {
flex: 1;
overflow: hidden;
}
.selected-name {
font-size: 28rpx;
color: #1A2233;
font-weight: 600;
&.is-empty {
color: #A0B0C0;
font-weight: normal;
}
}
.selected-area {
font-size: 22rpx;
color: #6A7A99;
margin-top: 6rpx;
}
.popup-content {
display: flex;
flex-direction: column;
height: 100%;
background: #F8FAFF;
}
.popup-header {
padding: 40rpx 30rpx 20rpx;
text-align: center;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
background: #FFFFFF;
}
.popup-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.area-list-scroll {
flex: 1;
height: 0;
padding: 20rpx 30rpx 60rpx;
}
.area-group {
margin-bottom: 30rpx;
background: #FFFFFF;
border-radius: 20rpx;
padding: 24rpx;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.04);
border: 1rpx solid rgba($brand-primary, 0.05);
}
.area-title {
font-size: 28rpx;
font-weight: 600;
color: #1A2233;
margin-bottom: 20rpx;
}
.area-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: $brand-primary;
margin-right: 12rpx;
box-shadow: 0 0 8rpx rgba($brand-primary, 0.4);
}
.room-type-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.room-type-item {
padding: 20rpx;
border-radius: 16rpx;
background: #FFFFFF;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
transition: all 0.3s ease;
&.is-active {
background: #FFFFFF;
border-color: $brand-primary;
box-shadow: 0 4rpx 16rpx rgba($brand-primary, 0.1);
.room-name {
color: $brand-primary;
}
.room-price {
color: $brand-primary;
}
}
}
.room-name {
font-size: 26rpx;
font-weight: 600;
color: #1A2233;
margin-bottom: 8rpx;
}
.room-price {
font-size: 22rpx;
color: #6A7A99;
}
</style>

View File

@@ -45,6 +45,14 @@
<script> <script>
import { localizeHousingTag, localizePayMethodText } from '../../../utils/select-room-normalizers' import { localizeHousingTag, localizePayMethodText } from '../../../utils/select-room-normalizers'
function stripHtmlTags(value = '') {
return String(value || '')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/\s+/g, ' ')
.trim()
}
export default { export default {
props: { props: {
roomTypes: { roomTypes: {
@@ -80,13 +88,14 @@ export default {
return room.name || room.nameEn || '' return room.name || room.nameEn || ''
}, },
getRoomDescription(room = {}) { getRoomDescription(room = {}) {
if (this.language === 'en') { const rawDescription = this.language === 'en'
return room.descriptionEnDisplay || room.descriptionEn || room.description || '' ? (room.descriptionEnDisplay || room.descriptionEn || room.description || '')
} : (room.description || room.descriptionEn || '')
return room.description || room.descriptionEn || ''
return stripHtmlTags(rawDescription)
}, },
goToRoomDetail(room) { goToRoomDetail(room) {
this.$emit('go-detail', room); this.$emit('go-detail', room)
} }
} }
} }

View File

@@ -19,10 +19,16 @@
<view class="section-header"> <view class="section-header">
<text class="section-title">{{ t('storeIntro') }}</text> <text class="section-title">{{ t('storeIntro') }}</text>
</view> </view>
<view class="store-desc" :class="{'store-desc--collapsed': isDescCollapsed}"> <view
{{ displayDescription }} class="store-desc"
:class="{
'store-desc--collapsed': !isRichDescription && isDescCollapsed
}"
>
<rich-text v-if="isRichDescription" class="store-desc-rich" :nodes="displayDescriptionNodes"></rich-text>
<text v-else>{{ displayDescription }}</text>
</view> </view>
<view class="expand-btn tn-flex tn-flex-row-center tn-flex-col-center" @click="toggleDesc"> <view v-if="shouldShowToggle" class="expand-btn tn-flex tn-flex-row-center tn-flex-col-center" @click="toggleDesc">
<text>{{ isDescCollapsed ? t('expandAll') : t('collapse') }}</text> <text>{{ isDescCollapsed ? t('expandAll') : t('collapse') }}</text>
<text :class="isDescCollapsed ? 'tn-icon-down' : 'tn-icon-up'" class="expand-icon"></text> <text :class="isDescCollapsed ? 'tn-icon-down' : 'tn-icon-up'" class="expand-icon"></text>
</view> </view>
@@ -31,6 +37,33 @@
</template> </template>
<script> <script>
import { normalizeRichTextImages } from '../../../utils/rich-text'
function hasHtmlTag(value = '') {
return /<\/?(p|div|img|br|span|h[1-6]|ul|li|ol|table|td|tr|strong|em|section|article|blockquote)[^>]*>/i.test(String(value || ''))
}
function normalizeRichDescription(value = '') {
let html = String(value || '').trim()
if (!html) {
return ''
}
html = normalizeRichTextImages(
html,
'display:block;width:100%;max-width:100%;height:auto;box-sizing:border-box;border-radius:16rpx;margin:16rpx 0;vertical-align:top;'
)
html = html.replace(/<p\b([^>]*)>/gi, '<p$1 style="line-height:1.9;color:rgba(255,255,255,0.78);margin:0 0 20rpx;word-break:break-word;">')
html = html.replace(/<div\b([^>]*)>/gi, '<div$1 style="line-height:1.9;color:rgba(255,255,255,0.78);margin:0 0 20rpx;word-break:break-word;">')
html = html.replace(/<ul\b([^>]*)>/gi, '<ul$1 style="padding-left:28rpx;margin:0 0 20rpx;color:rgba(255,255,255,0.78);line-height:1.9;">')
html = html.replace(/<ol\b([^>]*)>/gi, '<ol$1 style="padding-left:32rpx;margin:0 0 20rpx;color:rgba(255,255,255,0.78);line-height:1.9;">')
html = html.replace(/<li\b([^>]*)>/gi, '<li$1 style="margin-bottom:12rpx;">')
html = html.replace(/<h([1-6])\b([^>]*)>/gi, '<h$1$2 style="color:#FFFFFF;line-height:1.5;margin:0 0 20rpx;font-weight:600;">')
html = html.replace(/<strong\b([^>]*)>/gi, '<strong$1 style="color:#FFFFFF;font-weight:600;">')
html = html.replace(/<a\b([^>]*)>/gi, '<a$1 style="color:#9EC5FF;text-decoration:underline;">')
return html
}
export default { export default {
props: { props: {
storeData: { storeData: {
@@ -54,6 +87,18 @@ export default {
} }
return this.storeData.description || this.storeData.descriptionEn || '' return this.storeData.description || this.storeData.descriptionEn || ''
}, },
isRichDescription() {
return hasHtmlTag(this.displayDescription)
},
displayDescriptionNodes() {
return this.isRichDescription ? normalizeRichDescription(this.displayDescription) : ''
},
shouldShowToggle() {
if (this.isRichDescription) {
return false
}
return String(this.displayDescription || '').length > 90
},
shortAddress() { shortAddress() {
const addr = this.language === 'en' const addr = this.language === 'en'
? (this.storeData.addressEnDisplay || this.storeData.addressEn || this.storeData.address || '') ? (this.storeData.addressEnDisplay || this.storeData.addressEn || this.storeData.address || '')
@@ -176,6 +221,7 @@ export default {
font-size: 28rpx; font-size: 28rpx;
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
line-height: 1.6; line-height: 1.6;
word-break: break-word;
} }
.store-desc--collapsed { .store-desc--collapsed {
@@ -185,6 +231,10 @@ export default {
overflow: hidden; overflow: hidden;
} }
.store-desc-rich {
display: block;
}
.expand-btn { .expand-btn {
margin-top: 16rpx; margin-top: 16rpx;
font-size: 24rpx; font-size: 24rpx;

View File

@@ -114,14 +114,28 @@
v-if="canCancelOrder" v-if="canCancelOrder"
class="danger-btn" class="danger-btn"
:class="{ 'danger-btn--loading': canceling }" :class="{ 'danger-btn--loading': canceling }"
@tap.stop="handleCancelOrder" @click.stop="handleCancelOrder"
> >
<text class="tn-text-bold">{{ canceling ? t('cancelOrderLoading') : t('cancelOrder') }}</text> <text class="tn-text-bold">{{ canceling ? t('cancelOrderLoading') : t('cancelOrder') }}</text>
</view> </view>
<view <view
v-if="canViewBill" v-if="currentStepActionText"
class="action-btn" class="action-btn"
@tap.stop="goToBillPage" @click.stop="handleCurrentStepAction"
>
<text class="tn-text-bold">{{ currentStepActionText }}</text>
</view>
<view
v-else-if="isReservationCheckinProcessing()"
class="action-btn action-btn--ghost"
@click.stop="refreshCurrentPage"
>
<text class="tn-text-bold">{{ t('refreshAction') }}</text>
</view>
<view
v-else-if="canViewBill"
class="action-btn"
@click.stop="goToBillPage"
> >
<text class="tn-text-bold">{{ t('viewBill') }}</text> <text class="tn-text-bold">{{ t('viewBill') }}</text>
</view> </view>
@@ -131,8 +145,48 @@
</template> </template>
<script> <script>
import { userDetailCancel } from '@/api/index.js' import { contractDetail, orderSearchedReservationFlow, roomInspectionDetail, userDetailCancel } from '@/api/index.js'
import { getMyApplicationList, saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state' import { getMyApplicationList, saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state'
import {
RESERVATION_FLOW_STEP,
attachReservationFlow,
buildReservationFlowMap,
getReservationCurrentActionText,
getReservationCurrentStepText,
getReservationOrderNo,
parseReservationFlowResponse
} from '@/utils/reservation-flow'
const VIEWING_APPLICATION_PATTERN = /预约看房申请|看房时间[::]|意向房型[::]|意向区域[::]|viewing request|viewing time[::]|preferred room type[::]|preferred area[::]/i
function isViewingApplicationRecord(item = {}) {
if (!item || typeof item !== 'object') {
return false
}
const typeText = [
item.businessType,
item.applicationType,
item.applicationTypeDesc,
item.formType,
item.bizType,
item.type,
item.typeDesc
]
.filter(value => value !== null && typeof value !== 'undefined' && String(value).trim())
.join(' ')
if (/viewing|看房|带看/i.test(typeText)) {
return true
}
if ([item.appointmentViewingTime, item.viewingTime, item.bookViewingTime].some(value => String(value || '').trim())) {
return true
}
const remark = item && item.remark ? String(item.remark) : ''
return VIEWING_APPLICATION_PATTERN.test(remark)
}
function normalizeApplicationList(list = []) { function normalizeApplicationList(list = []) {
if (!Array.isArray(list)) { if (!Array.isArray(list)) {
@@ -141,15 +195,31 @@ function normalizeApplicationList(list = []) {
return list return list
.filter(item => item && typeof item === 'object') .filter(item => item && typeof item === 'object')
.map((item) => { .map((item) => {
const remark = item && item.remark ? String(item.remark) : ''
const isViewingApplication = /预约看房申请|看房时间[::]|意向房型[::]|意向区域[::]|viewing request|viewing time[::]|preferred room type[::]|preferred area[::]/i.test(remark)
return { return {
...item, ...item,
isViewingApplication isViewingApplication: isViewingApplicationRecord(item)
} }
}) })
} }
function parseJsonStringMaybe(value) {
if (typeof value !== 'string') {
return value
}
const text = value.trim()
if (!text) {
return value
}
if (!((text.startsWith('{') && text.endsWith('}')) || (text.startsWith('[') && text.endsWith(']')))) {
return value
}
try {
return JSON.parse(text)
} catch (error) {
return value
}
}
export default { export default {
i18n: { i18n: {
zh: { zh: {
@@ -186,6 +256,9 @@ export default {
emptyRemark: '暂无补充说明', emptyRemark: '暂无补充说明',
codeCopied: '申请编号已复制', codeCopied: '申请编号已复制',
missingBillCode: '申请单编号缺失', missingBillCode: '申请单编号缺失',
missingContractNo: '合同编号缺失',
checkinPendingHint: '宿管正在办理入住,请耐心等待',
refreshAction: '刷新',
cancelOrder: '取消订单', cancelOrder: '取消订单',
cancelOrderLoading: '取消中...', cancelOrderLoading: '取消中...',
cancelOrderTitle: '取消订单', cancelOrderTitle: '取消订单',
@@ -235,6 +308,9 @@ export default {
emptyRemark: 'No additional remarks', emptyRemark: 'No additional remarks',
codeCopied: 'Application number copied', codeCopied: 'Application number copied',
missingBillCode: 'Missing application bill number', missingBillCode: 'Missing application bill number',
missingContractNo: 'Missing contract number',
checkinPendingHint: 'Move-in is being processed, please wait',
refreshAction: 'Refresh',
cancelOrder: 'Cancel Order', cancelOrder: 'Cancel Order',
cancelOrderLoading: 'Canceling...', cancelOrderLoading: 'Canceling...',
cancelOrderTitle: 'Cancel Order', cancelOrderTitle: 'Cancel Order',
@@ -255,7 +331,14 @@ export default {
return { return {
detailCode: '', detailCode: '',
detail: {}, detail: {},
canceling: false canceling: false,
reservationContractDetail: null,
reservationInspectionDetail: null
}
},
watch: {
language() {
this.loadReservationFlow()
} }
}, },
computed: { computed: {
@@ -323,7 +406,35 @@ export default {
} }
return this.parseViewingRemark() return this.parseViewingRemark()
}, },
currentStepActionText() {
if (this.isViewingApplication) {
return ''
}
if (this.isReservationInspectionPendingReview()) {
return ''
}
if (this.isReservationContractAwaitingReview()) {
return ''
}
if (this.isReservationCheckinProcessing()) {
return ''
}
return getReservationCurrentActionText(this.detail, this.language)
},
statusReminderText() { statusReminderText() {
if (this.isReservationInspectionPendingReview()) {
return this.getReservationInspectionStatusText()
}
if (this.isReservationContractAwaitingReview()) {
return this.getReservationContractStatusText()
}
if (this.isReservationCheckinProcessing()) {
return this.t('checkinPendingHint')
}
const reservationStepText = getReservationCurrentStepText(this.detail, this.language)
if (reservationStepText) {
return reservationStepText
}
if (this.shouldPayDeposit(this.detail)) { if (this.shouldPayDeposit(this.detail)) {
return this.t('statusPendingPayment') return this.t('statusPendingPayment')
} }
@@ -351,6 +462,9 @@ export default {
this.detailCode = options.code || '' this.detailCode = options.code || ''
this.restoreDetail(options.payload || '') this.restoreDetail(options.payload || '')
}, },
onShow() {
this.loadReservationFlow()
},
methods: { methods: {
t(key, params) { t(key, params) {
return this.$tm('pkgService.application_detail.application_detail', key, params) return this.$tm('pkgService.application_detail.application_detail', key, params)
@@ -368,6 +482,106 @@ export default {
&& Number(item.status) === 0 && Number(item.status) === 0
&& Number(item.payStatus) === 10 && Number(item.payStatus) === 10
}, },
getReservationCurrentStepValue() {
return Number(this.detail && this.detail.reservationCurrentNode ? this.detail.reservationCurrentNode.step : 0)
},
isReservationCheckinProcessing() {
return this.getReservationCurrentStepValue() === RESERVATION_FLOW_STEP.CHECKIN
},
getReservationInspectionNo() {
const currentNode = this.detail && this.detail.reservationCurrentNode ? this.detail.reservationCurrentNode : {}
if (Number(currentNode.step || 0) !== RESERVATION_FLOW_STEP.INSPECTION) {
return ''
}
return String(currentNode.refNo || '').trim()
},
getReservationContractNo() {
const currentNode = this.detail && this.detail.reservationCurrentNode ? this.detail.reservationCurrentNode : {}
if (Number(currentNode.step || 0) !== RESERVATION_FLOW_STEP.CONTRACT) {
return ''
}
return String(currentNode.refNo || '').trim()
},
normalizeInspectionDetail(response) {
const payload = parseJsonStringMaybe(response && response.data ? response.data : response)
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload
}
if (payload && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
return payload.data
}
return {}
},
normalizeContractDetail(response) {
const payload = parseJsonStringMaybe(response && response.data ? response.data : response)
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload
}
if (payload && payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
return payload.data
}
return {}
},
isReservationInspectionPendingReview() {
if (this.getReservationCurrentStepValue() !== RESERVATION_FLOW_STEP.INSPECTION) {
return false
}
const detail = this.reservationInspectionDetail || {}
return Number(detail.status) === 2
},
getReservationInspectionStatusText() {
return this.t('inspectionPendingReviewHint')
},
isReservationContractAwaitingReview() {
if (this.getReservationCurrentStepValue() !== RESERVATION_FLOW_STEP.CONTRACT) {
return false
}
const detail = this.reservationContractDetail || {}
const queryStatus = Number(detail.queryStatus)
const approvalStatus = Number(detail.approvalStatus)
const hasSigned = !!String(detail.signTime || detail.signImageUrl || '').trim()
return queryStatus === 1 || (hasSigned && approvalStatus === 0)
},
getReservationContractStatusText() {
const detail = this.reservationContractDetail || {}
return detail.queryStatusDesc || detail.approvalStatusDesc || detail.statusDesc || ''
},
async ensureReservationInspectionDetail() {
const inspectionNo = this.getReservationInspectionNo()
if (!inspectionNo) {
this.reservationInspectionDetail = null
return null
}
try {
const detail = this.normalizeInspectionDetail(await roomInspectionDetail({
inspectionNo
}, this.language))
this.reservationInspectionDetail = detail && Object.keys(detail).length ? detail : null
return this.reservationInspectionDetail
} catch (error) {
console.error('load inspection detail failed', error)
this.reservationInspectionDetail = null
return null
}
},
async ensureReservationContractDetail() {
const contractNo = this.getReservationContractNo()
if (!contractNo) {
this.reservationContractDetail = null
return null
}
try {
const detail = this.normalizeContractDetail(await contractDetail({
contractNo
}, this.language))
this.reservationContractDetail = detail && Object.keys(detail).length ? detail : null
return this.reservationContractDetail
} catch (error) {
console.error('load contract detail failed', error)
this.reservationContractDetail = null
return null
}
},
restoreDetail(payload = '') { restoreDetail(payload = '') {
if (payload) { if (payload) {
try { try {
@@ -386,6 +600,46 @@ export default {
isViewingApplication: false isViewingApplication: false
} }
}, },
async loadReservationFlow() {
if (!this.detail || this.detail.isViewingApplication) {
return
}
const orderNo = getReservationOrderNo(this.detail)
if (!orderNo) {
this.detail = attachReservationFlow(this.detail, {})
this.reservationInspectionDetail = null
this.reservationContractDetail = null
return
}
try {
const response = await orderSearchedReservationFlow({
orderNos: [orderNo]
}, this.language)
const flowMap = buildReservationFlowMap(parseReservationFlowResponse(response))
this.detail = attachReservationFlow(this.detail, flowMap)
await Promise.all([
this.ensureReservationInspectionDetail(),
this.ensureReservationContractDetail()
])
const list = normalizeApplicationList(getMyApplicationList())
if (list.length) {
saveMyApplicationList(list.map((item) => {
if (String(item.detailCode || '') !== String(this.detail.detailCode || '')) {
return item
}
return this.detail
}))
}
} catch (error) {
console.error('load reservation flow failed', error)
this.detail = attachReservationFlow(this.detail, {})
this.reservationInspectionDetail = null
this.reservationContractDetail = null
}
},
parseViewingRemark() { parseViewingRemark() {
const lines = this.remarkLines const lines = this.remarkLines
const targets = [ const targets = [
@@ -537,6 +791,54 @@ export default {
uni.navigateTo({ uni.navigateTo({
url: `/pkgService/utility_recharge/utility_recharge?referenceTypeNo=${encodeURIComponent(this.detail.detailCode)}` url: `/pkgService/utility_recharge/utility_recharge?referenceTypeNo=${encodeURIComponent(this.detail.detailCode)}`
}) })
},
refreshCurrentPage() {
this.loadReservationFlow()
},
handleCurrentStepAction() {
const step = this.getReservationCurrentStepValue()
if (step === RESERVATION_FLOW_STEP.BILL) {
this.goToBillPage()
return
}
if (step === RESERVATION_FLOW_STEP.CONTRACT) {
if (this.isReservationContractAwaitingReview()) {
return
}
const contractNo = String(this.detail && this.detail.reservationCurrentNode && this.detail.reservationCurrentNode.refNo || '').trim()
if (!contractNo) {
uni.showToast({
title: this.t('missingContractNo'),
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pkgService/contract_sign/contract_sign?no=${encodeURIComponent(contractNo)}`
})
return
}
if (step === RESERVATION_FLOW_STEP.CHECKIN) {
uni.reLaunch({
url: '/pages/index/index?pageId=stay-service'
})
return
}
if (step === RESERVATION_FLOW_STEP.INSPECTION) {
if (this.isReservationInspectionPendingReview()) {
return
}
const orderNo = getReservationOrderNo(this.detail)
uni.navigateTo({
url: orderNo
? `/pkgService/room_inspection/room_inspection?orderNo=${encodeURIComponent(orderNo)}`
: '/pkgService/room_inspection/room_inspection'
})
}
} }
} }
} }
@@ -784,4 +1086,11 @@ export default {
color: #FFFFFF; color: #FFFFFF;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.25); box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.25);
} }
.action-btn--ghost {
background: rgba($brand-primary, 0.08);
color: $brand-primary;
border: 1rpx solid rgba($brand-primary, 0.18);
box-shadow: none;
}
</style> </style>

View File

@@ -245,6 +245,35 @@ import { getActiveApplicationCode, getMyApplicationList, saveActiveApplicationCo
const VIEWING_APPLICATION_PATTERN = /预约看房申请|看房时间:|意向房型:|意向区域:|viewing request|viewing time|preferred room type|preferred area/i const VIEWING_APPLICATION_PATTERN = /预约看房申请|看房时间:|意向房型:|意向区域:|viewing request|viewing time|preferred room type|preferred area/i
function isViewingApplicationRecord(item = {}) {
if (!item || typeof item !== 'object') {
return false
}
const typeText = [
item.businessType,
item.applicationType,
item.applicationTypeDesc,
item.formType,
item.bizType,
item.type,
item.typeDesc
]
.filter(value => value !== null && typeof value !== 'undefined' && String(value).trim())
.join(' ')
if (/viewing|看房|带看/i.test(typeText)) {
return true
}
if ([item.appointmentViewingTime, item.viewingTime, item.bookViewingTime].some(value => String(value || '').trim())) {
return true
}
const remark = item && item.remark ? String(item.remark) : ''
return VIEWING_APPLICATION_PATTERN.test(remark)
}
function normalizeBillList(response) { function normalizeBillList(response) {
const payload = response && response.data ? response.data : response const payload = response && response.data ? response.data : response
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
@@ -263,11 +292,9 @@ function normalizeApplicationList(list = []) {
return list return list
.filter(item => item && typeof item === 'object') .filter(item => item && typeof item === 'object')
.map((item) => { .map((item) => {
const remark = item && item.remark ? String(item.remark) : ''
const isViewingApplication = VIEWING_APPLICATION_PATTERN.test(remark)
return { return {
...item, ...item,
isViewingApplication isViewingApplication: isViewingApplicationRecord(item)
} }
}) })
} }

View File

@@ -344,6 +344,7 @@ export default {
uni.$emit('contractSignSuccess', { uni.$emit('contractSignSuccess', {
contractNo: this.contractNo contractNo: this.contractNo
}) })
uni.$emit('refresh-shell-state')
}) })
.then(() => { .then(() => {
uni.hideLoading() uni.hideLoading()
@@ -352,8 +353,8 @@ export default {
icon: 'success' icon: 'success'
}) })
setTimeout(() => { setTimeout(() => {
uni.redirectTo({ uni.navigateBack({
url: '/pkgService/my_contract/my_contract' delta: 1
}) })
}, 600) }, 600)
}) })

View File

@@ -0,0 +1,554 @@
<template>
<view class="lost-found-page tn-safe-area-inset-bottom">
<tn-nav-bar
:isBack="true"
:bottomShadow="false"
:backgroundColor="navBarBackgroundColor"
:customBackStyle="navBarBackStyle"
:fixed="true"
>
<view class="tn-text-bold tn-text-xl tn-color-black" :style="{ opacity: navTitleOpacity }">{{ t('navTitle') }}</view>
</tn-nav-bar>
<scroll-view class="page-scroll" scroll-y :show-scrollbar="false" @scroll="handleScroll">
<view class="page-content tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '220rpx' }">
<view class="hero-header tn-margin-bottom-xl">
<view class="hero-badge">
<text class="badge-dot"></text>
<text>{{ t('heroBadge') }}</text>
</view>
<view class="hero-title">{{ t('heroTitle') }}</view>
<view class="hero-desc">{{ t('heroDesc') }}</view>
</view>
<view class="form-card">
<view class="block-title">{{ t('formTitle') }}</view>
<view class="block-subtitle">{{ t('formDesc') }}</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('typeLabel') }}</view>
<picker mode="selector" :range="typeLabels" @change="handleTypeChange">
<view class="form-picker">
{{ selectedTypeLabel || t('typePlaceholder') }}
</view>
</picker>
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('itemTypeLabel') }}</view>
<input v-model="form.itemType" class="form-input" :placeholder="t('itemTypePlaceholder')" maxlength="40" />
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('itemNameLabel') }}</view>
<input v-model="form.itemName" class="form-input" :placeholder="t('itemNamePlaceholder')" maxlength="60" />
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('placeLabel') }}</view>
<input v-model="form.place" class="form-input" :placeholder="t('placePlaceholder')" maxlength="100" />
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('whenLabel') }}</view>
<view class="picker-row">
<picker mode="date" :value="form.whenDate" @change="handleDateChange">
<view class="form-picker">{{ form.whenDate || t('selectDate') }}</view>
</picker>
<picker mode="time" :value="form.whenTime" @change="handleTimeChange">
<view class="form-picker form-picker--time">{{ form.whenTime || t('selectTime') }}</view>
</picker>
</view>
<view class="field-tip">{{ t('whenTip') }}</view>
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('contractLabel') }}</view>
<input v-model="form.contract" class="form-input" :placeholder="t('contractPlaceholder')" maxlength="40" />
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('contractPhoneLabel') }}</view>
<input v-model="form.contractPhone" class="form-input" :placeholder="t('contractPhonePlaceholder')" maxlength="30" />
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('remarkLabel') }}</view>
<textarea
v-model="form.remark"
class="form-textarea"
:placeholder="t('remarkPlaceholder')"
maxlength="300"
></textarea>
<view class="field-counter">{{ (form.remark || '').length }}/300</view>
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('imagesLabel') }}</view>
<tn-image-upload
:key="uploadInstanceKey"
:fileList="form.photos"
:action="uploadAction"
:header="uploadHeaders"
:formData="uploadFormData"
:max-count="6"
:max-size="5 * 1024 * 1024"
uploadText=""
@on-success="onUploadSuccess"
@on-list-change="onPhotoListChange"
@on-remove="onRemoveImage"
@on-error="onUploadError"
@on-oversize="onUploadOversize"
@on-exceed="onUploadExceed"
>
<template #addBtn>
<view class="upload-trigger">
<text class="tn-icon-camera-fill upload-trigger__icon"></text>
<view class="upload-trigger__text">{{ t('uploadImage') }}</view>
</view>
</template>
</tn-image-upload>
<view class="field-tip">{{ t('imagesTip') }}</view>
</view>
</view>
</view>
</scroll-view>
<view class="floating-submit-bar">
<view class="floating-submit-bar__inner">
<view class="floating-submit-tip">{{ t('submitTip') }}</view>
<view class="submit-btn btn-primary" :class="{ 'submit-btn--disabled': isSubmitting }" @click="submitForm">
<text class="tn-text-bold">{{ isSubmitting ? t('submitting') : t('submitButton') }}</text>
</view>
</view>
</view>
</view>
</template>
<script>
import { API_BASE_URL } from '@/utils/request'
import { getAuthToken } from '@/utils/app-state'
import { addFilePrefix } from '@/utils/common'
import { lostAndFoundPublish } from '@/api/index.js'
import { getStoredUser } from '@/utils/flow'
function pickReadableValue(...values) {
for (let index = 0; index < values.length; index += 1) {
const text = String(values[index] || '').trim()
if (text) {
return text
}
}
return ''
}
export default {
name: 'LostFoundPublishPage',
data() {
return {
scrollTop: 0,
isSubmitting: false,
uploadInstanceKey: 0,
form: this.createDefaultForm()
}
},
computed: {
language() {
return this.$getLanguage ? this.$getLanguage() : 'zh'
},
navBarBackgroundColor() {
if (this.scrollTop < 40) return 'rgba(255, 255, 255, 0)'
return 'rgba(255, 255, 255, 0.96)'
},
navBarBackStyle() {
const isDarkBg = this.scrollTop < 40
return {
transition: 'all 0.2s ease',
background: isDarkBg ? 'rgba(0, 0, 0, 0.2)' : 'transparent',
borderRadius: '50%',
width: '64rpx',
height: '64rpx',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: '12rpx',
color: isDarkBg ? '#FFFFFF' : '#000000'
}
},
navTitleOpacity() {
return Math.min(Math.max((this.scrollTop - 40) / 50, 0), 1)
},
typeOptions() {
return [
{ value: 1, label: this.t('typeLost'), labelEn: this.t('typeLost') },
{ value: 2, label: this.t('typeFound'), labelEn: this.t('typeFound') }
]
},
typeLabels() {
return this.typeOptions.map(item => item.label)
},
selectedTypeLabel() {
const current = this.typeOptions.find(item => item.value === this.form.type)
return current ? current.label : ''
},
uploadAction() {
return `${String(API_BASE_URL || '').replace(/\/$/, '')}/file/common/upload`
},
uploadHeaders() {
const token = getAuthToken()
return {
...(token ? { Authorization: `Bearer ${token}` } : {}),
'X-Requested-Language': this.language === 'en' ? 'en' : 'zh'
}
},
uploadFormData() {
return {
ProviderType: 'WechatMiniProgram'
}
}
},
onLoad() {
this.prefillContact()
},
methods: {
t(key, params) {
return this.$tm('pkgService.lost_found_publish.lost_found_publish', key, params)
},
createDefaultForm() {
return {
type: 1,
itemType: '',
itemName: '',
place: '',
whenDate: '',
whenTime: '',
contract: '',
contractPhone: '',
remark: '',
photos: [],
imageUrls: []
}
},
prefillContact() {
const user = getStoredUser() || {}
this.form.contract = this.form.contract || pickReadableValue(user.name, user.realName, user.username)
this.form.contractPhone = this.form.contractPhone || pickReadableValue(user.phone, user.mobile, user.tel)
},
handleScroll(e) {
this.scrollTop = e.detail.scrollTop
},
handleTypeChange(event) {
const index = Number(event && event.detail ? event.detail.value : 0)
const target = this.typeOptions[index]
this.form.type = target ? target.value : 1
},
handleDateChange(event) {
this.form.whenDate = event && event.detail ? event.detail.value : ''
},
handleTimeChange(event) {
this.form.whenTime = event && event.detail ? event.detail.value : ''
},
resolveWhenValue() {
if (!this.form.whenDate) {
return null
}
const timeText = this.form.whenTime || '00:00'
return `${this.form.whenDate}T${timeText}:00`
},
resolveUploadedFileUrl(uploadResult) {
return pickReadableValue(
uploadResult && uploadResult.data && uploadResult.data.url,
uploadResult && uploadResult.data && uploadResult.data.fileUrl,
uploadResult && uploadResult.data && uploadResult.data.path,
uploadResult && uploadResult.data,
uploadResult && uploadResult.url,
uploadResult && uploadResult.fileUrl,
uploadResult && uploadResult.path,
typeof uploadResult === 'string' ? uploadResult : ''
)
},
onUploadSuccess(data, index, list) {
let parsedData = data
if (typeof data === 'string') {
try {
parsedData = JSON.parse(data)
} catch (error) {}
}
const resourceUrl = this.resolveUploadedFileUrl(parsedData)
if (!resourceUrl) {
return
}
this.form.photos = Array.isArray(list) ? list : this.form.photos
this.form.imageUrls.splice(index, 1, resourceUrl)
},
onPhotoListChange(list) {
this.form.photos = Array.isArray(list) ? list : []
},
onRemoveImage(index) {
this.form.imageUrls.splice(index, 1)
},
onUploadError() {
uni.showToast({ title: this.t('uploadFailed'), icon: 'none' })
},
onUploadOversize() {
uni.showToast({ title: this.t('uploadOversize'), icon: 'none' })
},
onUploadExceed() {
uni.showToast({ title: this.t('uploadExceed'), icon: 'none' })
},
getValidationMessage() {
if (!String(this.form.itemType || '').trim()) return this.t('validateItemType')
if (!String(this.form.itemName || '').trim()) return this.t('validateItemName')
if (!String(this.form.place || '').trim()) return this.t('validatePlace')
if (!String(this.form.contract || '').trim()) return this.t('validateContract')
if (!String(this.form.contractPhone || '').trim()) return this.t('validateContractPhone')
return ''
},
buildSubmitPayload() {
return {
type: Number(this.form.type) || 1,
itemType: String(this.form.itemType || '').trim(),
itemName: String(this.form.itemName || '').trim(),
place: String(this.form.place || '').trim(),
when: this.resolveWhenValue(),
contract: String(this.form.contract || '').trim(),
contractPhone: String(this.form.contractPhone || '').trim(),
remark: String(this.form.remark || '').trim() || null,
imageUrls: this.form.imageUrls.length ? this.form.imageUrls.slice() : null
}
},
resetForm() {
this.uploadInstanceKey += 1
this.form = this.createDefaultForm()
this.prefillContact()
},
submitForm() {
if (this.isSubmitting) {
return
}
const validationMessage = this.getValidationMessage()
if (validationMessage) {
uni.showToast({ title: validationMessage, icon: 'none' })
return
}
this.isSubmitting = true
lostAndFoundPublish(this.buildSubmitPayload(), this.language)
.then(() => {
uni.showToast({
title: this.t('submitSuccess'),
icon: 'success'
})
this.resetForm()
setTimeout(() => {
uni.navigateBack({
delta: 1
})
}, 600)
})
.catch((error) => {
uni.showToast({
title: (error && (error.message || error.msg)) || this.t('submitFailed'),
icon: 'none'
})
})
.finally(() => {
this.isSubmitting = false
})
}
}
}
</script>
<style scoped lang="scss">
.lost-found-page {
min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
}
.page-scroll {
height: 100vh;
}
.hero-header {
padding: 24rpx 8rpx 0;
}
.hero-badge {
display: inline-flex;
align-items: center;
padding: 10rpx 18rpx;
background: rgba($brand-primary, 0.08);
border-radius: 999rpx;
color: $brand-primary;
font-size: 22rpx;
font-weight: 700;
}
.badge-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: $brand-primary;
margin-right: 10rpx;
}
.hero-title {
margin-top: 22rpx;
font-size: 52rpx;
line-height: 1.2;
color: #1A2233;
font-weight: 800;
}
.hero-desc {
margin-top: 16rpx;
color: #6A7A99;
font-size: 26rpx;
line-height: 1.6;
}
.form-card {
background: #FFFFFF;
border-radius: 28rpx;
box-shadow: 0 12rpx 36rpx rgba(43, 142, 255, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.8);
padding: 30rpx;
}
.block-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.block-subtitle {
margin-top: 10rpx;
font-size: 24rpx;
color: #7C8AA5;
line-height: 1.6;
}
.form-label {
margin-bottom: 16rpx;
font-size: 28rpx;
color: #1A2233;
font-weight: 700;
}
.form-input,
.form-picker,
.form-textarea {
width: 100%;
background: #F8FAFF;
border-radius: 22rpx;
border: 1rpx solid rgba($brand-primary, 0.08);
color: #1A2233;
}
.form-input,
.form-picker {
min-height: 96rpx;
padding: 0 26rpx;
display: flex;
align-items: center;
font-size: 28rpx;
}
.form-textarea {
min-height: 180rpx;
padding: 26rpx;
font-size: 28rpx;
line-height: 1.6;
}
.picker-row {
display: flex;
gap: 16rpx;
}
.picker-row picker {
flex: 1;
}
.form-picker--time {
justify-content: center;
}
.field-tip {
margin-top: 12rpx;
font-size: 22rpx;
color: #8A97AE;
line-height: 1.6;
}
.field-counter {
margin-top: 12rpx;
text-align: right;
font-size: 22rpx;
color: #9AA6BC;
}
.upload-trigger {
width: 200rpx;
height: 200rpx;
border-radius: 22rpx;
background: linear-gradient(135deg, rgba($brand-primary, 0.08) 0%, rgba($brand-primary, 0.03) 100%);
border: 1rpx dashed rgba($brand-primary, 0.26);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-trigger__icon {
font-size: 40rpx;
color: $brand-primary;
}
.upload-trigger__text {
margin-top: 12rpx;
font-size: 24rpx;
color: $brand-primary;
font-weight: 600;
}
.floating-submit-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 24rpx 24rpx calc(24rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(248, 250, 255, 0) 0%, rgba(248, 250, 255, 0.98) 36%, rgba(248, 250, 255, 1) 100%);
}
.floating-submit-bar__inner {
background: rgba(255, 255, 255, 0.96);
border-radius: 28rpx;
padding: 22rpx;
box-shadow: 0 12rpx 36rpx rgba(43, 142, 255, 0.08);
}
.floating-submit-tip {
margin-bottom: 16rpx;
font-size: 22rpx;
color: #7C8AA5;
}
.submit-btn {
height: 96rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
.btn-primary {
background: linear-gradient(135deg, rgba($brand-primary, 0.92) 0%, $brand-primary 100%);
color: #FFFFFF;
box-shadow: 0 12rpx 28rpx rgba($brand-primary, 0.24);
}
.submit-btn--disabled {
opacity: 0.7;
}
</style>

View File

@@ -109,14 +109,51 @@
</template> </template>
<script> <script>
import { applicationFormMePage } from '@/api/index.js' import { applicationFormMePage, orderSearchedReservationFlow } from '@/api/index.js'
import { saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state' import { saveActiveApplicationCode, saveMyApplicationList } from '@/utils/app-state'
import {
attachReservationFlow,
buildReservationFlowMap,
getReservationOrderNo,
parseReservationFlowResponse
} from '@/utils/reservation-flow'
function formatPageResponse(response) { function formatPageResponse(response) {
const payload = response && response.data ? response.data : response const payload = response && response.data ? response.data : response
return payload && typeof payload === 'object' ? payload : {} return payload && typeof payload === 'object' ? payload : {}
} }
const VIEWING_APPLICATION_PATTERN = /预约看房申请|看房时间[::]|意向房型[::]|意向区域[::]|viewing request|viewing time[::]|preferred room type[::]|preferred area[::]/i
function isViewingApplicationRecord(item = {}) {
if (!item || typeof item !== 'object') {
return false
}
const typeText = [
item.businessType,
item.applicationType,
item.applicationTypeDesc,
item.formType,
item.bizType,
item.type,
item.typeDesc
]
.filter(value => value !== null && typeof value !== 'undefined' && String(value).trim())
.join(' ')
if (/viewing|看房|带看/i.test(typeText)) {
return true
}
if ([item.appointmentViewingTime, item.viewingTime, item.bookViewingTime].some(value => String(value || '').trim())) {
return true
}
const remark = item && item.remark ? String(item.remark) : ''
return VIEWING_APPLICATION_PATTERN.test(remark)
}
function normalizeApplicationList(list = []) { function normalizeApplicationList(list = []) {
if (!Array.isArray(list)) { if (!Array.isArray(list)) {
return [] return []
@@ -124,11 +161,9 @@ function normalizeApplicationList(list = []) {
return list return list
.filter(item => item && typeof item === 'object') .filter(item => item && typeof item === 'object')
.map((item) => { .map((item) => {
const remark = item && item.remark ? String(item.remark) : ''
const isViewingApplication = /预约看房申请|看房时间[::]|意向房型[::]|意向区域[::]|viewing request|viewing time[::]|preferred room type[::]|preferred area[::]/i.test(remark)
return { return {
...item, ...item,
isViewingApplication isViewingApplication: isViewingApplicationRecord(item)
} }
}) })
} }
@@ -349,7 +384,7 @@ export default {
} }
await applicationFormMePage(params, this.language) await applicationFormMePage(params, this.language)
.then((response) => { .then(async (response) => {
const pageData = formatPageResponse(response) const pageData = formatPageResponse(response)
console.log('pageData', pageData) console.log('pageData', pageData)
const nextList = normalizeApplicationList(pageData.data || []) const nextList = normalizeApplicationList(pageData.data || [])
@@ -358,12 +393,14 @@ export default {
this.total = total this.total = total
this.list = normalizeApplicationList(isReload ? nextList : this.list.concat(nextList)) this.list = normalizeApplicationList(isReload ? nextList : this.list.concat(nextList))
this.hasMore = this.list.length < total && nextList.length > 0 this.hasMore = this.list.length < total && nextList.length > 0
saveMyApplicationList(this.list)
const activeCode = this.resolvePreferredReferenceTypeNo(this.list) const activeCode = this.resolvePreferredReferenceTypeNo(this.list)
if (activeCode) { if (activeCode) {
saveActiveApplicationCode(activeCode) saveActiveApplicationCode(activeCode)
} }
await this.loadReservationFlowsForList(this.list)
saveMyApplicationList(this.list)
}) })
.catch((error) => { .catch((error) => {
if (!isReload) { if (!isReload) {
@@ -416,6 +453,41 @@ export default {
const preferredItem = sortedList.find(item => Number(item.status) !== 99) || sortedList[0] const preferredItem = sortedList.find(item => Number(item.status) !== 99) || sortedList[0]
return this.resolveReferenceTypeNo(preferredItem) return this.resolveReferenceTypeNo(preferredItem)
}, },
async loadReservationFlowsForList(list = []) {
const baseList = Array.isArray(list) ? list : []
const orderNos = [...new Set(
baseList
.filter(item => item && !item.isViewingApplication)
.map(item => getReservationOrderNo(item))
.filter(Boolean)
)]
const requestToken = Date.now()
this._reservationFlowRequestToken = requestToken
const applyFlowMap = (flowMap = {}) => {
if (this._reservationFlowRequestToken !== requestToken) {
return
}
this.list = (Array.isArray(this.list) ? this.list : []).map(item => attachReservationFlow(item, flowMap))
}
if (!orderNos.length) {
applyFlowMap({})
return
}
try {
const response = await orderSearchedReservationFlow({
orderNos
}, this.language)
const flowMap = buildReservationFlowMap(parseReservationFlowResponse(response))
applyFlowMap(flowMap)
} catch (error) {
console.log('[my_application] load reservation flow failed', error)
applyFlowMap({})
}
},
goToBillPage(event) { goToBillPage(event) {
const dataset = event && event.currentTarget ? event.currentTarget.dataset || {} : {} const dataset = event && event.currentTarget ? event.currentTarget.dataset || {} : {}
const referenceTypeNo = dataset.referenceTypeNo || dataset.referenceTypeNo === 0 const referenceTypeNo = dataset.referenceTypeNo || dataset.referenceTypeNo === 0
@@ -437,10 +509,6 @@ export default {
goToApplicationDetail(item = {}) { goToApplicationDetail(item = {}) {
const code = item.detailCode || '' const code = item.detailCode || ''
if (!code) { if (!code) {
uni.showToast({
title: this.t('missingApplicationCode'),
icon: 'none'
})
return return
} }
uni.navigateTo({ uni.navigateTo({

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,314 @@
<template>
<view class="building-selector">
<view
class="selector-header tn-flex tn-flex-row-between tn-flex-col-center"
:class="{ 'selector-header--disabled': !areaCode }"
@click="handleOpen"
>
<view class="selected-info">
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedBuilding }">
{{ selectedBuilding ? getBuildingDisplayName(selectedBuilding) : placeholderText }}
</view>
</view>
<text class="tn-icon-right" :class="areaCode ? 'tn-color-gray' : 'tn-color-disabled'"></text>
</view>
<tn-popup v-model="showPopup" mode="bottom" length="72%" :borderRadius="32" :closeBtn="true">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">{{ t('popupTitle') }}</text>
</view>
<scroll-view scroll-y class="building-list-scroll">
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
<tn-loading mode="circle"></tn-loading>
<view class="tn-margin-top-sm tn-color-gray">{{ t('loadingText') }}</view>
</view>
<view v-else-if="!buildingList.length" class="empty-state tn-padding-xl tn-text-center">
<text class="tn-color-gray">{{ t('emptyText') }}</text>
</view>
<view v-else class="building-list">
<view
v-for="item in buildingList"
:key="item.code"
class="building-item"
:class="{ 'is-active': selectedBuilding && selectedBuilding.code === item.code }"
@click="handleSelect(item)"
>
<text class="building-name">{{ getBuildingDisplayName(item) }}</text>
<text v-if="selectedBuilding && selectedBuilding.code === item.code" class="tn-icon-success-circle-fill building-check"></text>
</view>
</view>
</scroll-view>
</view>
</tn-popup>
</view>
</template>
<script>
import { buildingPagination } from '@/api/index.js'
function normalizeText(value) {
if (value === undefined || value === null) {
return ''
}
return String(value).trim()
}
function normalizeBuildingItem(item = {}) {
return {
areaCode: normalizeText(item.areaCode),
code: normalizeText(item.code),
name: normalizeText(item.name),
nameEn: normalizeText(item.nameEn),
sort: Number(item.sort || 0),
buildingSections: Array.isArray(item.buildingSections) ? item.buildingSections : []
}
}
export default {
name: 'BuildingSelector',
props: {
value: {
type: String,
default: ''
},
areaCode: {
type: String,
default: ''
},
roomTypeCode: {
type: String,
default: ''
}
},
data() {
return {
showPopup: false,
isLoading: false,
buildingList: [],
selectedBuilding: null
}
},
computed: {
language() {
return this.$getLanguage ? this.$getLanguage() : 'zh'
},
placeholderText() {
return this.areaCode ? this.t('selectBuilding') : this.t('selectAreaFirst')
}
},
watch: {
value() {
this.syncSelection()
},
language() {
this.loadData(true)
},
areaCode() {
this.handleAreaChange()
},
roomTypeCode(newVal, oldVal) {
if (newVal !== oldVal) {
this.handleRoomTypeChange()
}
}
},
mounted() {
this.loadData()
},
methods: {
t(key, params) {
return this.$tm('pkgService.submit_request.components.BuildingSelector', key, params)
},
handleOpen() {
if (!this.areaCode) {
uni.showToast({
title: this.t('selectAreaFirst'),
icon: 'none'
})
return
}
this.showPopup = true
this.loadData()
},
handleAreaChange() {
this.buildingList = []
this.selectedBuilding = null
if (this.value) {
this.$emit('input', '')
this.$emit('change', { building: null })
}
if (!this.areaCode) {
return
}
this.loadData(true)
},
handleRoomTypeChange() {
this.buildingList = []
this.selectedBuilding = null
if (this.value) {
this.$emit('input', '')
this.$emit('change', { building: null })
}
if (!this.areaCode) {
return
}
this.loadData(true)
},
loadData(force = false) {
if (!this.areaCode) {
return Promise.resolve()
}
if (!force && this.buildingList.length) {
this.syncSelection()
return Promise.resolve()
}
this.isLoading = true
return buildingPagination({
AreaCode: this.areaCode,
PageIndex: 1,
PageSize: 100
}, this.language)
.then((res) => {
const payload = res && res.data ? res.data : res
const rawList = Array.isArray(payload && payload.data)
? payload.data
: (Array.isArray(payload) ? payload : [])
this.buildingList = rawList
.map(item => normalizeBuildingItem(item))
.filter(item => item.code)
.sort((a, b) => a.sort - b.sort || a.code.localeCompare(b.code))
this.syncSelection()
})
.catch((error) => {
console.error('load building list error', error)
this.buildingList = []
this.selectedBuilding = null
uni.showToast({
title: this.t('loadFailed'),
icon: 'none'
})
})
.finally(() => {
this.isLoading = false
})
},
syncSelection() {
if (!this.value || !this.buildingList.length) {
this.selectedBuilding = null
return
}
this.selectedBuilding = this.buildingList.find(item => item.code === this.value) || null
},
handleSelect(item) {
this.selectedBuilding = item
this.$emit('input', item.code)
this.$emit('change', { building: item })
this.showPopup = false
},
getBuildingDisplayName(item = {}) {
if (this.language === 'en') {
return item.nameEn || item.name || item.code || ''
}
return item.name || item.nameEn || item.code || ''
}
}
}
</script>
<style scoped lang="scss">
.building-selector {
width: 100%;
}
.selector-header {
background: #FFFFFF;
border-radius: 20rpx;
padding: 26rpx 32rpx;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
min-height: 100rpx;
}
.selector-header--disabled {
opacity: 0.72;
}
.selected-info {
flex: 1;
overflow: hidden;
}
.selected-name {
font-size: 28rpx;
color: #1A2233;
font-weight: 600;
&.is-empty {
color: #A0B0C0;
font-weight: normal;
}
}
.popup-content {
display: flex;
flex-direction: column;
height: 100%;
background: #F8FAFF;
}
.popup-header {
padding: 40rpx 30rpx 20rpx;
text-align: center;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
background: #FFFFFF;
}
.popup-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.building-list-scroll {
flex: 1;
height: 0;
padding: 20rpx 30rpx 60rpx;
}
.building-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.building-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #FFFFFF;
border-radius: 20rpx;
padding: 28rpx 30rpx;
border: 1rpx solid rgba($brand-primary, 0.12);
transition: all 0.2s ease;
&.is-active {
border-color: rgba($brand-primary, 0.5);
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.08);
}
}
.building-name {
font-size: 28rpx;
color: #1A2233;
}
.building-check {
color: $brand-primary;
font-size: 30rpx;
}
</style>

View File

@@ -0,0 +1,308 @@
<template>
<view class="floor-selector">
<view
class="selector-header tn-flex tn-flex-row-between tn-flex-col-center"
@click="handleOpen"
>
<view class="selected-info">
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedFloor }">
{{ selectedFloor ? getFloorDisplayName(selectedFloor) : placeholderText }}
</view>
</view>
<text class="tn-icon-right tn-color-gray"></text>
</view>
<tn-popup v-model="showPopup" mode="bottom" length="72%" :borderRadius="32" :closeBtn="true">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">{{ t('popupTitle') }}</text>
</view>
<scroll-view scroll-y class="floor-list-scroll">
<view v-if="isLoading" class="loading-state tn-padding-xl tn-text-center">
<tn-loading mode="circle"></tn-loading>
<view class="tn-margin-top-sm tn-color-gray">{{ t('loadingText') }}</view>
</view>
<view v-else-if="!floorList.length" class="empty-state tn-padding-xl tn-text-center">
<text class="tn-color-gray">{{ t('emptyText') }}</text>
</view>
<view v-else class="floor-list">
<view
class="floor-item floor-item--clear"
:class="{ 'is-active': !selectedFloor }"
@click="handleClear"
>
<text class="floor-name">{{ t('clearSelection') }}</text>
<text v-if="!selectedFloor" class="tn-icon-success-circle-fill floor-check"></text>
</view>
<view
v-for="item in floorList"
:key="item.value"
class="floor-item"
:class="{ 'is-active': selectedFloor && selectedFloor.value === item.value }"
@click="handleSelect(item)"
>
<text class="floor-name">{{ getFloorDisplayName(item) }}</text>
<text v-if="selectedFloor && selectedFloor.value === item.value" class="tn-icon-success-circle-fill floor-check"></text>
</view>
</view>
</scroll-view>
</view>
</tn-popup>
</view>
</template>
<script>
import { getEnums } from '@/api/index.js'
function pickFirstDefined(...values) {
const matched = values.find(value => value !== undefined && value !== null && value !== '')
return matched === undefined ? '' : matched
}
function normalizeFloorValue(value) {
if (value === undefined || value === null) {
return ''
}
return String(value).trim()
}
function resolveFloorList(response) {
const payload = response && response.data !== undefined ? response.data : response
if (Array.isArray(payload)) {
const floorEnum = payload.find(item => item && item.code === 'FloorLevelEnum')
if (floorEnum && Array.isArray(floorEnum.data)) {
return floorEnum.data
}
return payload
}
if (payload && Array.isArray(payload.data)) {
return payload.data
}
if (payload && payload.data && Array.isArray(payload.data.data)) {
return payload.data.data
}
if (payload && Array.isArray(payload.items)) {
return payload.items
}
if (payload && Array.isArray(payload.records)) {
return payload.records
}
return []
}
function normalizeFloorItem(item = {}) {
const code = normalizeFloorValue(pickFirstDefined(item.key, item.code, item.value))
const name = normalizeFloorValue(pickFirstDefined(item.cname, item.nameZh, item.labelZh, item.name, item.value))
const nameEn = normalizeFloorValue(pickFirstDefined(item.ename, item.nameEn, item.labelEn, item.descriptionEn, ''))
return {
value: code || name,
code,
name,
nameEn,
sort: Number(pickFirstDefined(item.sort, item.key, 0))
}
}
export default {
name: 'FloorSelector',
props: {
value: {
type: [String, Number],
default: ''
}
},
data() {
return {
showPopup: false,
isLoading: false,
floorList: [],
selectedFloor: null
}
},
computed: {
language() {
return this.$getLanguage ? this.$getLanguage() : 'zh'
},
placeholderText() {
return this.t('selectFloor')
}
},
watch: {
value() {
this.syncSelection()
},
language() {
this.loadData(true)
}
},
mounted() {
this.loadData()
},
methods: {
t(key, params) {
return this.$tm('pkgService.submit_request.components.FloorSelector', key, params)
},
handleOpen() {
this.showPopup = true
this.loadData()
},
loadData(force = false) {
if (!force && this.floorList.length) {
this.syncSelection()
return Promise.resolve()
}
this.isLoading = true
return getEnums({}, this.language)
.then((res) => {
const rawList = resolveFloorList(res)
this.floorList = rawList
.map(item => normalizeFloorItem(item))
.filter(item => item.value)
.sort((a, b) => a.sort - b.sort || a.value.localeCompare(b.value))
this.syncSelection()
})
.catch((error) => {
console.error('load floor list error', error)
this.floorList = []
this.selectedFloor = null
uni.showToast({
title: this.t('loadFailed'),
icon: 'none'
})
})
.finally(() => {
this.isLoading = false
})
},
syncSelection() {
const currentValue = normalizeFloorValue(this.value)
if (!currentValue || !this.floorList.length) {
this.selectedFloor = null
return
}
this.selectedFloor = this.floorList.find(item => item.value === currentValue) || null
},
handleSelect(item) {
this.selectedFloor = item
this.$emit('input', item.value)
this.$emit('change', { floor: item })
this.showPopup = false
},
handleClear() {
this.selectedFloor = null
this.$emit('input', '')
this.$emit('change', { floor: null })
this.showPopup = false
},
getFloorDisplayName(item = {}) {
if (this.language === 'en') {
return item.nameEn || item.name || item.code || ''
}
return item.name || item.nameEn || item.code || ''
}
}
}
</script>
<style scoped lang="scss">
.floor-selector {
width: 100%;
}
.selector-header {
background: #FFFFFF;
border-radius: 20rpx;
padding: 26rpx 32rpx;
border: 1rpx solid rgba($brand-primary, 0.15);
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
min-height: 100rpx;
}
.selected-info {
flex: 1;
overflow: hidden;
}
.selected-name {
font-size: 28rpx;
color: #1A2233;
font-weight: 600;
&.is-empty {
color: #A0B0C0;
font-weight: normal;
}
}
.popup-content {
display: flex;
flex-direction: column;
height: 100%;
background: #F8FAFF;
}
.popup-header {
padding: 40rpx 30rpx 20rpx;
text-align: center;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
background: #FFFFFF;
}
.popup-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
}
.floor-list-scroll {
flex: 1;
height: 0;
padding: 20rpx 30rpx 60rpx;
}
.floor-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.floor-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #FFFFFF;
border-radius: 20rpx;
padding: 28rpx 30rpx;
border: 1rpx solid rgba($brand-primary, 0.12);
transition: all 0.2s ease;
&.is-active {
border-color: rgba($brand-primary, 0.5);
box-shadow: 0 8rpx 20rpx rgba($brand-primary, 0.08);
}
}
.floor-item--clear {
border-style: dashed;
}
.floor-name {
font-size: 28rpx;
color: #1A2233;
}
.floor-check {
color: $brand-primary;
font-size: 30rpx;
}
</style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<view class="room-type-selector"> <view class="room-type-selector">
<view class="selector-header tn-flex tn-flex-row-between tn-flex-col-center" @click="showPopup = true"> <view class="selector-header tn-flex tn-flex-row-between tn-flex-col-center" @click="openSelector">
<view class="selected-info"> <view class="selected-info">
<view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }"> <view class="selected-name tn-text-ellipsis" :class="{ 'is-empty': !selectedRoomType }">
{{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : t('selectRoomType') }} {{ selectedRoomType ? getRoomTypeDisplayName(selectedRoomType) : t('selectRoomType') }}
@@ -24,11 +24,11 @@
<view class="tn-margin-top-sm tn-color-gray">{{ t('loadingRoomTypes') }}</view> <view class="tn-margin-top-sm tn-color-gray">{{ t('loadingRoomTypes') }}</view>
</view> </view>
<view v-else-if="!areaList.length" class="empty-state tn-padding-xl tn-text-center"> <view v-else-if="!displayAreaList.length" class="empty-state tn-padding-xl tn-text-center">
<text class="tn-color-gray">{{ t('emptyRoomTypes') }}</text> <text class="tn-color-gray">{{ t('emptyRoomTypes') }}</text>
</view> </view>
<view v-else class="area-group" v-for="(area, index) in areaList" :key="area.code"> <view v-else class="area-group" v-for="(area, index) in displayAreaList" :key="area.code">
<view class="area-title tn-flex tn-flex-row-between tn-flex-col-center" @click="toggleArea(index)"> <view class="area-title tn-flex tn-flex-row-between tn-flex-col-center" @click="toggleArea(index)">
<view class="tn-flex tn-flex-col-center"> <view class="tn-flex tn-flex-col-center">
<text class="area-dot"></text> <text class="area-dot"></text>
@@ -69,6 +69,10 @@ export default {
initialAreaCode: { initialAreaCode: {
type: String, type: String,
default: '' default: ''
},
buildingCode: {
type: String,
default: ''
} }
}, },
data() { data() {
@@ -84,6 +88,19 @@ export default {
computed: { computed: {
language() { language() {
return this.$getLanguage ? this.$getLanguage() : 'zh' return this.$getLanguage ? this.$getLanguage() : 'zh'
},
displayAreaList() {
if (!this.buildingCode) {
return this.areaList
}
return this.areaList
.map(area => ({
...area,
roomTypes: Array.isArray(area.roomTypes)
? area.roomTypes.filter(roomType => !roomType.buildingCode || roomType.buildingCode === this.buildingCode)
: []
}))
.filter(area => area.roomTypes && area.roomTypes.length > 0)
} }
}, },
watch: { watch: {
@@ -92,6 +109,9 @@ export default {
}, },
language() { language() {
this.loadData(true) this.loadData(true)
},
buildingCode() {
this.syncSelection()
} }
}, },
mounted() { mounted() {
@@ -101,6 +121,10 @@ export default {
t(key) { t(key) {
return this.$translate(this.i18n, key) return this.$translate(this.i18n, key)
}, },
openSelector() {
this.showPopup = true
this.loadData()
},
loadData(force = false) { loadData(force = false) {
if (!force && this.areaList.length > 0) return if (!force && this.areaList.length > 0) return
@@ -131,14 +155,16 @@ export default {
}) })
}, },
syncSelection() { syncSelection() {
if (!this.areaList.length) return if (!this.displayAreaList.length) return
let foundArea = null let foundArea = null
let foundRoom = null let foundRoom = null
const previousAreaCode = this.selectedArea && this.selectedArea.code ? this.selectedArea.code : ''
const previousRoomCode = this.selectedRoomType && this.selectedRoomType.code ? this.selectedRoomType.code : ''
// Try to find by room type code // Try to find by room type code
if (this.value) { if (this.value) {
for (const area of this.areaList) { for (const area of this.displayAreaList) {
const room = area.roomTypes.find(r => r.code === this.value) const room = area.roomTypes.find(r => r.code === this.value)
if (room) { if (room) {
foundArea = area foundArea = area
@@ -150,7 +176,7 @@ export default {
// If not found by room, try to find by initial area code // If not found by room, try to find by initial area code
if (!foundRoom && this.initialAreaCode) { if (!foundRoom && this.initialAreaCode) {
foundArea = this.areaList.find(a => a.code === this.initialAreaCode) foundArea = this.displayAreaList.find(a => a.code === this.initialAreaCode)
} }
if (foundArea) { if (foundArea) {
@@ -159,11 +185,17 @@ export default {
this.selectedRoomType = foundRoom this.selectedRoomType = foundRoom
} }
// If we found the room, or we just want to initialize the area but no room yet if (foundRoom) {
if (foundRoom && this.value !== foundRoom.code) { if (this.value !== foundRoom.code) {
this.$emit('input', foundRoom.code) this.$emit('input', foundRoom.code)
this.$emit('change', { area: foundArea, roomType: foundRoom }) }
if (previousAreaCode !== foundArea.code || previousRoomCode !== foundRoom.code) {
this.$emit('change', { area: foundArea, roomType: foundRoom })
}
} }
} else {
this.selectedArea = null
this.selectedRoomType = null
} }
}, },
handleSelect(area, roomType) { handleSelect(area, roomType) {

View File

@@ -12,7 +12,7 @@
</tn-nav-bar> </tn-nav-bar>
<scroll-view class="page-scroll" scroll-y @scroll="handleScroll" :show-scrollbar="false"> <scroll-view class="page-scroll" scroll-y @scroll="handleScroll" :show-scrollbar="false">
<view class="page-content tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: '60rpx' }"> <view class="page-content tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px', paddingBottom: pageBottomPadding }">
<view class="hero-header tn-margin-bottom-xl"> <view class="hero-header tn-margin-bottom-xl">
<view class="hero-badge"> <view class="hero-badge">
<text class="badge-dot"></text> <text class="badge-dot"></text>
@@ -20,13 +20,6 @@
</view> </view>
<view class="hero-title">{{ pageTitle }}</view> <view class="hero-title">{{ pageTitle }}</view>
<view class="hero-desc">{{ pageDesc }}</view> <view class="hero-desc">{{ pageDesc }}</view>
<view v-if="sourceSummaryList.length" class="tn-margin-top-lg">
<view v-for="item in sourceSummaryList" :key="item.label" class="source-row tn-flex tn-flex-row-between">
<text class="source-row__label">{{ item.label }}</text>
<text class="source-row__value">{{ item.value }}</text>
</view>
</view>
</view> </view>
<view class="form-card tn-margin-top-xl"> <view class="form-card tn-margin-top-xl">
@@ -42,6 +35,14 @@
/> />
</view> </view>
<view v-if="showFloorSelector" class="form-item tn-margin-top">
<view class="form-label">{{ floorLabel }}</view>
<floor-selector
v-model="form.intendedFloorLevel"
@change="handleFloorChange"
/>
</view>
<view v-if="showPreferenceSelectors" class="form-item tn-margin-top"> <view v-if="showPreferenceSelectors" class="form-item tn-margin-top">
<view class="form-label">{{ t('hobbyLabel') }}</view> <view class="form-label">{{ t('hobbyLabel') }}</view>
<tag-selector <tag-selector
@@ -79,13 +80,22 @@
<textarea class="form-textarea" :placeholder="remarkPlaceholder" v-model="form.remark"></textarea> <textarea class="form-textarea" :placeholder="remarkPlaceholder" v-model="form.remark"></textarea>
</view> </view>
<view class="submit-btn btn-primary tn-margin-top-xl" :class="{ 'submit-btn--disabled': isSubmitting }" @click="submitRequest"> <view v-if="!showFloatingSubmitBtn" class="submit-btn btn-primary tn-margin-top-xl" :class="{ 'submit-btn--disabled': isSubmitting }" @click="submitRequest">
<text class="tn-text-bold">{{ isSubmitting ? t('submitting') : primaryActionText }}</text> <text class="tn-text-bold">{{ isSubmitting ? t('submitting') : primaryActionText }}</text>
</view> </view>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
<view v-if="showFloatingSubmitBtn" class="floating-submit-bar">
<view class="floating-submit-bar__inner">
<view class="submit-btn btn-primary" :class="{ 'submit-btn--disabled': isSubmitting }" @click="submitRequest">
<text class="tn-text-bold">{{ isSubmitting ? t('submitting') : primaryActionText }}</text>
</view>
</view>
</view>
</view> </view>
</template> </template>
@@ -98,6 +108,7 @@ import { getStoredUser } from '../../utils/flow'
import TagSelector from './components/TagSelector.vue' import TagSelector from './components/TagSelector.vue'
import DateRangeSelector from './components/DateRangeSelector.vue' import DateRangeSelector from './components/DateRangeSelector.vue'
import RoomTypeSelector from './components/RoomTypeSelector.vue' import RoomTypeSelector from './components/RoomTypeSelector.vue'
import FloorSelector from './components/FloorSelector.vue'
function createDefaultForm() { function createDefaultForm() {
const now = new Date() const now = new Date()
@@ -107,6 +118,7 @@ function createDefaultForm() {
return { return {
intendedAreaCode: '', intendedAreaCode: '',
intendedRoomTypeCode: '', intendedRoomTypeCode: '',
intendedFloorLevel: '',
roomSlotHeight: 1, roomSlotHeight: 1,
hobbiesAndInterestsTagKeys: [], hobbiesAndInterestsTagKeys: [],
lifestyleHabitsTagKeys: [], lifestyleHabitsTagKeys: [],
@@ -564,7 +576,8 @@ export default {
components: { components: {
TagSelector, TagSelector,
DateRangeSelector, DateRangeSelector,
RoomTypeSelector RoomTypeSelector,
FloorSelector
}, },
data() { data() {
return { return {
@@ -743,12 +756,27 @@ export default {
roomTypeLabel() { roomTypeLabel() {
return this.getBusinessText('roomTypeLabel') || this.t('applyRoomType') return this.getBusinessText('roomTypeLabel') || this.t('applyRoomType')
}, },
pageBottomPadding() {
return this.showFloatingSubmitBtn ? '220rpx' : '60rpx'
},
showFloatingSubmitBtn() {
return this.isRoomTypeApply
},
floorLabel() {
return this.getBusinessText('floorLabel') || this.t('floorLabel')
},
showRoomTypeSelector() { showRoomTypeSelector() {
if (this.isRoomTypeApply) { if (this.isRoomTypeApply) {
return true return true
} }
return this.businessConfig ? this.businessConfig.showRoomTypeSelector !== false : true return this.businessConfig ? this.businessConfig.showRoomTypeSelector !== false : true
}, },
showFloorSelector() {
if (this.isRoomTypeApply) {
return true
}
return this.businessConfig ? this.businessConfig.showFloorSelector !== false : true
},
showPreferenceSelectors() { showPreferenceSelectors() {
if (this.isRoomTypeApply) { if (this.isRoomTypeApply) {
return true return true
@@ -775,41 +803,6 @@ export default {
}, },
dateRangeEndPlaceholder() { dateRangeEndPlaceholder() {
return this.getBusinessText('dateRangeEndPlaceholder') || this.t('selectCheckoutDate') return this.getBusinessText('dateRangeEndPlaceholder') || this.t('selectCheckoutDate')
},
sourceSummaryList() {
const payload = this.sourcePayload || {}
if (!Object.keys(payload).length) {
return []
}
const roomTypeName = this.language === 'en'
? (payload.roomTypeNameEn || payload.roomTypeName || '')
: (payload.roomTypeName || '')
const roomLabel = this.language === 'en'
? (payload.roomLabelEn || payload.currentRoomLabelEn || payload.roomLabel || payload.currentRoomLabel || '')
: (payload.roomLabel || payload.currentRoomLabel || '')
const areaName = this.language === 'en'
? (payload.areaLabelEn || payload.areaNameEn || payload.areaLabel || payload.areaName || '')
: (payload.areaLabel || payload.areaName || '')
const list = []
if (roomTypeName) {
list.push({ label: this.t('applyRoomType'), value: roomTypeName })
}
if (roomLabel) {
list.push({ label: this.t('currentRoom'), value: roomLabel })
}
if (areaName) {
list.push({ label: this.t('areaLabel'), value: areaName })
}
if (payload.price) {
list.push({ label: this.t('referencePrice'), value: payload.price })
}
if (payload.payMethod) {
list.push({ label: this.t('paymentMethod'), value: localizePayMethodText(payload.payMethod, this.language) })
}
if (this.pageTitle) {
list.push({ label: this.t('applicationType'), value: this.pageTitle })
}
return list
} }
}, },
watch: { watch: {
@@ -943,6 +936,7 @@ export default {
...defaultForm, ...defaultForm,
intendedAreaCode: this.sourcePayload.areaCode || '', intendedAreaCode: this.sourcePayload.areaCode || '',
intendedRoomTypeCode: this.sourcePayload.roomTypeCode || '', intendedRoomTypeCode: this.sourcePayload.roomTypeCode || '',
intendedFloorLevel: this.sourcePayload.intendedFloorLevel || this.sourcePayload.IntendedFloorLevel || '',
remark: '', remark: '',
start: this.sourcePayload.start || defaultForm.start, start: this.sourcePayload.start || defaultForm.start,
end: this.sourcePayload.end || '' end: this.sourcePayload.end || ''
@@ -967,6 +961,10 @@ export default {
this.sourcePayload.roomTypeCode = roomType.code this.sourcePayload.roomTypeCode = roomType.code
this.sourcePayload.roomTypeName = roomType.name this.sourcePayload.roomTypeName = roomType.name
this.sourcePayload.roomTypeNameEn = roomType.nameEn || '' this.sourcePayload.roomTypeNameEn = roomType.nameEn || ''
this.sourcePayload.selectedRoomTypeBuildingCode = roomType.buildingCode || ''
this.sourcePayload.selectedRoomTypeBuildingSectionCode = roomType.buildingSectionCode || ''
this.sourcePayload.selectedRoomTypeBuildingName = roomType.buildingName || ''
this.sourcePayload.selectedRoomTypeBuildingSectionName = roomType.buildingSectionName || ''
if (area) { if (area) {
this.sourcePayload.areaCode = area.code this.sourcePayload.areaCode = area.code
@@ -979,6 +977,15 @@ export default {
} }
} }
}, },
handleFloorChange({ floor }) {
if (!this.sourcePayload) {
this.sourcePayload = {}
}
this.sourcePayload.intendedFloorLevel = floor && floor.value ? floor.value : ''
this.sourcePayload.intendedFloorName = floor
? (this.language === 'en' ? (floor.nameEn || floor.name || '') : (floor.name || floor.nameEn || ''))
: ''
},
loadDictOptions() { loadDictOptions() {
this.dictLoading = true this.dictLoading = true
return Promise.all([ return Promise.all([
@@ -1052,6 +1059,7 @@ export default {
return { return {
intendedAreaCode: this.form.intendedAreaCode || null, intendedAreaCode: this.form.intendedAreaCode || null,
intendedRoomTypeCode: this.form.intendedRoomTypeCode || null, intendedRoomTypeCode: this.form.intendedRoomTypeCode || null,
IntendedFloorLevel: this.form.intendedFloorLevel || null,
roomSlotHeight: Number(this.form.roomSlotHeight), roomSlotHeight: Number(this.form.roomSlotHeight),
hobbiesAndInterestsTagKeys: this.showPreferenceSelectors && this.form.hobbiesAndInterestsTagKeys.length ? this.form.hobbiesAndInterestsTagKeys : null, hobbiesAndInterestsTagKeys: this.showPreferenceSelectors && this.form.hobbiesAndInterestsTagKeys.length ? this.form.hobbiesAndInterestsTagKeys : null,
lifestyleHabitsTagKeys: this.showPreferenceSelectors && this.form.lifestyleHabitsTagKeys.length ? this.form.lifestyleHabitsTagKeys : null, lifestyleHabitsTagKeys: this.showPreferenceSelectors && this.form.lifestyleHabitsTagKeys.length ? this.form.lifestyleHabitsTagKeys : null,
@@ -1276,6 +1284,22 @@ export default {
pointer-events: none; pointer-events: none;
} }
.floating-submit-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
padding: 20rpx 24rpx calc(20rpx + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(248, 250, 255, 0) 0%, rgba(248, 250, 255, 0.92) 24%, #F8FAFF 100%);
backdrop-filter: blur(16rpx);
-webkit-backdrop-filter: blur(16rpx);
}
.floating-submit-bar__inner {
max-width: 100%;
}
.source-row { .source-row {
padding: 24rpx 0; padding: 24rpx 0;
border-bottom: 1px dashed rgba($brand-primary, 0.15); border-bottom: 1px dashed rgba($brand-primary, 0.15);
@@ -1286,6 +1310,10 @@ export default {
padding-bottom: 8rpx; padding-bottom: 8rpx;
} }
.source-row--interactive:active {
opacity: 0.8;
}
.source-row__label { .source-row__label {
color: #6A7A99; color: #6A7A99;
font-size: 26rpx; font-size: 26rpx;
@@ -1301,4 +1329,5 @@ export default {
margin-left: 24rpx; margin-left: 24rpx;
word-break: break-word; word-break: break-word;
} }
</style> </style>

View File

@@ -0,0 +1,211 @@
<template>
<view class="ticket-center-page tn-safe-area-inset-bottom">
<tn-nav-bar :isBack="true" :bottomShadow="false" backgroundColor="transparent">
<view class="tn-text-bold tn-text-xl tn-color-black">{{ t('navTitle') }}</view>
</tn-nav-bar>
<view class="page-content tn-padding" :style="{ paddingTop: vuex_custom_bar_height + 'px' }">
<view class="hero-card tn-margin-bottom-lg">
<view class="hero-badge">
<text class="badge-dot"></text>
<text>{{ t('heroBadge') }}</text>
</view>
<view class="hero-title tn-margin-top">{{ t('heroTitle') }}</view>
<view class="hero-desc tn-margin-top-sm">{{ t('heroDesc') }}</view>
</view>
<view class="service-grid">
<view
v-for="item in ticketServices"
:key="item.serviceType"
class="service-card"
@click="openTicketWorkbench(item)"
>
<view class="service-card__icon"><text :class="item.icon"></text></view>
<view class="service-card__title tn-margin-top">{{ getItemText(item, 'title', 'titleEn') }}</view>
<view class="service-card__desc tn-margin-top-xs">{{ getItemText(item, 'desc', 'descEn') }}</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'TicketCenterPage',
i18n: {
zh: {
navTitle: '工单中心',
heroBadge: 'SERVICE',
heroTitle: '住中工单服务',
heroDesc: '报修、续住、退宿、调宿、送水、清洁和意见建议都统一从这里进入,提交后可继续查看流转记录。'
},
en: {
navTitle: 'Work Orders',
heroBadge: 'SERVICE',
heroTitle: 'In-stay Ticket Services',
heroDesc: 'Repair, renewal, checkout, room change, water delivery, cleaning, and suggestions are all handled here.'
}
},
data() {
return {
ticketServices: [
{
serviceType: 'TicketRepairApply',
icon: 'tn-icon-set-fill',
title: '报修申请',
titleEn: 'Repair Request',
desc: '设备故障、设施损坏等问题报修',
descEn: 'Report device or facility issues'
},
{
serviceType: 'TicketRenewalApply',
icon: 'tn-icon-calendar-fill',
title: '续住申请',
titleEn: 'Renewal',
desc: '提交续住时间与诉求',
descEn: 'Submit renewal details'
},
{
serviceType: 'TicketCheckOutApply',
icon: 'tn-icon-home-in-fill',
title: '退宿申请',
titleEn: 'Checkout',
desc: '提交退宿时间与原因',
descEn: 'Submit checkout request'
},
{
serviceType: 'TicketChangeApply',
icon: 'tn-icon-home-smile-fill',
title: '调宿申请',
titleEn: 'Room Change',
desc: '选择意向房型并提交调宿需求',
descEn: 'Submit room change request'
},
{
serviceType: 'TicketWaterDeliveryApply',
icon: 'tn-icon-shop-fill',
title: '送水申请',
titleEn: 'Water Delivery',
desc: '填写送水桶数和送达说明',
descEn: 'Request water delivery'
},
{
serviceType: 'TicketCleaningApply',
icon: 'tn-icon-clean',
title: '清洁服务',
titleEn: 'Cleaning Service',
desc: '提交清洁需求或预约时间',
descEn: 'Submit cleaning request'
},
{
serviceType: 'TicketSuggestionApply',
icon: 'tn-icon-message-fill',
title: '意见建议',
titleEn: 'Suggestion',
desc: '反馈建议、投诉或其他诉求',
descEn: 'Feedback, suggestions, or complaints'
}
]
}
},
methods: {
t(key, params) {
return this.$translate(this.i18n, key, params)
},
getItemText(item, fieldZh, fieldEn) {
return this.$getLanguage && this.$getLanguage() === 'en'
? (item[fieldEn] || item[fieldZh] || '')
: (item[fieldZh] || item[fieldEn] || '')
},
openTicketWorkbench(item) {
uni.navigateTo({
url: `/pkgService/ticket_workbench/ticket_workbench?serviceType=${encodeURIComponent(item.serviceType)}`
})
}
}
}
</script>
<style scoped lang="scss">
.ticket-center-page {
min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
}
.hero-card {
padding: 32rpx;
background: #FFFFFF;
border-radius: 30rpx;
box-shadow: 0 16rpx 40rpx rgba(43, 142, 255, 0.08);
}
.hero-badge {
display: inline-flex;
align-items: center;
padding: 10rpx 18rpx;
border-radius: 999rpx;
background: rgba($brand-primary, 0.08);
color: $brand-primary;
font-size: 22rpx;
font-weight: 700;
}
.badge-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: $brand-primary;
margin-right: 10rpx;
}
.hero-title {
font-size: 44rpx;
color: #1A2233;
font-weight: 800;
}
.hero-desc {
font-size: 26rpx;
color: #6A7A99;
line-height: 1.7;
}
.service-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20rpx;
padding-bottom: 40rpx;
}
.service-card {
padding: 28rpx;
background: #FFFFFF;
border-radius: 28rpx;
box-shadow: 0 12rpx 32rpx rgba(43, 142, 255, 0.06);
}
.service-card__icon {
width: 88rpx;
height: 88rpx;
border-radius: 24rpx;
background: linear-gradient(135deg, rgba($brand-primary, 0.14) 0%, rgba($brand-primary, 0.05) 100%);
color: $brand-primary;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
}
.service-card__title {
font-size: 30rpx;
color: #1A2233;
font-weight: 700;
}
.service-card__desc {
font-size: 22rpx;
color: #7C8AA5;
line-height: 1.6;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import Vue from 'vue'
import Vuex from 'vuex' import Vuex from 'vuex'
import { getEnums, getMyEffectiveContracts, getMyPendingSignContracts, orderSearchedMePage } from '@/api/index.js' import { getEnums, getMyEffectiveContracts, getMyPendingSignContracts, orderSearchedMePage } from '@/api/index.js'
import { normalizeBookingList, parseOrderListResponse, pickCurrentBooking, pickCurrentStayOrder } from '@/utils/booking-helper' import { normalizeBookingList, parseOrderListResponse, pickCurrentBooking, pickCurrentStayOrder } from '@/utils/booking-helper'
import { getBuiltInEnums } from '@/utils/enum-store'
Vue.use(Vuex) Vue.use(Vuex)
@@ -62,6 +63,21 @@ function logRequestError(name, error) {
return [] return []
} }
function resolveOrderNo(order = {}) {
const candidates = [order.orderNo, order.no, order.code, order.OrderNo]
for (let index = 0; index < candidates.length; index += 1) {
const value = candidates[index]
if (value === undefined || value === null) {
continue
}
const text = String(value).trim()
if (text) {
return text
}
}
return ''
}
let lastFetchStatusTime = 0 let lastFetchStatusTime = 0
let fetchUserGlobalStatusPromise = null let fetchUserGlobalStatusPromise = null
let globalEnumsPromise = null let globalEnumsPromise = null
@@ -89,7 +105,7 @@ const store = new Vuex.Store({
vuex_effective_contracts: [], vuex_effective_contracts: [],
vuex_valid_orders: [], vuex_valid_orders: [],
vuex_current_order: lifeData.vuex_current_order ? lifeData.vuex_current_order : null, vuex_current_order: lifeData.vuex_current_order ? lifeData.vuex_current_order : null,
vuex_enums: {} vuex_enums: getBuiltInEnums(lifeData.vuex_locale || 'zh-Hans')
}, },
mutations: { mutations: {
$tStore(state, payload) { $tStore(state, payload) {
@@ -148,14 +164,25 @@ const store = new Vuex.Store({
lastFetchStatusTime = now lastFetchStatusTime = now
fetchUserGlobalStatusPromise = Promise.all([ fetchUserGlobalStatusPromise = Promise.all([
getMyPendingSignContracts({ UserCode: userCode }, language).catch((error) => logRequestError('getMyPendingSignContracts', error)),
getMyEffectiveContracts({ UserCode: userCode }, language).catch((error) => logRequestError('getMyEffectiveContracts', error)),
orderSearchedMePage({ PageIndex: 1, PageSize: 100, IsExport: false }, language).catch((error) => logRequestError('orderSearchedMePage', error)) orderSearchedMePage({ PageIndex: 1, PageSize: 100, IsExport: false }, language).catch((error) => logRequestError('orderSearchedMePage', error))
]).then(([pendingContractsResponse, effectiveContractsResponse, orderResponse]) => { ]).then(([orderResponse]) => {
const pendingContracts = parseContractList(pendingContractsResponse)
const effectiveContracts = parseContractList(effectiveContractsResponse)
const validOrders = normalizeBookingList(parseOrderListResponse(orderResponse), language) const validOrders = normalizeBookingList(parseOrderListResponse(orderResponse), language)
const currentOrder = pickCurrentStayOrder(validOrders) const currentOrder = pickCurrentStayOrder(validOrders)
const currentOrderNo = resolveOrderNo(currentOrder)
return Promise.all([
Promise.resolve(validOrders),
Promise.resolve(currentOrder),
currentOrderNo
? getMyPendingSignContracts({ OrderNo: currentOrderNo, ProviderType: 'WechatMiniProgram' }, language).catch((error) => logRequestError('getMyPendingSignContracts', error))
: Promise.resolve([]),
currentOrderNo
? getMyEffectiveContracts({ OrderNo: currentOrderNo }, language).catch((error) => logRequestError('getMyEffectiveContracts', error))
: Promise.resolve([])
])
}).then(([validOrders, currentOrder, pendingContractsResponse, effectiveContractsResponse]) => {
const pendingContracts = parseContractList(pendingContractsResponse)
const effectiveContracts = parseContractList(effectiveContractsResponse)
const currentBooking = pickCurrentBooking(validOrders) const currentBooking = pickCurrentBooking(validOrders)
const nextUser = { const nextUser = {
...currentUser, ...currentUser,
@@ -208,7 +235,7 @@ const store = new Vuex.Store({
globalEnumsPromise = getEnums({}, language) globalEnumsPromise = getEnums({}, language)
.then((res) => { .then((res) => {
const data = res && res.data ? res.data : res const data = res && res.data ? res.data : res
const enumsMap = {} const enumsMap = getBuiltInEnums(language)
if (Array.isArray(data)) { if (Array.isArray(data)) {
data.forEach(item => { data.forEach(item => {
enumsMap[item.code] = item.data || [] enumsMap[item.code] = item.data || []
@@ -222,7 +249,7 @@ const store = new Vuex.Store({
return enumsMap return enumsMap
}) })
.catch(() => { .catch(() => {
return hasEnums ? state.vuex_enums : {} return hasEnums ? state.vuex_enums : getBuiltInEnums(language)
}) })
.finally(() => { .finally(() => {
globalEnumsPromise = null globalEnumsPromise = null

36
utils/booking-entry.js Normal file
View File

@@ -0,0 +1,36 @@
function normalizeUrl(url = '') {
return String(url || '').trim()
}
export function isBookingEntryUrl(url = '') {
return /^\/pkgBooking\/(book_viewing\/book_viewing|book\/book)(?:\?|$)/.test(normalizeUrl(url))
}
export function resolveExistingBookingNavigationTarget(stayState = {}) {
const source = stayState || {}
const currentBooking = source.currentBooking || null
const hasCheckedIn = source.hasCheckedIn === true
if (currentBooking && currentBooking.orderNo) {
return `/pkgBooking/booking_detail/booking_detail?orderNo=${encodeURIComponent(currentBooking.orderNo)}`
}
if (currentBooking) {
return '/pkgBooking/booking_detail/booking_detail'
}
if (hasCheckedIn) {
return '/pages/index/index?pageId=stay-service'
}
return ''
}
export function resolveBookingRedirectAfterLogin(targetUrl = '', stayState = {}) {
const normalizedTargetUrl = normalizeUrl(targetUrl)
if (!isBookingEntryUrl(normalizedTargetUrl)) {
return normalizedTargetUrl
}
return resolveExistingBookingNavigationTarget(stayState) || normalizedTargetUrl
}

View File

@@ -2,15 +2,110 @@ import { getEnums } from '@/api/index.js'
import { getCurrentLanguage } from '@/utils/i18n' import { getCurrentLanguage } from '@/utils/i18n'
const ENUMS_CACHE_KEY = 'mini_enums_cache_v1' const ENUMS_CACHE_KEY = 'mini_enums_cache_v1'
const BUILTIN_ENUMS = {
zh: {
IdCardTypeEnum: [
{ key: 3, value: '港澳通行证', valueEn: 'Mainland Travel Permit for Hong Kong and Macao Residents' },
{ key: 2, value: '护照', valueEn: 'Passport' },
{ key: 99, value: '其他', valueEn: 'Other' },
{ key: 1, value: '身份证', valueEn: 'ID Card' },
{ key: 4, value: '台湾通行证', valueEn: 'Mainland Travel Permit for Taiwan Residents' }
]
},
en: {
IdCardTypeEnum: [
{ key: 3, value: 'Mainland Travel Permit for Hong Kong and Macao Residents', valueZh: '港澳通行证' },
{ key: 2, value: 'Passport', valueZh: '护照' },
{ key: 99, value: 'Other', valueZh: '其他' },
{ key: 1, value: 'ID Card', valueZh: '身份证' },
{ key: 4, value: 'Mainland Travel Permit for Taiwan Residents', valueZh: '台湾通行证' }
]
}
}
let enumsMap = {} let enumsMap = {}
let enumsLanguage = '' let enumsLanguage = ''
let fetchPromise = null let fetchPromise = null
function hasRemoteEnumData(map = {}, language) {
const currentLanguage = getNormalizedLanguage(language)
const builtinKeys = Object.keys(BUILTIN_ENUMS[currentLanguage] || {})
const sourceMap = map && typeof map === 'object' ? map : {}
return Object.keys(sourceMap).some(code => !builtinKeys.includes(code))
}
function mergeEnumOptionList(builtinList = [], customList = []) {
const nextList = []
const customMap = new Map()
;(Array.isArray(customList) ? customList : []).forEach((item) => {
if (!item || typeof item !== 'object') return
customMap.set(String(item.key), item)
})
;(Array.isArray(builtinList) ? builtinList : []).forEach((item) => {
const key = String(item && item.key)
const customItem = customMap.get(key)
if (customItem) {
nextList.push({
...item,
...customItem
})
customMap.delete(key)
return
}
nextList.push(item)
})
customMap.forEach((item) => {
nextList.push(item)
})
return nextList
}
function mergeBuiltinEnums(map = {}, language) {
const currentLanguage = getNormalizedLanguage(language)
const builtinMap = BUILTIN_ENUMS[currentLanguage] || {}
const mergedMap = {
...builtinMap,
...(map || {})
}
Object.keys(builtinMap).forEach((code) => {
if (!Array.isArray(mergedMap[code])) {
mergedMap[code] = builtinMap[code]
return
}
mergedMap[code] = mergeEnumOptionList(builtinMap[code], mergedMap[code])
})
return mergedMap
}
function parseJsonStringMaybe(value) {
if (typeof value !== 'string') {
return value
}
const text = value.trim()
if (!text) {
return value
}
if (!((text.startsWith('{') && text.endsWith('}')) || (text.startsWith('[') && text.endsWith(']')))) {
return value
}
try {
return JSON.parse(text)
} catch (error) {
return value
}
}
function normalizeEnumMap(data) { function normalizeEnumMap(data) {
const normalizedData = parseJsonStringMaybe(data)
const map = {} const map = {}
if (Array.isArray(data)) { if (Array.isArray(normalizedData)) {
data.forEach((item) => { normalizedData.forEach((item) => {
if (item && item.code) { if (item && item.code) {
map[item.code] = Array.isArray(item.data) ? item.data : [] map[item.code] = Array.isArray(item.data) ? item.data : []
} }
@@ -76,26 +171,30 @@ function writeEnumsCache(map, language) {
export function initEnumsFromCache(language) { export function initEnumsFromCache(language) {
const currentLanguage = getNormalizedLanguage(language) const currentLanguage = getNormalizedLanguage(language)
if (Object.keys(enumsMap).length && enumsLanguage === currentLanguage) return enumsMap if (Object.keys(enumsMap).length && enumsLanguage === currentLanguage) return enumsMap
enumsMap = readEnumsCache(currentLanguage) enumsMap = mergeBuiltinEnums(readEnumsCache(currentLanguage), currentLanguage)
enumsLanguage = currentLanguage enumsLanguage = currentLanguage
return enumsMap return enumsMap
} }
export async function fetchEnums(force = false, language) { export async function fetchEnums(force = false, language) {
const currentLanguage = getNormalizedLanguage(language) const currentLanguage = getNormalizedLanguage(language)
if (!force && Object.keys(enumsMap).length && enumsLanguage === currentLanguage) return enumsMap if (!force && Object.keys(enumsMap).length && enumsLanguage === currentLanguage && hasRemoteEnumData(enumsMap, currentLanguage)) {
return enumsMap
}
if (fetchPromise) return fetchPromise if (fetchPromise) return fetchPromise
fetchPromise = (async () => { fetchPromise = (async () => {
if (!force && (!Object.keys(enumsMap).length || enumsLanguage !== currentLanguage)) { if (!force && (!Object.keys(enumsMap).length || enumsLanguage !== currentLanguage)) {
initEnumsFromCache(currentLanguage) initEnumsFromCache(currentLanguage)
if (Object.keys(enumsMap).length) return enumsMap if (Object.keys(enumsMap).length && hasRemoteEnumData(enumsMap, currentLanguage)) {
return enumsMap
}
} }
try { try {
const res = await getEnums({}, currentLanguage) const res = await getEnums({}, currentLanguage)
const data = res && res.data ? res.data : res const data = res && res.data ? res.data : res
const map = normalizeEnumMap(data) const map = mergeBuiltinEnums(normalizeEnumMap(data), currentLanguage)
if (Object.keys(map).length) { if (Object.keys(map).length) {
enumsMap = map enumsMap = map
enumsLanguage = currentLanguage enumsLanguage = currentLanguage
@@ -118,6 +217,10 @@ export function getEnumOptions(code, language) {
return enumsMap[code] || [] return enumsMap[code] || []
} }
export function getBuiltInEnums(language) {
return mergeBuiltinEnums({}, language)
}
export function getEnumLabel(code, key, fallback = '', language) { export function getEnumLabel(code, key, fallback = '', language) {
const options = getEnumOptions(code, language) const options = getEnumOptions(code, language)
const matched = options.find(item => String(item.key) === String(key)) const matched = options.find(item => String(item.key) === String(key))

86
utils/privacy.js Normal file
View File

@@ -0,0 +1,86 @@
const privacyAuthorizationHandlers = []
let hasBoundPrivacyAuthorizationListener = false
function getActivePrivacyAuthorizationHandler() {
return privacyAuthorizationHandlers.length
? privacyAuthorizationHandlers[privacyAuthorizationHandlers.length - 1]
: null
}
export function bindPrivacyAuthorizationListener(handler) {
if (typeof handler !== 'function') {
return
}
const existedIndex = privacyAuthorizationHandlers.indexOf(handler)
if (existedIndex !== -1) {
privacyAuthorizationHandlers.splice(existedIndex, 1)
}
privacyAuthorizationHandlers.push(handler)
// #ifdef MP-WEIXIN
if (hasBoundPrivacyAuthorizationListener || typeof wx === 'undefined' || !wx.onNeedPrivacyAuthorization) {
return
}
wx.onNeedPrivacyAuthorization((resolve) => {
const activeHandler = getActivePrivacyAuthorizationHandler()
if (typeof activeHandler === 'function') {
activeHandler(resolve)
}
})
hasBoundPrivacyAuthorizationListener = true
// #endif
}
export function unbindPrivacyAuthorizationListener(handler) {
const existedIndex = privacyAuthorizationHandlers.lastIndexOf(handler)
if (existedIndex !== -1) {
privacyAuthorizationHandlers.splice(existedIndex, 1)
}
}
export function getPrivacyAuthorizationStatus() {
// #ifdef MP-WEIXIN
return new Promise((resolve) => {
if (typeof wx === 'undefined' || !wx.getPrivacySetting) {
resolve({ needAuthorization: false })
return
}
wx.getPrivacySetting({
success: (res) => {
resolve({
...res,
needAuthorization: !!(res && res.needAuthorization)
})
},
fail: () => {
resolve({ needAuthorization: false })
}
})
})
// #endif
// #ifndef MP-WEIXIN
return Promise.resolve({ needAuthorization: false })
// #endif
}
export function openWechatPrivacyContract() {
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
if (typeof wx === 'undefined' || !wx.openPrivacyContract) {
resolve()
return
}
wx.openPrivacyContract({
success: resolve,
fail: reject
})
})
// #endif
// #ifndef MP-WEIXIN
return Promise.resolve()
// #endif
}

View File

@@ -84,6 +84,29 @@ function shouldSkipUnauthorizedRedirect() {
return route === 'pkgAccount/login/login' || route === 'pages/login/index' || route === 'pages/login/login' return route === 'pkgAccount/login/login' || route === 'pages/login/index' || route === 'pages/login/login'
} }
function normalizeRequestedLanguageHeaderValue(language) {
return String(language || getCurrentLanguage() || 'zh').trim().toLowerCase() === 'en' ? 'en' : 'zh'
}
function buildRequestHeader(header = {}, token = '') {
const normalizedHeader = { ...(header || {}) }
const requestedLanguageHeaderKey = Object.keys(normalizedHeader).find(key => String(key || '').toLowerCase() === 'x-requested-language')
if (requestedLanguageHeaderKey && requestedLanguageHeaderKey !== 'X-Requested-Language') {
delete normalizedHeader[requestedLanguageHeaderKey]
}
normalizedHeader['X-Requested-Language'] = normalizeRequestedLanguageHeaderValue(
requestedLanguageHeaderKey ? normalizedHeader[requestedLanguageHeaderKey] : normalizedHeader['X-Requested-Language']
)
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...normalizedHeader
}
}
function handleUnauthorized(responseData) { function handleUnauthorized(responseData) {
clearLoginSession() clearLoginSession()
@@ -126,12 +149,7 @@ export function request(options = {}) {
data: uppercaseMethod === 'GET' data: uppercaseMethod === 'GET'
? (params !== undefined ? normalizedParams : normalizedData) ? (params !== undefined ? normalizedParams : normalizedData)
: normalizedData, : normalizedData,
header: { header: buildRequestHeader(header, token),
'Content-Type': 'application/json',
'X-Requested-Language': getCurrentLanguage(),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...header
},
success: (res) => { success: (res) => {
const { statusCode, data: responseData } = res const { statusCode, data: responseData } = res
if ((statusCode === 401) || (responseData && Number(responseData.code) === 401)) { if ((statusCode === 401) || (responseData && Number(responseData.code) === 401)) {

176
utils/reservation-flow.js Normal file
View File

@@ -0,0 +1,176 @@
const RESERVATION_FLOW_STEP = {
INSPECTION: 10,
BILL: 20,
CONTRACT: 30,
CHECKIN: 40
}
function getLanguage(language) {
return language === 'en' ? 'en' : 'zh'
}
function parseJsonStringMaybe(value) {
if (typeof value !== 'string') {
return value
}
const text = value.trim()
if (!text) {
return value
}
const isJsonLike = (
(text.startsWith('{') && text.endsWith('}'))
|| (text.startsWith('[') && text.endsWith(']'))
)
if (!isJsonLike) {
return value
}
try {
return JSON.parse(text)
} catch (error) {
return value
}
}
function normalizeArray(value) {
if (Array.isArray(value)) {
return value
}
return []
}
export function parseReservationFlowResponse(response) {
const rawPayload = response && response.data ? response.data : response
const payload = parseJsonStringMaybe(rawPayload)
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 []
}
export function getReservationOrderNo(source = {}) {
const candidates = [
source.orderNo,
source.OrderNo,
source.reservationOrderNo,
source.searchedOrderNo,
source.bookingOrderNo
]
for (let index = 0; index < candidates.length; index += 1) {
const value = String(candidates[index] || '').trim()
if (value) {
return value
}
}
return ''
}
export function getReservationCurrentNode(flow = {}) {
const nodes = normalizeArray(flow && flow.flowNodes)
.filter(item => item && typeof item === 'object')
.sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0))
if (!nodes.length) {
return null
}
return nodes.find(item => item.isCurrent) || nodes[nodes.length - 1]
}
export function buildReservationFlowMap(flowList = []) {
return normalizeArray(flowList)
.filter(item => item && typeof item === 'object')
.reduce((result, item) => {
const orderNo = getReservationOrderNo(item)
if (!orderNo) {
return result
}
result[orderNo] = {
...item,
orderNo,
currentNode: getReservationCurrentNode(item)
}
return result
}, {})
}
export function attachReservationFlow(item = {}, flowMap = {}) {
const orderNo = getReservationOrderNo(item)
const matchedFlow = orderNo ? flowMap[orderNo] : null
return {
...item,
reservationOrderNo: orderNo,
reservationFlow: matchedFlow || null,
reservationCurrentNode: matchedFlow && matchedFlow.currentNode ? matchedFlow.currentNode : null
}
}
export function getReservationStepMeta(step, language = 'zh') {
const currentLanguage = getLanguage(language)
const map = {
[RESERVATION_FLOW_STEP.INSPECTION]: {
label: currentLanguage === 'en' ? 'Inspection' : '验房',
actionText: currentLanguage === 'en' ? 'View Inspection' : '查看验房'
},
[RESERVATION_FLOW_STEP.BILL]: {
label: currentLanguage === 'en' ? 'Bill' : '账单',
actionText: currentLanguage === 'en' ? 'Go to Bill' : '去缴费'
},
[RESERVATION_FLOW_STEP.CONTRACT]: {
label: currentLanguage === 'en' ? 'Contract' : '合同',
actionText: currentLanguage === 'en' ? 'Go to Contract' : '去签约'
},
[RESERVATION_FLOW_STEP.CHECKIN]: {
label: currentLanguage === 'en' ? 'Check-in' : '入住',
actionText: currentLanguage === 'en' ? 'Go to Check-in' : '去入住'
}
}
return map[Number(step)] || {
label: currentLanguage === 'en' ? 'Current Step' : '当前步骤',
actionText: currentLanguage === 'en' ? 'View Current Step' : '查看当前步骤'
}
}
export function getReservationCurrentStepText(source = {}, language = 'zh') {
const node = source && source.reservationCurrentNode
? source.reservationCurrentNode
: getReservationCurrentNode(source)
if (!node) {
return ''
}
return String(node.stepDesc || '').trim() || getReservationStepMeta(node.step, language).label
}
export function getReservationCurrentActionText(source = {}, language = 'zh') {
const node = source && source.reservationCurrentNode
? source.reservationCurrentNode
: getReservationCurrentNode(source)
if (!node) {
return ''
}
return getReservationStepMeta(node.step, language).actionText
}
export { RESERVATION_FLOW_STEP }

View File

@@ -364,6 +364,11 @@ export function normalizeAreaRoomType(item = {}) {
return { return {
code: item.code || '', code: item.code || '',
areaCode: item.areaCode || '',
buildingCode: item.buildingCode || item.buildingNo || '',
buildingName: item.buildingName || '',
buildingSectionCode: item.buildingSectionCode || item.unitCode || '',
buildingSectionName: item.buildingSectionName || item.unitName || '',
name: item.name || '', name: item.name || '',
nameEn: item.nameEn || '', nameEn: item.nameEn || '',
nameEnDisplay: compactEnglishText(item.nameEn || '', 22), nameEnDisplay: compactEnglishText(item.nameEn || '', 22),

View File

@@ -112,18 +112,25 @@ function pickFirstValue(...values) {
return '' return ''
} }
function buildPendingContractQuery() { function buildPendingContractQuery(order = {}) {
const user = getCurrentUser() || {} const orderNo = pickFirstValue(order.orderNo, order.no, order.code, order.OrderNo)
const userCode = pickFirstValue(user.userCode, user.code, user.UserCode) if (!orderNo) {
const query = { return null
ProviderType: 'WechatMiniProgram'
} }
return {
if (userCode) { ProviderType: 'WechatMiniProgram',
query.UserCode = userCode OrderNo: orderNo
} }
}
return query function buildEffectiveContractQuery(order = {}) {
const orderNo = pickFirstValue(order.orderNo, order.no, order.code, order.OrderNo)
if (!orderNo) {
return null
}
return {
OrderNo: orderNo
}
} }
function enrichBookingLanguageFields(booking = {}) { function enrichBookingLanguageFields(booking = {}) {
@@ -233,15 +240,23 @@ function requestStayState(language = 'zh') {
IsExport: false IsExport: false
}, language) }, language)
.then((orderResponse) => { .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 orderList = normalizeBookingList(parseOrderListResponse(orderResponse), language)
const currentOrder = pickCurrentStayOrder(orderList) const currentOrder = pickCurrentStayOrder(orderList)
const pendingContractQuery = buildPendingContractQuery(currentOrder)
const effectiveContractQuery = buildEffectiveContractQuery(currentOrder)
return Promise.all([
Promise.resolve(orderList),
Promise.resolve(currentOrder),
pendingContractQuery
? getMyPendingSignContracts(pendingContractQuery, language).catch(() => [])
: Promise.resolve([]),
effectiveContractQuery
? getMyEffectiveContracts(effectiveContractQuery, language).catch(() => [])
: Promise.resolve([])
])
})
.then(([orderList, currentOrder, pendingContractsResponse, effectiveContractsResponse]) => {
const pendingContracts = parseContractList(pendingContractsResponse) const pendingContracts = parseContractList(pendingContractsResponse)
const effectiveContracts = parseContractList(effectiveContractsResponse) const effectiveContracts = parseContractList(effectiveContractsResponse)
const currentBooking = enrichBookingLanguageFields(pickCurrentBooking(orderList) || buildContractStepBooking(pendingContracts)) const currentBooking = enrichBookingLanguageFields(pickCurrentBooking(orderList) || buildContractStepBooking(pendingContracts))