Files
QSY-lvjuxcx/pkgService/submit_request/submit_request.vue
2026-06-29 09:42:53 +08:00

1305 lines
47 KiB
Vue

<template>
<view class="submit-request-page tn-safe-area-inset-bottom">
<tn-nav-bar
:isBack="true"
:bottomShadow="false"
:backgroundColor="navBarBackgroundColor"
:customBackStyle="navBarBackStyle"
:fixed="true"
class="glass-nav-bar"
>
<view class="tn-text-bold tn-text-xl tn-color-black" :style="{ opacity: navTitleOpacity }">{{ pageTitle }}</view>
</tn-nav-bar>
<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="hero-header tn-margin-bottom-xl">
<view class="hero-badge">
<text class="badge-dot"></text>
<text>APPLICATION</text>
</view>
<view class="hero-title">{{ pageTitle }}</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 class="form-card tn-margin-top-xl">
<view class="block-title">{{ formBlockTitle }}</view>
<view class="block-subtitle">{{ formBlockDesc }}</view>
<view v-if="showRoomTypeSelector" class="form-item tn-margin-top">
<view class="form-label">{{ roomTypeLabel }}</view>
<room-type-selector
v-model="form.intendedRoomTypeCode"
:initial-area-code="form.intendedAreaCode"
@change="handleRoomTypeChange"
/>
</view>
<view v-if="showPreferenceSelectors" class="form-item tn-margin-top">
<view class="form-label">{{ t('hobbyLabel') }}</view>
<tag-selector
v-model="form.hobbiesAndInterestsTagKeys"
:options="hobbyOptions"
:loading="dictLoading"
:emptyText="t('emptyHobbyTags')"
/>
</view>
<view v-if="showPreferenceSelectors" class="form-item tn-margin-top">
<view class="form-label">{{ t('lifestyleLabel') }}</view>
<tag-selector
v-model="form.lifestyleHabitsTagKeys"
:options="lifestyleOptions"
:loading="dictLoading"
:emptyText="t('emptyLifestyleTags')"
/>
</view>
<view v-if="showDateRange" class="form-item tn-margin-top">
<view class="form-label">{{ dateRangeLabel }}</view>
<date-range-selector
:start.sync="form.start"
:end.sync="form.end"
:start-label="dateRangeStartLabel"
:end-label="dateRangeEndLabel"
:start-placeholder="dateRangeStartPlaceholder"
:end-placeholder="dateRangeEndPlaceholder"
/>
</view>
<view class="form-item tn-margin-top">
<view class="form-label">{{ t('remarkLabel') }}</view>
<textarea class="form-textarea" :placeholder="remarkPlaceholder" v-model="form.remark"></textarea>
</view>
<view 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>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { submitApplicationForm, sysdictPage } from '@/api/index.js'
import { saveActiveApplicationCode, saveHomePageTabId } from '@/utils/app-state'
import { compactEnglishText, localizePayMethodText } from '../../utils/select-room-normalizers'
import { getStoredUser } from '../../utils/flow'
import TagSelector from './components/TagSelector.vue'
import DateRangeSelector from './components/DateRangeSelector.vue'
import RoomTypeSelector from './components/RoomTypeSelector.vue'
function createDefaultForm() {
const now = new Date()
const pad = n => String(n).padStart(2, '0')
const defaultStart = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
return {
intendedAreaCode: '',
intendedRoomTypeCode: '',
roomSlotHeight: 1,
hobbiesAndInterestsTagKeys: [],
lifestyleHabitsTagKeys: [],
remark: '',
start: defaultStart,
end: ''
}
}
const DICT_TEXT_FALLBACK_MAP = {
hobbies: {
'打篮球': 'Play Basketball',
'打羽毛球': 'Play Badminton',
'唱歌': 'Sing',
'电竞': 'Esports Games',
'电竞游戏': 'Esports Games',
'旅游': 'Travel'
},
lifestyle: {
'晚睡': 'Stay Up Late',
'早睡': 'Sleep Early',
'早起': 'Wake Up Early',
'安静': 'Quiet',
'整洁': 'Tidy',
'爱干净': 'Neat'
}
}
function getOptionTextCandidates(item = {}) {
return [
item.value,
item.label,
item.description,
item.name,
item.cname,
item.nameZh,
item.labelZh,
item.valueZh,
item.descriptionZh,
item.ename,
item.nameEn,
item.labelEn,
item.valueEn,
item.descriptionEn,
item.code
].map(value => String(value || '').trim()).filter(Boolean)
}
function getDictFallbackText(item = {}, language = 'zh', type = '') {
if (language !== 'en') {
return ''
}
const fallbackMap = DICT_TEXT_FALLBACK_MAP[type] || {}
const baseText = [
item.description,
item.value,
item.label,
item.name,
item.cname,
item.nameZh,
item.labelZh,
item.valueZh,
item.descriptionZh
].map(value => String(value || '').trim()).find(Boolean)
return baseText ? (fallbackMap[baseText] || '') : ''
}
function getLocalizedOptionText(item = {}, language = 'zh', type = '') {
const isEn = language === 'en'
const preferred = isEn
? [item.descriptionEn, item.ename, item.nameEn, item.labelEn, item.valueEn, item.description, item.value, item.label, item.name, item.cname, item.code]
: [item.description, item.cname, item.nameZh, item.labelZh, item.valueZh, item.value, item.label, item.name, item.code]
const matched = preferred.map(value => String(value || '').trim()).find(Boolean)
if (!matched) {
return getDictFallbackText(item, language, type)
}
if (isEn) {
const fallbackMap = DICT_TEXT_FALLBACK_MAP[type] || {}
if (fallbackMap[matched]) {
return fallbackMap[matched]
}
const zhText = [item.label, item.name, item.cname, item.nameZh, item.labelZh, item.description, item.value, item.valueZh, item.descriptionZh]
.map(value => String(value || '').trim())
.find(Boolean)
if (zhText && fallbackMap[zhText]) {
return fallbackMap[zhText]
}
if (zhText && matched === zhText) {
return getDictFallbackText(item, language, type) || matched
}
}
return matched
}
function normalizeDictList(response, language = 'zh', type = '') {
const raw = response && response.data ? (response.data.data || response.data) : response
const list = Array.isArray(raw) ? raw : []
return list
.map(item => ({
key: item && (item.key || item.code || item.value || ''),
value: getLocalizedOptionText(item, language, type) || getOptionTextCandidates(item)[0] || ''
}))
.filter(item => item.key && item.value)
}
function normalizeDateTime(date, endOfDay = false) {
if (!date) {
return ''
}
return `${date}T${endOfDay ? '23:59:59' : '00:00:00'}`
}
function buildRoomLabel(payload = {}) {
return payload.roomLabel || payload.currentRoomLabel || payload.roomName || ''
}
function isPlainObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function normalizeCodeValue(value) {
if (value === undefined || value === null) {
return ''
}
if (typeof value === 'string' || typeof value === 'number') {
return String(value).trim()
}
return ''
}
function extractSubmitApplicationCode(response) {
const payload = response && response.data !== undefined ? response.data : response
const directValues = [
payload,
payload && payload.data,
payload && payload.result,
payload && payload.record,
payload && payload.application
]
for (let i = 0; i < directValues.length; i += 1) {
const directCode = normalizeCodeValue(directValues[i])
if (directCode) {
return directCode
}
}
const sources = directValues.filter(isPlainObject)
const codeFields = ['detailCode', 'code', 'applicationCode', 'formCode', 'referenceTypeNo', 'no']
for (let i = 0; i < sources.length; i += 1) {
const current = sources[i]
for (let j = 0; j < codeFields.length; j += 1) {
const nextCode = normalizeCodeValue(current[codeFields[j]])
if (nextCode) {
return nextCode
}
}
}
return ''
}
const BUSINESS_TYPE_CONFIG = {
'change-room': {
title: '换房申请',
titleEn: 'Change Room Request',
titleEnShort: 'Room Change',
desc: '请填写期望房型、计划换房时间和补充说明,提交后由管家继续跟进。',
descEn: 'Fill in the expected room type, move date, and notes. Your butler will follow up after submission.',
formBlockTitle: '换房申请信息',
formBlockTitleEn: 'Change Room Details',
formBlockDesc: '将通过真实申请接口提交换房意向。',
formBlockDescEn: 'Your change room request will be submitted through the live application API.',
primaryActionText: '提交换房申请',
primaryActionTextEn: 'Submit Change Room Request',
remarkPlaceholder: '请输入换房原因、房间偏好和其他补充说明',
remarkPlaceholderEn: 'Enter the reason, room preference, and additional notes',
roomTypeLabel: '期望房型',
roomTypeLabelEn: 'Expected Room Type',
dateRangeLabel: '时间安排',
dateRangeLabelEn: 'Schedule',
dateRangeStartLabel: '期望换房',
dateRangeStartLabelEn: 'Move Date',
dateRangeEndLabel: '预计入住',
dateRangeEndLabelEn: 'Expected Check-in',
dateRangeStartPlaceholder: '请选择换房日期',
dateRangeStartPlaceholderEn: 'Select move date',
dateRangeEndPlaceholder: '请选择入住日期',
dateRangeEndPlaceholderEn: 'Select check-in date',
showRoomTypeSelector: true,
showPreferenceSelectors: false,
showDateRange: true,
buildDefaultRemark(payload = {}) {
const roomLabel = buildRoomLabel(payload)
const language = payload.language || 'zh'
if (language === 'en') {
return roomLabel
? `I am currently staying in "${roomLabel}" and would like to request a room change. Please help confirm available room types and processing time.`
: 'I would like to request a room change. Please help confirm available room types and processing time.'
}
return roomLabel
? `我当前居住房间为“${roomLabel}”,现申请换房,请协助确认可调整房型和办理时间。`
: '我想申请换房,请协助确认可调整房型和办理时间。'
}
},
'renew-stay': {
title: '续住申请',
titleEn: 'Renew Stay Request',
titleEnShort: 'Renew Stay',
desc: '请填写计划续住时间和补充说明,提交后由管家继续跟进。',
descEn: 'Fill in the renewal period and notes. Your butler will follow up after submission.',
formBlockTitle: '续住申请信息',
formBlockTitleEn: 'Renewal Details',
formBlockDesc: '将通过真实申请接口提交续住意向。',
formBlockDescEn: 'Your renewal request will be submitted through the live application API.',
primaryActionText: '提交续住申请',
primaryActionTextEn: 'Submit Renewal Request',
remarkPlaceholder: '请输入续住原因、计划续住时长和其他说明',
remarkPlaceholderEn: 'Enter the renewal reason, duration, and notes',
dateRangeLabel: '续住时间',
dateRangeLabelEn: 'Renewal Period',
dateRangeStartLabel: '当前到期',
dateRangeStartLabelEn: 'Current End Date',
dateRangeEndLabel: '计划续住至',
dateRangeEndLabelEn: 'Renew Until',
dateRangeStartPlaceholder: '请选择当前到期日',
dateRangeStartPlaceholderEn: 'Select current end date',
dateRangeEndPlaceholder: '请选择续住到期日',
dateRangeEndPlaceholderEn: 'Select renewed end date',
showRoomTypeSelector: false,
showPreferenceSelectors: false,
showDateRange: true,
buildDefaultRemark(payload = {}) {
const roomLabel = buildRoomLabel(payload)
const language = payload.language || 'zh'
if (language === 'en') {
return roomLabel
? `I am currently staying in "${roomLabel}" and would like to renew my stay. Please help confirm the renewal process.`
: 'I would like to renew my stay. Please help confirm the renewal process.'
}
return roomLabel
? `我当前居住房间为“${roomLabel}”,现申请续住,请协助确认续住办理流程。`
: '我想申请续住,请协助确认续住办理流程。'
}
},
checkout: {
title: '退房申请',
titleEn: 'Checkout Request',
titleEnShort: 'Check Out',
desc: '请填写计划退房时间和离场说明,提交后由管家安排后续验房流程。',
descEn: 'Fill in the checkout schedule and departure notes. Your butler will arrange the inspection process.',
formBlockTitle: '退房申请信息',
formBlockTitleEn: 'Checkout Details',
formBlockDesc: '将通过真实申请接口提交退房意向。',
formBlockDescEn: 'Your checkout request will be submitted through the live application API.',
primaryActionText: '提交退房申请',
primaryActionTextEn: 'Submit Checkout Request',
remarkPlaceholder: '请输入退房原因、物品处理说明和其他补充内容',
remarkPlaceholderEn: 'Enter the checkout reason, belongings arrangement, and notes',
dateRangeLabel: '退房安排',
dateRangeLabelEn: 'Checkout Schedule',
dateRangeStartLabel: '计划退房',
dateRangeStartLabelEn: 'Planned Checkout',
dateRangeEndLabel: '最晚离场',
dateRangeEndLabelEn: 'Latest Departure',
dateRangeStartPlaceholder: '请选择退房日期',
dateRangeStartPlaceholderEn: 'Select checkout date',
dateRangeEndPlaceholder: '请选择离场日期',
dateRangeEndPlaceholderEn: 'Select departure date',
showRoomTypeSelector: false,
showPreferenceSelectors: false,
showDateRange: true,
buildDefaultRemark(payload = {}) {
const roomLabel = buildRoomLabel(payload)
const language = payload.language || 'zh'
if (language === 'en') {
return roomLabel
? `I am currently staying in "${roomLabel}" and would like to check out. Please help arrange the inspection and departure process.`
: 'I would like to check out. Please help arrange the inspection and departure process.'
}
return roomLabel
? `我当前居住房间为“${roomLabel}”,现申请退房,请协助安排后续验房和离场流程。`
: '我想申请退房,请协助安排后续验房和离场流程。'
}
},
repair: {
title: '设备报修',
titleEn: 'Repair Request',
titleEnShort: 'Repair',
desc: '请填写故障位置、问题描述和期望上门时间,提交后由管家安排处理。',
descEn: 'Fill in the issue location, problem details, and preferred visit time. Your butler will arrange service.',
formBlockTitle: '报修信息',
formBlockTitleEn: 'Repair Details',
formBlockDesc: '将通过真实申请接口提交报修需求。',
formBlockDescEn: 'Your repair request will be submitted through the live application API.',
primaryActionText: '提交报修申请',
primaryActionTextEn: 'Submit Repair Request',
remarkPlaceholder: '请输入故障位置、问题现象和期望处理时间',
remarkPlaceholderEn: 'Enter the issue location, symptoms, and preferred handling time',
dateRangeLabel: '上门时间',
dateRangeLabelEn: 'Visit Time',
dateRangeStartLabel: '期望上门',
dateRangeStartLabelEn: 'Preferred Visit',
dateRangeEndLabel: '可接受至',
dateRangeEndLabelEn: 'Available Until',
dateRangeStartPlaceholder: '请选择期望时间',
dateRangeStartPlaceholderEn: 'Select preferred time',
dateRangeEndPlaceholder: '请选择最晚时间',
dateRangeEndPlaceholderEn: 'Select latest time',
showRoomTypeSelector: false,
showPreferenceSelectors: false,
showDateRange: true,
buildDefaultRemark(payload = {}) {
const roomLabel = buildRoomLabel(payload)
const language = payload.language || 'zh'
if (language === 'en') {
return roomLabel
? `I am currently staying in "${roomLabel}" and need help with a device repair. Please arrange service support.`
: 'I need help with a device repair. Please arrange service support.'
}
return roomLabel
? `我当前居住房间为“${roomLabel}”,有设备需要报修,请协助安排人员处理。`
: '我有设备需要报修,请协助安排人员处理。'
}
},
laundry: {
title: '洗衣房预约',
titleEn: 'Laundry Room Booking',
titleEnShort: 'Laundry',
desc: '请填写预约时间和补充说明,提交后由管家确认场地安排。',
descEn: 'Fill in the booking time and notes. Your butler will confirm the venue arrangement.',
formBlockTitle: '预约信息',
formBlockTitleEn: 'Booking Details',
formBlockDesc: '将通过真实申请接口提交洗衣房预约需求。',
formBlockDescEn: 'Your laundry room booking will be submitted through the live application API.',
primaryActionText: '提交洗衣房预约',
primaryActionTextEn: 'Submit Laundry Booking',
remarkPlaceholder: '请输入预约需求、设备偏好和其他说明',
remarkPlaceholderEn: 'Enter the booking request, equipment preference, and notes',
dateRangeLabel: '预约时间',
dateRangeLabelEn: 'Booking Time',
dateRangeStartLabel: '预约开始',
dateRangeStartLabelEn: 'Start Time',
dateRangeEndLabel: '预约结束',
dateRangeEndLabelEn: 'End Time',
dateRangeStartPlaceholder: '请选择开始时间',
dateRangeStartPlaceholderEn: 'Select start time',
dateRangeEndPlaceholder: '请选择结束时间',
dateRangeEndPlaceholderEn: 'Select end time',
showRoomTypeSelector: false,
showPreferenceSelectors: false,
showDateRange: true,
buildDefaultRemark(payload = {}) {
if ((payload.language || 'zh') === 'en') {
return 'I would like to book the laundry room. Please help confirm available time slots and equipment arrangements.'
}
return '我想预约洗衣房,请协助确认可用时段和设备安排。'
}
},
gym: {
title: '健身房预约',
titleEn: 'Gym Booking',
titleEnShort: 'Gym',
desc: '请填写预约时间和补充说明,提交后由管家确认场地安排。',
descEn: 'Fill in the booking time and notes. Your butler will confirm the venue arrangement.',
formBlockTitle: '预约信息',
formBlockTitleEn: 'Booking Details',
formBlockDesc: '将通过真实申请接口提交健身房预约需求。',
formBlockDescEn: 'Your gym booking will be submitted through the live application API.',
primaryActionText: '提交健身房预约',
primaryActionTextEn: 'Submit Gym Booking',
remarkPlaceholder: '请输入预约需求、人数和其他说明',
remarkPlaceholderEn: 'Enter the booking request, attendee count, and notes',
dateRangeLabel: '预约时间',
dateRangeLabelEn: 'Booking Time',
dateRangeStartLabel: '预约开始',
dateRangeStartLabelEn: 'Start Time',
dateRangeEndLabel: '预约结束',
dateRangeEndLabelEn: 'End Time',
dateRangeStartPlaceholder: '请选择开始时间',
dateRangeStartPlaceholderEn: 'Select start time',
dateRangeEndPlaceholder: '请选择结束时间',
dateRangeEndPlaceholderEn: 'Select end time',
showRoomTypeSelector: false,
showPreferenceSelectors: false,
showDateRange: true,
buildDefaultRemark(payload = {}) {
if ((payload.language || 'zh') === 'en') {
return 'I would like to book the gym. Please help confirm available time slots.'
}
return '我想预约健身房,请协助确认可用时段。'
}
},
kitchen: {
title: '公共厨房预约',
titleEn: 'Shared Kitchen Booking',
titleEnShort: 'Kitchen',
desc: '请填写预约时间和补充说明,提交后由管家确认场地安排。',
descEn: 'Fill in the booking time and notes. Your butler will confirm the venue arrangement.',
formBlockTitle: '预约信息',
formBlockTitleEn: 'Booking Details',
formBlockDesc: '将通过真实申请接口提交公共厨房预约需求。',
formBlockDescEn: 'Your shared kitchen booking will be submitted through the live application API.',
primaryActionText: '提交公共厨房预约',
primaryActionTextEn: 'Submit Kitchen Booking',
remarkPlaceholder: '请输入预约需求、人数和其他说明',
remarkPlaceholderEn: 'Enter the booking request, attendee count, and notes',
dateRangeLabel: '预约时间',
dateRangeLabelEn: 'Booking Time',
dateRangeStartLabel: '预约开始',
dateRangeStartLabelEn: 'Start Time',
dateRangeEndLabel: '预约结束',
dateRangeEndLabelEn: 'End Time',
dateRangeStartPlaceholder: '请选择开始时间',
dateRangeStartPlaceholderEn: 'Select start time',
dateRangeEndPlaceholder: '请选择结束时间',
dateRangeEndPlaceholderEn: 'Select end time',
showRoomTypeSelector: false,
showPreferenceSelectors: false,
showDateRange: true,
buildDefaultRemark(payload = {}) {
if ((payload.language || 'zh') === 'en') {
return 'I would like to book the shared kitchen. Please help confirm available time slots.'
}
return '我想预约公共厨房,请协助确认可用时段。'
}
},
'butler-contact': {
title: '联系管家',
titleEn: 'Contact Butler',
titleEnShort: 'Butler',
desc: '请填写需要协助的事项和补充说明,提交后由管家跟进处理。',
descEn: 'Fill in the request details and notes. Your butler will follow up after submission.',
formBlockTitle: '沟通信息',
formBlockTitleEn: 'Request Details',
formBlockDesc: '将通过真实申请接口提交沟通需求。',
formBlockDescEn: 'Your request will be submitted through the live application API.',
primaryActionText: '提交联系事项',
primaryActionTextEn: 'Submit Request',
remarkPlaceholder: '请输入希望联系管家的事项内容',
remarkPlaceholderEn: 'Enter what you would like help from your butler with',
showRoomTypeSelector: false,
showPreferenceSelectors: false,
showDateRange: false,
buildDefaultRemark(payload = {}) {
if ((payload.language || 'zh') === 'en') {
return 'I need to contact my butler for an in-stay request. Please assist as soon as possible.'
}
return '我需要联系管家处理住中事项,请尽快协助。'
}
}
}
export default {
components: {
TagSelector,
DateRangeSelector,
RoomTypeSelector
},
data() {
return {
sourceTitleZh: '',
sourceTitleEn: '',
sourceDescZh: '',
sourceDescEn: '',
rawSourceTitle: '',
rawSourceDesc: '',
sourcePayload: {},
isSubmitting: false,
dictLoading: false,
hobbyOptions: [],
lifestyleOptions: [],
form: createDefaultForm(),
scrollTop: 0,
lastAutoRemark: '',
i18n: {
zh: {
hobbyLabel: '兴趣爱好',
emptyHobbyTags: '暂无兴趣爱好标签',
lifestyleLabel: '生活习惯',
emptyLifestyleTags: '暂无生活习惯标签',
remarkLabel: '补充说明',
submitting: '提交中...',
roomTypeApplyTitle: '房型申请',
roomTypeApplyDesc: '请确认房型、区域、偏好标签和租期信息后提交申请。',
roomTypeApplyBlockTitle: '房型申请信息',
roomTypeApplyBlockDesc: '已接入真实申请单接口,支持带出房型与区域编码后直接提交。',
applyRemarkPlaceholder: '请输入您的申请补充说明',
submitRoomTypeApply: '提交房型申请',
applyRoomType: '申请房型',
leaseRange: '租期时间',
expectedCheckin: '预计入住',
expectedCheckout: '预计离开',
selectCheckinDate: '请选择入住日期',
selectCheckoutDate: '请选择离开日期',
currentRoom: '当前房间',
areaLabel: '所属区域',
referencePrice: '参考价格',
paymentMethod: '支付方式',
applicationType: '申请类型',
stayApplyDefaultTitle: '入住申请',
stayApplyDefaultDesc: '请填写您的入住意向、生活偏好与预计租期后提交申请单。',
sourceDefaultDesc: '请填写您的入住意向、租期和补充说明。',
tagLoadFailed: '偏好标签加载失败',
remarkRequired: '请输入补充说明',
applicationInfo: '申请信息',
selectDateStart: '请选择{label}',
selectDateEnd: '请选择{label}',
dateCannotBefore: '{endLabel}不能早于{startLabel}',
submitSuccess: '申请单提交成功',
submitFailed: '申请单提交失败'
},
en: {
hobbyLabel: 'Hobbies',
emptyHobbyTags: 'No hobby tags',
lifestyleLabel: 'Lifestyle Habits',
emptyLifestyleTags: 'No lifestyle tags',
remarkLabel: 'Additional Notes',
submitting: 'Submitting...',
roomTypeApplyTitle: 'Room Type Application',
roomTypeApplyDesc: 'Confirm the room type, area, preference tags, and lease period before submitting.',
roomTypeApplyBlockTitle: 'Application Details',
roomTypeApplyBlockDesc: 'The live application API is connected and can submit with room and area codes.',
applyRemarkPlaceholder: 'Enter your additional notes',
submitRoomTypeApply: 'Submit Application',
applyRoomType: 'Requested Room Type',
leaseRange: 'Lease Period',
expectedCheckin: 'Expected Check-in',
expectedCheckout: 'Expected Checkout',
selectCheckinDate: 'Select check-in date',
selectCheckoutDate: 'Select checkout date',
currentRoom: 'Current Room',
areaLabel: 'Area',
referencePrice: 'Reference Price',
paymentMethod: 'Payment Method',
applicationType: 'Application Type',
stayApplyDefaultTitle: 'Stay Application',
stayApplyDefaultDesc: 'Fill in your stay intention, preferences, and lease period before submission.',
sourceDefaultDesc: 'Fill in your stay intention, lease period, and notes.',
tagLoadFailed: 'Failed to load preference tags',
remarkRequired: 'Please enter additional notes',
applicationInfo: 'Application Details',
selectDateStart: 'Please select {label}',
selectDateEnd: 'Please select {label}',
dateCannotBefore: '{endLabel} cannot be earlier than {startLabel}',
submitSuccess: 'Application submitted',
submitFailed: 'Failed to submit application'
}
}
}
},
computed: {
language() {
return this.$getLanguage()
},
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)
},
businessType() {
return this.sourcePayload && this.sourcePayload.businessType ? this.sourcePayload.businessType : ''
},
businessConfig() {
return BUSINESS_TYPE_CONFIG[this.businessType] || null
},
isRoomTypeApply() {
return this.sourcePayload && this.sourcePayload.sourceType === 'area-room-type'
},
pageTitle() {
if (this.isRoomTypeApply) {
return this.language === 'en'
? compactEnglishText(this.t('roomTypeApplyTitle'), 22)
: this.t('roomTypeApplyTitle')
}
return this.getBusinessText('titleShort')
|| this.getBusinessText('title')
|| this.getLocalizedSourceText('title')
|| this.t('stayApplyDefaultTitle')
},
pageDesc() {
if (this.isRoomTypeApply) {
return this.language === 'en'
? compactEnglishText(this.t('roomTypeApplyDesc'), 96)
: this.t('roomTypeApplyDesc')
}
const text = this.getBusinessText('desc') || this.getLocalizedSourceText('desc') || this.t('stayApplyDefaultDesc')
return this.language === 'en' ? compactEnglishText(text, 96) : text
},
formBlockTitle() {
if (this.isRoomTypeApply) {
return this.t('roomTypeApplyBlockTitle')
}
return this.getBusinessText('formBlockTitle') || this.t('applicationInfo')
},
formBlockDesc() {
if (this.isRoomTypeApply) {
return this.t('roomTypeApplyBlockDesc')
}
return this.getBusinessText('formBlockDesc') || this.t('stayApplyDefaultDesc')
},
remarkPlaceholder() {
if (this.isRoomTypeApply) {
return this.t('applyRemarkPlaceholder')
}
return this.getBusinessText('remarkPlaceholder') || this.t('remarkRequired')
},
primaryActionText() {
if (this.isRoomTypeApply) {
return this.language === 'en'
? compactEnglishText(this.t('submitRoomTypeApply'), 24)
: this.t('submitRoomTypeApply')
}
const text = this.getBusinessText('primaryActionText') || this.t('submitApplicationFallback')
return this.language === 'en' ? compactEnglishText(text, 24) : text
},
roomTypeLabel() {
return this.getBusinessText('roomTypeLabel') || this.t('applyRoomType')
},
showRoomTypeSelector() {
if (this.isRoomTypeApply) {
return true
}
return this.businessConfig ? this.businessConfig.showRoomTypeSelector !== false : true
},
showPreferenceSelectors() {
if (this.isRoomTypeApply) {
return true
}
return this.businessConfig ? !!this.businessConfig.showPreferenceSelectors : true
},
showDateRange() {
if (this.isRoomTypeApply) {
return true
}
return this.businessConfig ? this.businessConfig.showDateRange !== false : true
},
dateRangeLabel() {
return this.getBusinessText('dateRangeLabel') || this.t('leaseRange')
},
dateRangeStartLabel() {
return this.getBusinessText('dateRangeStartLabel') || this.t('expectedCheckin')
},
dateRangeEndLabel() {
return this.getBusinessText('dateRangeEndLabel') || this.t('expectedCheckout')
},
dateRangeStartPlaceholder() {
return this.getBusinessText('dateRangeStartPlaceholder') || this.t('selectCheckinDate')
},
dateRangeEndPlaceholder() {
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: {
language() {
this.syncLocalizedDraftContent()
this.loadDictOptions()
}
},
onLoad(options) {
this.sourceTitleZh = this.decodeRouteText(options && (options.titleZh || options.title), '')
this.sourceTitleEn = this.decodeRouteText(options && options.titleEn, '')
this.sourceDescZh = this.decodeRouteText(options && (options.descZh || options.desc), '')
this.sourceDescEn = this.decodeRouteText(options && options.descEn, '')
this.rawSourceTitle = this.decodeRouteText(options && options.title, '')
this.rawSourceDesc = this.decodeRouteText(options && options.desc, '')
if (options.payload) {
Promise.resolve(options.payload)
.then(decodeURIComponent)
.then(JSON.parse)
.then((payload) => {
this.sourcePayload = payload && typeof payload === 'object' ? payload : {}
})
.catch((error) => {
console.error('parse submit payload error', error)
this.sourcePayload = {}
})
.finally(() => {
this.initFormFromSource()
this.loadDictOptions()
})
return
}
this.initFormFromSource()
this.loadDictOptions()
},
methods: {
t(key, params) {
return this.$tm('pkgService.submit_request.submit_request', key, params)
},
decodeRouteText(value, fallback = '') {
if (value === undefined || value === null || value === '') {
return fallback
}
try {
return decodeURIComponent(String(value))
} catch (error) {
return String(value)
}
},
getLocalizedSourceText(type) {
if (type === 'title') {
if (this.language === 'en') {
return this.sourceTitleEn || this.sourceTitleZh || this.rawSourceTitle || ''
}
return this.sourceTitleZh || this.sourceTitleEn || this.rawSourceTitle || ''
}
if (this.language === 'en') {
return this.sourceDescEn || this.sourceDescZh || this.rawSourceDesc || ''
}
return this.sourceDescZh || this.sourceDescEn || this.rawSourceDesc || ''
},
getBusinessText(field) {
if (!this.businessConfig) {
return ''
}
const fieldKey = this.language === 'en'
? `${field}En${field.endsWith('Short') ? '' : ''}`
: field
if (this.language === 'en' && field === 'titleShort') {
return this.businessConfig.titleEnShort || this.businessConfig.titleEn || this.businessConfig.title || ''
}
return this.businessConfig[fieldKey] || this.businessConfig[field] || ''
},
handleScroll(e) {
this.scrollTop = e.detail.scrollTop
},
buildDefaultRemarkFromSource() {
const roomTypeName = this.language === 'en'
? (this.sourcePayload.roomTypeNameEn || this.sourcePayload.roomTypeName || '')
: (this.sourcePayload.roomTypeName || '')
const roomName = this.language === 'en'
? (this.sourcePayload.roomNameEn || this.sourcePayload.roomName || '')
: (this.sourcePayload.roomName || '')
const bannerTitle = this.language === 'en'
? (this.sourcePayload.bannerTitleEn || this.sourcePayload.bannerTitle || '')
: (this.sourcePayload.bannerTitle || '')
if (roomTypeName) {
return this.language === 'en'
? `I plan to apply for the room type "${roomTypeName}". Please help confirm the application requirements, pricing, and next steps.`
: `我计划申请房型“${roomTypeName}”,请协助确认申请条件、价格和后续安排。`
}
if (roomName) {
return this.language === 'en'
? `I plan to apply for the room "${roomName}". Please help confirm the move-in process and review requirements.`
: `我计划申请房源“${roomName}”,请协助确认入住流程和审核要求。`
}
if (bannerTitle) {
return this.language === 'en'
? `I would like to submit a stay application related to "${bannerTitle}".`
: `我想提交“${bannerTitle}”相关房源的入住申请。`
}
if (this.businessConfig && typeof this.businessConfig.buildDefaultRemark === 'function') {
return this.businessConfig.buildDefaultRemark({
...this.sourcePayload,
language: this.language
})
}
return ''
},
syncLocalizedDraftContent() {
const nextAutoRemark = this.buildDefaultRemarkFromSource()
if (!this.form || typeof this.form !== 'object') {
this.lastAutoRemark = nextAutoRemark
return
}
const currentRemark = String(this.form.remark || '')
const shouldSyncRemark = !currentRemark || currentRemark === this.lastAutoRemark
if (shouldSyncRemark) {
this.form.remark = nextAutoRemark
}
this.lastAutoRemark = nextAutoRemark
},
initFormFromSource() {
const defaultForm = createDefaultForm()
this.form = {
...defaultForm,
intendedAreaCode: this.sourcePayload.areaCode || '',
intendedRoomTypeCode: this.sourcePayload.roomTypeCode || '',
remark: '',
start: this.sourcePayload.start || defaultForm.start,
end: this.sourcePayload.end || ''
}
// If we don't have a roomTypeCode but we have an areaCode,
// we still pass it down so RoomTypeSelector can select the current store/area
if (this.sourcePayload.areaCode && !this.sourcePayload.roomTypeCode) {
this.form.intendedAreaCode = this.sourcePayload.areaCode
}
this.form.remark = this.buildDefaultRemarkFromSource()
this.lastAutoRemark = this.form.remark
},
handleRoomTypeChange({ area, roomType }) {
if (area && area.code) {
this.form.intendedAreaCode = area.code
}
if (roomType) {
// 如果用户主动切换了房型,可以动态更新页面上的信息
if (!this.sourcePayload) this.sourcePayload = {}
this.sourcePayload.roomTypeCode = roomType.code
this.sourcePayload.roomTypeName = roomType.name
this.sourcePayload.roomTypeNameEn = roomType.nameEn || ''
if (area) {
this.sourcePayload.areaCode = area.code
this.sourcePayload.areaName = area.name
this.sourcePayload.areaNameEn = area.nameEn || ''
}
if (roomType.price) {
this.sourcePayload.price = roomType.price
}
}
},
loadDictOptions() {
this.dictLoading = true
return Promise.all([
sysdictPage({ Type: 'UserHobbiesAndInterestsTag', PageIndex: 1, PageSize: 100 }, this.language),
sysdictPage({ Type: 'UserLifestyleHabitsTag', PageIndex: 1, PageSize: 100 }, this.language)
])
.then(([hobbyRes, lifestyleRes]) => {
this.hobbyOptions = normalizeDictList(hobbyRes, this.language, 'hobbies')
this.lifestyleOptions = normalizeDictList(lifestyleRes, this.language, 'lifestyle')
this.initFormTagsFromUser()
})
.catch((error) => {
console.error('load application dict options failed', error)
uni.showToast({
title: this.t('tagLoadFailed'),
icon: 'none'
})
})
.finally(() => {
this.dictLoading = false
})
},
initFormTagsFromUser() {
const user = getStoredUser()
const authProfile = user && user.authProfile ? user.authProfile : {}
if (!this.form.hobbiesAndInterestsTagKeys.length) {
this.form.hobbiesAndInterestsTagKeys = this.matchTagKeys(authProfile.hobbyTags, this.hobbyOptions)
}
if (!this.form.lifestyleHabitsTagKeys.length) {
this.form.lifestyleHabitsTagKeys = this.matchTagKeys(authProfile.dietaryHabitTags, this.lifestyleOptions)
}
},
matchTagKeys(tagList = [], options = []) {
if (!Array.isArray(tagList) || !Array.isArray(options) || !options.length) {
return []
}
return tagList
.map(item => {
const rawKey = item && (item.key || item.code || item.value || '')
const matched = options.find(option => {
return String(option.key) === String(rawKey) || option.value === rawKey
})
return matched ? matched.key : ''
})
.filter(Boolean)
},
validateForm() {
if (this.showDateRange) {
if (!this.form.start) {
return this.t('selectDateStart', { label: this.dateRangeStartLabel })
}
if (!this.form.end) {
return this.t('selectDateEnd', { label: this.dateRangeEndLabel })
}
if (new Date(this.form.end).getTime() < new Date(this.form.start).getTime()) {
return this.t('dateCannotBefore', {
endLabel: this.dateRangeEndLabel,
startLabel: this.dateRangeStartLabel
})
}
}
if (!this.form.remark.trim()) {
return this.t('remarkRequired')
}
return ''
},
buildSubmitPayload() {
return {
intendedAreaCode: this.form.intendedAreaCode || null,
intendedRoomTypeCode: this.form.intendedRoomTypeCode || null,
roomSlotHeight: Number(this.form.roomSlotHeight),
hobbiesAndInterestsTagKeys: this.showPreferenceSelectors && this.form.hobbiesAndInterestsTagKeys.length ? this.form.hobbiesAndInterestsTagKeys : null,
lifestyleHabitsTagKeys: this.showPreferenceSelectors && this.form.lifestyleHabitsTagKeys.length ? this.form.lifestyleHabitsTagKeys : null,
remark: this.form.remark.trim() || null,
start: this.showDateRange ? normalizeDateTime(this.form.start, false) : null,
end: this.showDateRange ? normalizeDateTime(this.form.end, true) : null
}
},
submitRequest() {
if (this.isSubmitting) {
return
}
const errorMessage = this.validateForm()
if (errorMessage) {
uni.showToast({
title: errorMessage,
icon: 'none'
})
return
}
this.isSubmitting = true
uni.showLoading({
title: this.t('submitting')
})
const payload = this.buildSubmitPayload()
return submitApplicationForm(payload)
.then((response) => {
const applicationCode = extractSubmitApplicationCode(response)
if (applicationCode) {
saveActiveApplicationCode(applicationCode)
}
uni.hideLoading()
uni.showToast({
title: this.t('submitSuccess'),
icon: 'success'
})
setTimeout(() => {
saveHomePageTabId('my-application')
uni.reLaunch({
url: '/pages/index/index?pageId=my-application'
})
}, 800)
})
.catch((error) => {
console.error('submit application form failed', error)
uni.hideLoading()
uni.showToast({
title: (error && (error.message || error.msg)) || this.t('submitFailed'),
icon: 'none'
})
})
.finally(() => {
this.isSubmitting = false
})
}
}
}
</script>
<style scoped lang="scss">
.submit-request-page {
min-height: 100vh;
background: linear-gradient(180deg, #F0F6FF 0%, #F8FAFF 100%);
position: relative;
}
.page-scroll {
width: 100%;
height: 100vh;
}
.page-content {
position: relative;
z-index: 1;
}
/* 毛玻璃导航栏覆盖样式 */
.glass-nav-bar ::v-deep .tn-nav-bar {
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
}
.form-card {
background: #FFFFFF;
border-radius: 24rpx;
padding: 36rpx;
box-shadow: 0 8rpx 20rpx rgba(15, 23, 42, 0.04);
border: 1px solid rgba($brand-primary, 0.05);
}
.hero-header {
padding: 20rpx 10rpx;
}
.hero-badge {
display: inline-flex;
align-items: center;
padding: 8rpx 20rpx 8rpx 14rpx;
background: rgba($brand-primary, 0.08);
border-radius: 100rpx;
margin-bottom: 24rpx;
}
.badge-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: $brand-primary;
margin-right: 12rpx;
box-shadow: 0 0 8rpx rgba($brand-primary, 0.4);
}
.hero-badge text:not(.badge-dot) {
font-size: 20rpx;
color: $brand-primary;
font-weight: 700;
letter-spacing: 2rpx;
}
.hero-title {
font-size: 44rpx;
font-weight: 700;
color: #1A2233;
letter-spacing: 2rpx;
margin-bottom: 16rpx;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.block-title {
font-size: 32rpx;
font-weight: 700;
color: #1A2233;
letter-spacing: 1rpx;
}
.block-subtitle {
margin-top: 12rpx;
color: #6A7A99;
font-size: 24rpx;
line-height: 1.6;
}
.hero-desc,
.form-label,
.empty-text {
color: #6A7A99;
}
.hero-desc {
font-size: 28rpx;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.form-label {
font-size: 26rpx;
font-weight: 600;
margin-bottom: 20rpx;
color: #1A2233;
}
.form-input,
.form-textarea {
width: 100%;
box-sizing: border-box;
background: #FFFFFF;
border-radius: 20rpx;
padding: 26rpx 32rpx;
border: 1rpx solid rgba($brand-primary, 0.15);
font-size: 28rpx;
color: #1A2233;
transition: all 0.3s ease;
box-shadow: 0 4rpx 12rpx rgba($brand-primary, 0.05);
}
.form-textarea:focus {
border: 1rpx solid $brand-primary;
background: #FFFFFF;
box-shadow: 0 0 0 6rpx rgba($brand-primary, 0.1);
}
.form-textarea {
height: 200rpx;
line-height: 1.5;
}
.submit-btn {
width: 100%;
height: 96rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, rgba($brand-primary, 0.9) 0%, $brand-primary 100%);
color: #FFFFFF;
box-shadow: 0 12rpx 32rpx rgba($brand-primary, 0.25);
font-size: 30rpx;
letter-spacing: 2rpx;
}
.btn-primary:active {
transform: scale(0.98);
}
.submit-btn--disabled {
opacity: 0.6;
pointer-events: none;
}
.source-row {
padding: 24rpx 0;
border-bottom: 1px dashed rgba($brand-primary, 0.15);
}
.source-row:last-child {
border-bottom: none;
padding-bottom: 8rpx;
}
.source-row__label {
color: #6A7A99;
font-size: 26rpx;
}
.source-row__value {
color: #1A2233;
font-size: 26rpx;
font-weight: 500;
text-align: right;
flex: 1;
min-width: 0;
margin-left: 24rpx;
word-break: break-word;
}
</style>