Files
medical-mall/pages/mall/consumer/home-service/service-detail.uvue

1392 lines
40 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="booking-page-shell">
<view class="detail-fixed-header">
<ServicePageHeader title="康养预约" fallback-url="/pages/mall/consumer/home-service/index"></ServicePageHeader>
</view>
<view class="detail-header-placeholder" :style="{ height: headerHeight + 'px' }"></view>
<scroll-view class="detail-scroll" scroll-y="true">
<view class="summary-card">
<view class="summary-top-row">
<view class="summary-cover">
<text class="summary-cover-text">{{ serviceImageText }}</text>
</view>
<view class="summary-main">
<text class="summary-title">{{ serviceTitle }}</text>
<text class="summary-desc">{{ serviceSubtitle }}</text>
<text class="summary-meta">服务时长:{{ serviceDuration }}</text>
<text class="summary-meta">有效期:{{ serviceValidity }}</text>
</view>
</view>
<view class="summary-tag-row">
<text v-for="tag in summaryGuaranteeTags" :key="tag.id" class="summary-tag">{{ tag.label }}</text>
</view>
<view class="summary-price-row">
<text class="summary-price-prefix">¥</text>
<text class="summary-price">{{ minPriceText }}</text>
<text class="summary-price-unit">起</text>
</view>
</view>
<ServicePanel title="Step1 服务地址" subtitle="确认上门地址、楼层与入户条件。">
<view class="service-address-card" @click="selectAddress">
<view class="service-address-main">
<text class="service-address-title">服务地址</text>
<view v-if="selectedAddress != null" class="service-address-detail">
<view class="service-address-contact-row">
<text class="service-address-contact">{{ selectedAddress.contactName }}</text>
<text class="service-address-phone">{{ getSelectedAddressPhone() }}</text>
</view>
<text class="service-address-full">{{ selectedAddress.fullAddress }}</text>
<text v-if="selectedAddress.remark != ''" class="service-address-remark">备注:{{ selectedAddress.remark }}</text>
</view>
<text v-else class="service-address-placeholder">请选择服务地址</text>
</view>
<text class="service-address-arrow">></text>
</view>
<view class="form-grid">
<view class="form-grid-item">
<text class="label">所在区域</text>
<input v-model="careArea" class="input" placeholder="请输入区域" />
</view>
<view class="form-grid-item">
<text class="label">楼层</text>
<input v-model="careFloor" class="input" placeholder="如 6 楼" />
</view>
</view>
<view class="form-item form-item-last">
<text class="label">是否有电梯</text>
<view class="pill-row">
<view :class="['pill', careHasElevator ? 'pill-active' : '']" @click="setElevator(true)">
<text :class="['pill-text', careHasElevator ? 'pill-text-active' : '']">有电梯</text>
</view>
<view :class="['pill', !careHasElevator ? 'pill-active' : '']" @click="setElevator(false)">
<text :class="['pill-text', !careHasElevator ? 'pill-text-active' : '']">无电梯</text>
</view>
</view>
</view>
</ServicePanel>
<ServicePanel title="Step2 服务机构 / 服务人员" subtitle="当前优先展示平台推荐机构,后续可接入真实选择接口。">
<view class="agency-card">
<view class="agency-top-row">
<view>
<text class="agency-name">{{ agency.name }}</text>
<text class="agency-meta">{{ agency.distance }} 评分 {{ agency.rating }}</text>
</view>
<text class="agency-badge">平台认证</text>
</view>
<text class="agency-desc">{{ agency.summary }}</text>
<text class="agency-desc">服务资质:护理员上门、服务留痕、预约前电话确认</text>
<view class="action-row">
<view class="ghost-btn" @click="handleAgencyReplace"><text class="ghost-btn-text">更换机构</text></view>
<view class="ghost-btn" @click="handleAgencyContact"><text class="ghost-btn-text">立即联系</text></view>
<view class="ghost-btn" @click="handleAgencyNavigate"><text class="ghost-btn-text">立即导航</text></view>
</view>
</view>
</ServicePanel>
<ServicePanel title="Step3 上门时间" subtitle="先选日期和时间段,再提交预约。">
<scroll-view class="booking-day-scroll" direction="horizontal" :show-scrollbar="false">
<view class="booking-day-row">
<view
v-for="item in bookingDays"
:key="item.id"
:class="['booking-day-card', selectedDayId == item.id ? 'booking-day-card-active' : '']"
@click="selectDay(item.id)"
>
<text :class="['booking-day-label', selectedDayId == item.id ? 'booking-day-label-active' : '']">{{ item.label }}</text>
<text :class="['booking-day-date', selectedDayId == item.id ? 'booking-day-date-active' : '']">{{ item.dateText }}</text>
<text :class="['booking-day-weekday', selectedDayId == item.id ? 'booking-day-weekday-active' : '']">{{ item.weekday }}</text>
</view>
</view>
</scroll-view>
<view class="booking-slot-grid">
<view
v-for="item in bookingSlots"
:key="item.id"
:class="['booking-slot-card', selectedSlotId == item.id ? 'booking-slot-card-active' : '', item.available ? '' : 'booking-slot-card-disabled']"
@click="selectSlot(item.id, item.available)"
>
<text :class="['booking-slot-label', selectedSlotId == item.id ? 'booking-slot-label-active' : '']">{{ item.label }}</text>
</view>
</view>
</ServicePanel>
<ServicePanel title="Step4 服务对象信息" subtitle="补全被照护人情况,便于机构判断服务方案。">
<view class="form-grid">
<view class="form-grid-item">
<text class="label">服务对象姓名</text>
<input v-model="recipientName" class="input" placeholder="请输入姓名" />
</view>
<view class="form-grid-item">
<text class="label">年龄</text>
<input v-model="recipientAgeText" class="input" type="number" placeholder="请输入年龄" />
</view>
</view>
<view class="form-grid">
<view class="form-grid-item">
<text class="label">性别</text>
<view class="pill-row">
<view :class="['pill', recipientGender == '男' ? 'pill-active' : '']" @click="setRecipientGender('男')">
<text :class="['pill-text', recipientGender == '男' ? 'pill-text-active' : '']">男</text>
</view>
<view :class="['pill', recipientGender == '女' ? 'pill-active' : '']" @click="setRecipientGender('女')">
<text :class="['pill-text', recipientGender == '女' ? 'pill-text-active' : '']">女</text>
</view>
</view>
</view>
<view class="form-grid-item">
<text class="label">与下单人关系</text>
<input v-model="recipientRelation" class="input" placeholder="如 母亲 / 父亲 / 本人" />
</view>
</view>
<view class="form-item">
<text class="label">身体情况</text>
<textarea v-model="recipientHealthStatus" class="textarea" placeholder="请输入身体情况,如术后恢复、慢病随访、行动不便"></textarea>
</view>
<view class="form-item">
<text class="label">行动能力</text>
<input v-model="recipientMobilityLevel" class="input" placeholder="如 需要搀扶 / 轮椅出行 / 可自理" />
</view>
<view class="form-item">
<text class="label">风险标签</text>
<view class="tag-row">
<view
v-for="item in careRiskOptions"
:key="item.id"
:class="['tag-chip', item.selected ? 'tag-chip-active' : '']"
@click="toggleRiskOption(item.id)"
>
<text :class="['tag-chip-text', item.selected ? 'tag-chip-text-active' : '']">{{ item.label }}</text>
</view>
</view>
</view>
<view class="form-item form-item-last">
<text class="label">家属陪同</text>
<view class="pill-row">
<view :class="['pill', recipientNeedFamilyPresent ? 'pill-active' : '']" @click="setFamilyPresent(true)">
<text :class="['pill-text', recipientNeedFamilyPresent ? 'pill-text-active' : '']">需要家属在场</text>
</view>
<view :class="['pill', !recipientNeedFamilyPresent ? 'pill-active' : '']" @click="setFamilyPresent(false)">
<text :class="['pill-text', !recipientNeedFamilyPresent ? 'pill-text-active' : '']">可独立服务</text>
</view>
</view>
</view>
</ServicePanel>
<ServicePanel title="Step5 联系人信息" subtitle="保留现有提交流程所需联系人字段。">
<view class="form-grid">
<view class="form-grid-item">
<text class="label">联系人姓名</text>
<input v-model="contactName" class="input" placeholder="请输入联系人姓名" />
</view>
<view class="form-grid-item">
<text class="label">联系电话</text>
<input v-model="contactPhone" class="input" type="number" placeholder="请输入联系电话" />
</view>
</view>
<view class="form-item">
<text class="label">性别</text>
<view class="pill-row">
<view :class="['pill', contactGender == '先生' ? 'pill-active' : '']" @click="setContactGender('先生')">
<text :class="['pill-text', contactGender == '先生' ? 'pill-text-active' : '']">先生</text>
</view>
<view :class="['pill', contactGender == '女士' ? 'pill-active' : '']" @click="setContactGender('女士')">
<text :class="['pill-text', contactGender == '女士' ? 'pill-text-active' : '']">女士</text>
</view>
</view>
</view>
<view class="form-grid">
<view class="form-grid-item">
<text class="label">紧急联系人</text>
<input v-model="emergencyName" class="input" placeholder="请输入紧急联系人姓名" />
</view>
<view class="form-grid-item">
<text class="label">紧急联系人电话</text>
<input v-model="emergencyPhone" class="input" type="number" placeholder="请输入紧急联系人电话" />
</view>
</view>
<view class="form-item form-item-last">
<text class="label">补充备注</text>
<textarea v-model="remarkText" class="textarea" placeholder="如老人作息、注意事项、上门前沟通重点"></textarea>
</view>
</ServicePanel>
<ServicePanel title="Step6 服务需求" subtitle="选择服务方案和重点需求,仍然兼容现有申请接口。">
<view class="package-list">
<view
v-for="item in servicePackages"
:key="item.id"
:class="['package-card', item.selected ? 'package-card-active' : '']"
@click="selectServicePackage(item.id)"
>
<view class="package-top-row">
<text :class="['package-name', item.selected ? 'package-name-active' : '']">{{ item.name }}</text>
<text class="package-price">¥{{ formatPrice(item.price) }}</text>
</view>
<text class="package-desc">{{ item.desc }}</text>
<text class="package-duration">{{ item.duration }}</text>
</view>
</view>
<view class="form-item">
<text class="label">需求标签</text>
<view class="tag-row">
<view
v-for="item in careNeedOptions"
:key="item.id"
:class="['tag-chip', item.selected ? 'tag-chip-active' : '']"
@click="toggleCareNeedOption(item.id)"
>
<text :class="['tag-chip-text', item.selected ? 'tag-chip-text-active' : '']">{{ item.label }}</text>
</view>
</view>
</view>
<view class="form-item form-item-last">
<text class="label">详细需求</text>
<textarea v-model="demandDetailText" class="textarea" placeholder="请输入更详细的服务需求,如血压监测频次、陪诊时间、康复重点"></textarea>
</view>
</ServicePanel>
<ServicePanel title="服务说明与保障" subtitle="展示适用人群、免责项与平台保障信息。">
<view class="info-item">
<text class="info-label">适用人群</text>
<text class="info-value">{{ serviceSuitableFor }}</text>
</view>
<view class="info-item">
<text class="info-label">不包含项目</text>
<text class="info-value">{{ serviceExcludeText }}</text>
</view>
<view class="tag-row tag-row-last">
<text v-for="item in guaranteeTags" :key="item.id" class="summary-tag">{{ item.label }}</text>
<text class="summary-tag">上门前确认</text>
</view>
</ServicePanel>
<view class="detail-page-bottom-space"></view>
</scroll-view>
<view class="booking-bottom-bar">
<view>
<text class="booking-bottom-price">¥{{ minPriceText }} 起</text>
<text class="booking-bottom-time">{{ selectedTimeText }}</text>
</view>
<view class="booking-submit-btn" @click="submitBooking">
<text class="booking-submit-btn-text">立即预约</text>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import ServicePageHeader from '@/components/homeService/ServicePageHeader.uvue'
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
import { createHomeServiceApplication, fetchHomeServiceCatalog } from '@/services/homeServiceService.uts'
import { HomeServiceApplicationDraftType, HomeServiceCatalogType, HomeServiceSelectedAddressType } from '@/types/home-service.uts'
import { getCurrentUser, getCurrentUserId } from '@/utils/store.uts'
import { goToLogin } from '@/utils/utils.uts'
import {
BookingDayOptionType,
BookingTimeSlotType,
HomeServiceAgencyType,
HomeServiceGuaranteeItemType,
getBookingDayOptions,
getBookingTimeSlots,
getHomeServiceItems,
getRecommendedAgency,
getServiceExcludes,
getServiceGuarantees
} from '@/utils/homeServiceUiMock.uts'
type CareRecipientInfoType = {
name: string
age: number
gender: string
relation: string
healthStatus: string
mobilityLevel: string
needFamilyPresent: boolean
}
type CareContactInfoType = {
contactName: string
contactPhone: string
contactGender: string
emergencyName: string
emergencyPhone: string
}
type CareAddressInfoType = {
address: string
detailAddress: string
area: string
floor: string
hasElevator: boolean
}
type CareNeedOptionType = {
id: string
label: string
selected: boolean
}
type ServicePackageOptionType = {
id: string
name: string
desc: string
price: number
duration: string
selected: boolean
}
type CareRiskOptionType = {
id: string
label: string
selected: boolean
}
const serviceId = ref('svc-001')
const headerHeight = ref(116)
const serviceTitle = ref('基础上门护理')
const serviceSubtitle = ref('覆盖生命体征监测、基础照护、风险提醒。')
const servicePrice = ref(168)
const serviceDuration = ref('约 2 小时')
const serviceValidity = ref('预约后 30 天内可服务')
const serviceSuitableFor = ref('行动不便、术后恢复、慢病随访老人')
const serviceImageText = ref('照护')
const serviceExcludeText = ref('高风险处置、住院陪护、急诊陪诊')
const bookingDays = ref<Array<BookingDayOptionType>>([])
const bookingSlots = ref<Array<BookingTimeSlotType>>([])
const guaranteeTags = ref<Array<HomeServiceGuaranteeItemType>>([])
const agency = ref<HomeServiceAgencyType>({
id: 'agency-001',
name: '梅江居家护理服务中心',
distance: '距您 1.2km',
rating: '4.9',
summary: '提供基础照护、上门护理和长者陪伴服务。'
})
const defaultCareRecipient: CareRecipientInfoType = {
name: '李奶奶',
age: 78,
gender: '女',
relation: '母亲',
healthStatus: '行动不便,需基础照护与血压监测',
mobilityLevel: '需要搀扶',
needFamilyPresent: true
}
const defaultCareContact: CareContactInfoType = {
contactName: '李晓兰',
contactPhone: '13800138000',
contactGender: '女士',
emergencyName: '李先生',
emergencyPhone: '13900139000'
}
const defaultCareAddress: CareAddressInfoType = {
address: '梅州市梅江区学海路 18 号',
detailAddress: '2 栋 602',
area: '梅江区',
floor: '6 楼',
hasElevator: true
}
const careNeedOptions = ref<Array<CareNeedOptionType>>([])
const careRiskOptions = ref<Array<CareRiskOptionType>>([])
const servicePackages = ref<Array<ServicePackageOptionType>>([])
const selectedDayId = ref('day-1')
const selectedSlotId = ref('slot-1')
const selectedAddress = ref<HomeServiceSelectedAddressType | null>(null)
const careAddress = ref(defaultCareAddress.address)
const careDetailAddress = ref(defaultCareAddress.detailAddress)
const careArea = ref(defaultCareAddress.area)
const careFloor = ref(defaultCareAddress.floor)
const careHasElevator = ref(defaultCareAddress.hasElevator)
const recipientName = ref(defaultCareRecipient.name)
const recipientAgeText = ref(defaultCareRecipient.age.toString())
const recipientGender = ref(defaultCareRecipient.gender)
const recipientRelation = ref(defaultCareRecipient.relation)
const recipientHealthStatus = ref(defaultCareRecipient.healthStatus)
const recipientMobilityLevel = ref(defaultCareRecipient.mobilityLevel)
const recipientNeedFamilyPresent = ref(defaultCareRecipient.needFamilyPresent)
const contactName = ref(defaultCareContact.contactName)
const contactPhone = ref(defaultCareContact.contactPhone)
const contactGender = ref(defaultCareContact.contactGender)
const emergencyName = ref(defaultCareContact.emergencyName)
const emergencyPhone = ref(defaultCareContact.emergencyPhone)
const remarkText = ref('老人需要基础照护与血压监测。')
const demandDetailText = ref('请优先安排上午上门,家属希望同步了解老人近期血压情况。')
try {
const windowInfo = uni.getWindowInfo()
const statusBar = windowInfo.statusBarHeight
const unit = windowInfo.windowWidth / 750
headerHeight.value = statusBar + Math.round(124 * unit)
} catch (error) {
headerHeight.value = 116
}
function buildDefaultNeedOptions(currentServiceId: string): Array<CareNeedOptionType> {
if (currentServiceId == 'svc-002') {
return [
{ id: 'rehab', label: '康复指导', selected: true },
{ id: 'basic_care', label: '基础照护', selected: false },
{ id: 'blood_pressure', label: '血压监测', selected: false },
{ id: 'medicine', label: '用药提醒', selected: false },
{ id: 'escort', label: '陪诊陪护', selected: false },
{ id: 'chronic', label: '慢病随访', selected: true }
]
}
if (currentServiceId == 'svc-003') {
return [
{ id: 'basic_care', label: '基础照护', selected: false },
{ id: 'blood_pressure', label: '血压监测', selected: true },
{ id: 'medicine', label: '用药提醒', selected: true },
{ id: 'rehab', label: '康复指导', selected: false },
{ id: 'escort', label: '陪诊陪护', selected: false },
{ id: 'chronic', label: '慢病随访', selected: true }
]
}
return [
{ id: 'basic_care', label: '基础照护', selected: true },
{ id: 'blood_pressure', label: '血压监测', selected: true },
{ id: 'medicine', label: '用药提醒', selected: false },
{ id: 'rehab', label: '康复指导', selected: false },
{ id: 'escort', label: '陪诊陪护', selected: false },
{ id: 'chronic', label: '慢病随访', selected: true }
]
}
function buildDefaultRiskOptions(currentServiceId: string): Array<CareRiskOptionType> {
if (currentServiceId == 'svc-002') {
return [
{ id: 'post_op', label: '术后恢复', selected: true },
{ id: 'fall', label: '跌倒风险', selected: true },
{ id: 'chronic', label: '慢病管理', selected: false }
]
}
if (currentServiceId == 'svc-003') {
return [
{ id: 'post_op', label: '术后恢复', selected: false },
{ id: 'fall', label: '跌倒风险', selected: false },
{ id: 'chronic', label: '慢病管理', selected: true }
]
}
return [
{ id: 'post_op', label: '术后恢复', selected: false },
{ id: 'fall', label: '跌倒风险', selected: true },
{ id: 'chronic', label: '慢病管理', selected: true }
]
}
function buildDefaultPackages(currentServiceId: string, basePrice: number, durationText: string): Array<ServicePackageOptionType> {
// TODO: 后续接入真实服务套餐与机构报价接口,当前先使用页面内 mock 方案。
if (currentServiceId == 'svc-002') {
return [
{ id: 'pkg_rehab_assess', name: '康复基础评估', desc: '动作评估、居家训练建议、康复重点说明', price: Math.max(basePrice - 30, 128), duration: '约 1.5 小时', selected: true },
{ id: 'pkg_rehab_plus', name: '上门康复训练', desc: '动作陪练、训练纠正、阶段性康复记录', price: basePrice, duration: durationText, selected: false },
{ id: 'pkg_rehab_follow', name: '连续康复计划', desc: '适合术后恢复阶段,支持持续跟踪与复盘', price: basePrice + 58, duration: '约 2.5 小时', selected: false }
]
}
if (currentServiceId == 'svc-003') {
return [
{ id: 'pkg_chronic_basic', name: '慢病基础随访', desc: '血压血糖记录、用药核对、风险提醒', price: Math.max(basePrice - 20, 98), duration: '约 1 小时', selected: true },
{ id: 'pkg_chronic_plus', name: '慢病加强随访', desc: '增加生活方式建议和家属宣教内容', price: basePrice, duration: durationText, selected: false },
{ id: 'pkg_chronic_family', name: '家庭联合随访', desc: '适合需要家属共同参与的连续照护场景', price: basePrice + 36, duration: '约 1.5 小时', selected: false }
]
}
return [
{ id: 'pkg_basic_home', name: '基础照护', desc: '生命体征观察、协助起居、用药提醒', price: Math.max(basePrice - 20, 148), duration: '约 1.5 小时', selected: true },
{ id: 'pkg_basic_nursing', name: '标准照护', desc: '适合日常陪护与护理观察,支持家属沟通', price: basePrice, duration: durationText, selected: false },
{ id: 'pkg_basic_plus', name: '加强照护', desc: '增加翻身协助、环境提醒和风险沟通', price: basePrice + 48, duration: '约 2.5 小时', selected: false }
]
}
const addressLineText = computed((): string => {
if (selectedAddress.value != null && selectedAddress.value.fullAddress != '') {
return selectedAddress.value.fullAddress
}
const fullAddress = careAddress.value + ' ' + careDetailAddress.value
return fullAddress.trim() != '' ? fullAddress : '请选择服务地址'
})
function applySelectedAddress(address: HomeServiceSelectedAddressType | null): void {
selectedAddress.value = address
if (address == null) {
careAddress.value = ''
careDetailAddress.value = ''
return
}
const locationAddress = address.locationAddress != null && address.locationAddress != '' ? address.locationAddress : address.addressDetail
const doorNo = address.doorNo != null && address.doorNo != '' ? address.doorNo : address.houseNumber
careAddress.value = locationAddress
careDetailAddress.value = doorNo
if (contactName.value == '') {
contactName.value = address.contactName
}
const phoneText = address.phone != null && address.phone != '' ? address.phone : address.contactPhone
if (contactPhone.value == '') {
contactPhone.value = phoneText
}
}
function getSelectedAddressPhone(): string {
if (selectedAddress.value == null) {
return ''
}
if (selectedAddress.value.phone != null && selectedAddress.value.phone != '') {
return selectedAddress.value.phone
}
return selectedAddress.value.contactPhone
}
function loadCachedSelectedAddress(): void {
try {
const cachedAddress = uni.getStorageSync('hss_selected_service_address') as HomeServiceSelectedAddressType | null
if (cachedAddress != null && cachedAddress.fullAddress != '') {
applySelectedAddress(cachedAddress)
return
}
} catch (error) {
console.error('读取服务地址缓存失败', error)
}
applySelectedAddress(null)
}
const selectedTimeText = computed((): string => {
let selectedDayLabel = ''
for (let i = 0; i < bookingDays.value.length; i++) {
if (bookingDays.value[i].id == selectedDayId.value) {
selectedDayLabel = bookingDays.value[i].label + ' ' + bookingDays.value[i].dateText
break
}
}
let selectedSlotLabel = ''
for (let i = 0; i < bookingSlots.value.length; i++) {
if (bookingSlots.value[i].id == selectedSlotId.value) {
selectedSlotLabel = bookingSlots.value[i].label
break
}
}
if (selectedDayLabel == '' || selectedSlotLabel == '') {
return '请选择上门时间'
}
return selectedDayLabel + ' ' + selectedSlotLabel
})
const minPriceValue = computed((): number => {
let minPrice = servicePrice.value
for (let i = 0; i < servicePackages.value.length; i++) {
if (i == 0 || servicePackages.value[i].price < minPrice) {
minPrice = servicePackages.value[i].price
}
}
return minPrice
})
const minPriceText = computed((): string => {
return formatPrice(minPriceValue.value)
})
const summaryGuaranteeTags = computed((): Array<HomeServiceGuaranteeItemType> => {
const result: Array<HomeServiceGuaranteeItemType> = []
for (let i = 0; i < guaranteeTags.value.length && result.length < 3; i++) {
result.push(guaranteeTags.value[i])
}
return result
})
function formatPrice(price: number): string {
const rounded = Math.round(price)
if (Math.abs(price - rounded) < 0.001) {
return rounded.toString()
}
return price.toFixed(2)
}
function getSelectedPackageName(): string {
for (let i = 0; i < servicePackages.value.length; i++) {
if (servicePackages.value[i].selected) {
return servicePackages.value[i].name
}
}
return ''
}
function getSelectedRiskText(): string {
let text = ''
for (let i = 0; i < careRiskOptions.value.length; i++) {
if (careRiskOptions.value[i].selected) {
if (text != '') {
text = text + '、'
}
text = text + careRiskOptions.value[i].label
}
}
return text
}
function getSelectedNeedText(): string {
let text = ''
for (let i = 0; i < careNeedOptions.value.length; i++) {
if (careNeedOptions.value[i].selected) {
if (text != '') {
text = text + '、'
}
text = text + careNeedOptions.value[i].label
}
}
return text
}
function buildDemandSummary(): string {
const selectedNeeds = getSelectedNeedText()
const selectedRisks = getSelectedRiskText()
const packageName = getSelectedPackageName()
const familyPresentText = recipientNeedFamilyPresent.value ? '需要家属在场' : '可独立服务'
const elevatorText = careHasElevator.value ? '有电梯' : '无电梯'
return '服务对象:' + recipientName.value
+ ',年龄:' + recipientAgeText.value
+ ',性别:' + recipientGender.value
+ ',关系:' + recipientRelation.value
+ ',身体情况:' + recipientHealthStatus.value
+ ',行动能力:' + recipientMobilityLevel.value
+ ',风险标签:' + selectedRisks
+ ',是否需家属在场:' + familyPresentText
+ ',服务地址:' + addressLineText.value
+ ',区域:' + careArea.value
+ ',楼层:' + careFloor.value
+ ',电梯:' + elevatorText
+ ',套餐:' + packageName
+ ',服务需求:' + selectedNeeds
+ ',详细需求:' + demandDetailText.value
+ ',紧急联系人:' + emergencyName.value + ' ' + emergencyPhone.value
+ ',服务地址备注:' + (selectedAddress.value != null ? selectedAddress.value.remark : '')
+ ',备注:' + remarkText.value
}
function setUnavailableServiceState() {
serviceTitle.value = '服务暂未配置'
serviceSubtitle.value = '当前服务目录未找到该项目,请稍后再试。'
servicePrice.value = 0
serviceDuration.value = '待配置'
serviceSuitableFor.value = '请联系管理员初始化服务目录。'
serviceImageText.value = '服务'
servicePackages.value = [] as Array<ServicePackageOptionType>
}
async function loadData() {
bookingDays.value = getBookingDayOptions()
bookingSlots.value = getBookingTimeSlots()
guaranteeTags.value = getServiceGuarantees()
agency.value = getRecommendedAgency(serviceId.value)
serviceExcludeText.value = getServiceExcludes(serviceId.value).join('')
careNeedOptions.value = buildDefaultNeedOptions(serviceId.value)
careRiskOptions.value = buildDefaultRiskOptions(serviceId.value)
servicePackages.value = buildDefaultPackages(serviceId.value, servicePrice.value, serviceDuration.value)
const catalog = await fetchHomeServiceCatalog()
let matchedService: HomeServiceCatalogType | null = null
for (let i = 0; i < catalog.length; i++) {
if (catalog[i].id == serviceId.value) {
matchedService = catalog[i]
break
}
}
if (matchedService == null) {
setUnavailableServiceState()
return
}
serviceTitle.value = matchedService.name
serviceSubtitle.value = matchedService.summary
servicePrice.value = matchedService.price
serviceDuration.value = matchedService.durationText
serviceSuitableFor.value = matchedService.suitableFor
const mappedItems = getHomeServiceItems([matchedService])
if (mappedItems.length > 0) {
serviceImageText.value = mappedItems[0].imageText
}
servicePackages.value = buildDefaultPackages(serviceId.value, servicePrice.value, serviceDuration.value)
}
async function ensureLogin(): Promise<boolean> {
const user = await getCurrentUser()
if (user == null || getCurrentUserId() == '') {
goToLogin('/pages/mall/consumer/home-service/service-detail?id=' + serviceId.value + '&mode=booking')
return false
}
return true
}
function selectAddress() {
ensureLogin().then((ok) => {
if (!ok) {
return
}
uni.navigateTo({ url: '/pages/address/address-list' })
})
}
function handleAgencyReplace() {
uni.showToast({ title: '服务机构选择建设中', icon: 'none' })
}
function handleAgencyContact() {
uni.showToast({ title: '已为你联系服务机构', icon: 'none' })
}
function handleAgencyNavigate() {
uni.showToast({ title: '导航功能建设中', icon: 'none' })
}
function selectDay(dayId: string) {
selectedDayId.value = dayId
}
function selectSlot(slotId: string, available: boolean) {
if (!available) {
uni.showToast({ title: '该时段暂不可约', icon: 'none' })
return
}
selectedSlotId.value = slotId
}
function setRecipientGender(gender: string) {
recipientGender.value = gender
}
function setContactGender(gender: string) {
contactGender.value = gender
}
function setFamilyPresent(needFamilyPresent: boolean) {
recipientNeedFamilyPresent.value = needFamilyPresent
}
function setElevator(hasElevator: boolean) {
careHasElevator.value = hasElevator
}
function toggleRiskOption(optionId: string) {
for (let i = 0; i < careRiskOptions.value.length; i++) {
if (careRiskOptions.value[i].id == optionId) {
careRiskOptions.value[i].selected = !careRiskOptions.value[i].selected
break
}
}
}
function toggleCareNeedOption(optionId: string) {
let selectedCount = 0
let targetIndex = -1
for (let i = 0; i < careNeedOptions.value.length; i++) {
if (careNeedOptions.value[i].selected) {
selectedCount++
}
if (careNeedOptions.value[i].id == optionId) {
targetIndex = i
}
}
if (targetIndex == -1) {
return
}
if (careNeedOptions.value[targetIndex].selected && selectedCount <= 1) {
uni.showToast({ title: '请至少保留一个服务需求', icon: 'none' })
return
}
careNeedOptions.value[targetIndex].selected = !careNeedOptions.value[targetIndex].selected
}
function selectServicePackage(packageId: string) {
for (let i = 0; i < servicePackages.value.length; i++) {
servicePackages.value[i].selected = servicePackages.value[i].id == packageId
}
}
async function submitBooking() {
if (!(await ensureLogin())) {
return
}
if (servicePackages.value.length == 0 || servicePrice.value <= 0) {
uni.showToast({ title: '当前服务暂不可预约', icon: 'none' })
return
}
if (selectedAddress.value == null) {
uni.showToast({ title: '请选择服务地址', icon: 'none' })
return
}
if (recipientName.value == '') {
uni.showToast({ title: '请填写服务对象姓名', icon: 'none' })
return
}
if (contactPhone.value == '' || contactName.value == '') {
uni.showToast({ title: '请补全联系人信息', icon: 'none' })
return
}
if (emergencyName.value == '' || emergencyPhone.value == '') {
uni.showToast({ title: '请补全紧急联系人信息', icon: 'none' })
return
}
if (selectedTimeText.value == '请选择上门时间') {
uni.showToast({ title: '请选择上门时间', icon: 'none' })
return
}
let parsedAge = parseInt(recipientAgeText.value)
if (isNaN(parsedAge)) {
parsedAge = 0
}
const draft: HomeServiceApplicationDraftType = {
serviceId: serviceId.value,
serviceName: serviceTitle.value,
applicantName: contactName.value,
elderName: recipientName.value,
age: parsedAge,
phone: contactPhone.value,
address: addressLineText.value,
preferredTime: selectedTimeText.value,
demandSummary: buildDemandSummary(),
serviceAddressSnapshot: selectedAddress.value
}
const created = await createHomeServiceApplication(draft)
if (created == null) {
uni.showToast({ title: '预约提交失败,请稍后重试', icon: 'none' })
return
}
uni.showToast({ title: '预约已提交', icon: 'success' })
uni.navigateTo({ url: '/pages/mall/consumer/home-service/order-detail?id=' + created.id })
}
onLoad((options) => {
const id = options['id']
if (id != null) {
serviceId.value = id as string
}
const mode = options['mode']
if (mode != null && mode == 'booking') {
careArea.value = defaultCareAddress.area
}
loadData()
})
onShow(() => {
loadCachedSelectedAddress()
})
</script>
<style scoped>
.booking-page-shell {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
background: #f7f8fb;
overflow: hidden;
}
.detail-fixed-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: #f3f7f9;
}
.detail-header-placeholder {
width: 100%;
flex-shrink: 0;
}
.detail-scroll {
flex: 1;
min-height: 0;
width: 100%;
}
.summary-card {
background: linear-gradient(180deg, #fff0f4 0%, #fff7f9 100%);
border-radius: 28rpx;
padding: 28rpx;
margin-bottom: 24rpx;
box-shadow: 0 12rpx 24rpx rgba(15, 23, 42, 0.06);
}
.service-address-card {
background: #ffffff;
border-radius: 24rpx;
padding: 24rpx;
flex-direction: row;
align-items: center;
justify-content: space-between;
border: 1rpx solid rgba(255, 125, 151, 0.16);
margin-bottom: 24rpx;
box-shadow: 0 10rpx 20rpx rgba(15, 23, 42, 0.04);
}
.service-address-main {
flex: 1;
margin-right: 20rpx;
}
.service-address-title {
font-size: 28rpx;
font-weight: 600;
color: #1f2937;
margin-bottom: 12rpx;
}
.service-address-detail {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.service-address-contact-row {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: 16rpx;
}
.service-address-contact,
.service-address-phone,
.service-address-full,
.service-address-remark,
.service-address-placeholder,
.service-address-arrow {
font-size: 24rpx;
}
.service-address-contact {
font-weight: 600;
color: #111827;
}
.service-address-phone {
color: #4b5563;
}
.service-address-full {
color: #374151;
line-height: 1.5;
}
.service-address-remark {
color: #6b7280;
line-height: 1.5;
}
.service-address-placeholder {
color: #9ca3af;
line-height: 1.5;
}
.service-address-arrow {
color: #c18b95;
font-weight: 600;
}
.summary-top-row,
.summary-tag-row,
.summary-price-row,
.booking-bottom-bar,
.booking-day-row,
.form-grid,
.pill-row,
.tag-row,
.booking-slot-grid,
.agency-top-row,
.action-row,
.package-top-row {
flex-direction: row;
align-items: center;
}
.summary-top-row,
.agency-top-row,
.booking-bottom-bar,
.package-top-row {
justify-content: space-between;
}
.summary-cover {
width: 144rpx;
height: 144rpx;
border-radius: 24rpx;
background: linear-gradient(180deg, #dff6f1 0%, #ecf3ff 100%);
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.summary-cover-text {
font-size: 36rpx;
font-weight: 700;
color: #0f766e;
}
.summary-main {
flex: 1;
min-width: 0;
margin-left: 22rpx;
}
.summary-title {
font-size: 34rpx;
font-weight: 700;
color: #16324f;
line-height: 44rpx;
}
.summary-desc,
.summary-meta,
.agency-meta,
.agency-desc,
.booking-bottom-time,
.package-desc,
.package-duration,
.info-value,
.value-card-text {
margin-top: 10rpx;
font-size: 24rpx;
line-height: 34rpx;
color: #64748b;
}
.summary-tag-row,
.pill-row,
.tag-row {
flex-wrap: wrap;
margin-top: 18rpx;
}
.summary-tag,
.agency-badge,
.tag-chip {
padding: 10rpx 16rpx;
border-radius: 999rpx;
font-size: 22rpx;
margin-right: 12rpx;
margin-bottom: 12rpx;
box-sizing: border-box;
}
.summary-tag {
background: #ffffff;
color: #ff5a7a;
}
.agency-badge {
background: #eafbf7;
color: #0f766e;
}
.summary-price-row {
margin-top: 12rpx;
align-items: flex-end;
}
.summary-price-prefix,
.summary-price,
.booking-bottom-price,
.package-price {
font-size: 40rpx;
font-weight: 700;
color: #ff5a7a;
}
.summary-price-unit {
font-size: 22rpx;
color: #64748b;
margin-left: 8rpx;
margin-bottom: 6rpx;
}
.form-item {
margin-bottom: 24rpx;
}
.form-item-last,
.tag-row-last {
margin-bottom: 0;
}
.label,
.agency-name,
.info-label,
.package-name {
font-size: 28rpx;
font-weight: 700;
color: #16324f;
margin-bottom: 12rpx;
}
.value-card,
.agency-card,
.package-card {
background: #f8fbfc;
border-radius: 22rpx;
padding: 22rpx;
box-sizing: border-box;
}
.value-card {
border-width: 1rpx;
border-style: solid;
border-color: #eef2f6;
}
.value-card-action {
margin-top: 14rpx;
font-size: 22rpx;
color: #ff5a7a;
}
.form-grid {
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
}
.form-grid-item {
width: 48.5%;
margin-bottom: 24rpx;
box-sizing: border-box;
}
.input,
.textarea {
width: 100%;
background: #f8fbfc;
border-radius: 18rpx;
font-size: 28rpx;
color: #23384d;
box-sizing: border-box;
border-width: 1rpx;
border-style: solid;
border-color: #eef2f6;
}
.input {
height: 84rpx;
padding: 0 24rpx;
}
.textarea {
padding: 22rpx 24rpx;
height: 160rpx;
}
.pill {
height: 64rpx;
padding: 0 24rpx;
border-radius: 999rpx;
background: #f3f6f8;
align-items: center;
justify-content: center;
margin-right: 12rpx;
margin-bottom: 12rpx;
box-sizing: border-box;
}
.pill-active {
background: #fff0f4;
border-width: 1rpx;
border-style: solid;
border-color: #ffc7d3;
}
.pill-text,
.tag-chip-text,
.booking-slot-label,
.booking-day-label,
.booking-day-date,
.booking-day-weekday,
.ghost-btn-text {
font-size: 24rpx;
color: #476072;
}
.pill-text-active,
.tag-chip-text-active,
.booking-slot-label-active,
.booking-day-label-active,
.booking-day-date-active,
.booking-day-weekday-active {
color: #ff5a7a;
font-weight: 700;
}
.tag-chip {
background: #f3f6f8;
padding-left: 20rpx;
padding-right: 20rpx;
}
.tag-chip-active {
background: #fff0f4;
border-width: 1rpx;
border-style: solid;
border-color: #ffc7d3;
}
.action-row {
margin-top: 20rpx;
flex-wrap: wrap;
}
.ghost-btn {
padding: 0 22rpx;
height: 68rpx;
border-radius: 999rpx;
background: #ffffff;
border-width: 1rpx;
border-style: solid;
border-color: #ffd5de;
align-items: center;
justify-content: center;
margin-right: 12rpx;
margin-bottom: 12rpx;
box-sizing: border-box;
}
.ghost-btn-text {
color: #ff5a7a;
font-weight: 700;
}
.booking-day-scroll {
height: 156rpx;
}
.booking-day-row {
padding-right: 20rpx;
}
.booking-day-card {
width: 160rpx;
padding: 20rpx;
border-radius: 24rpx;
background: #f8fbfc;
margin-right: 16rpx;
box-sizing: border-box;
border-width: 1rpx;
border-style: solid;
border-color: #eef2f6;
}
.booking-day-card-active {
background: #fff0f4;
border-color: #ffc7d3;
}
.booking-day-date,
.booking-day-weekday {
display: block;
margin-top: 6rpx;
}
.booking-slot-grid {
flex-wrap: wrap;
justify-content: space-between;
margin-top: 20rpx;
}
.booking-slot-card {
width: 48.5%;
height: 82rpx;
border-radius: 22rpx;
background: #f8fbfc;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
border-width: 1rpx;
border-style: solid;
border-color: #eef2f6;
box-sizing: border-box;
}
.booking-slot-card-active {
background: #fff0f4;
border-color: #ffc7d3;
}
.booking-slot-card-disabled {
opacity: 0.55;
background: #f1f5f9;
}
.package-list {
margin-bottom: 22rpx;
}
.package-card {
margin-bottom: 16rpx;
border-width: 1rpx;
border-style: solid;
border-color: #eef2f6;
}
.package-card-active {
background: #fff8fa;
border-color: #ffc7d3;
}
.package-name-active {
color: #ff5a7a;
}
.info-item {
margin-bottom: 22rpx;
}
.detail-page-bottom-space {
height: 180rpx;
}
.booking-bottom-bar {
position: fixed;
left: 24rpx;
right: 24rpx;
bottom: 28rpx;
background: #ffffff;
border-radius: 30rpx;
padding: 20rpx 22rpx;
box-shadow: 0 12rpx 28rpx rgba(15, 23, 42, 0.12);
box-sizing: border-box;
}
.booking-bottom-time {
display: block;
}
.booking-submit-btn {
height: 84rpx;
padding: 0 34rpx;
border-radius: 999rpx;
background: #16a085;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex-shrink: 0;
}
.booking-submit-btn-text {
font-size: 28rpx;
font-weight: 700;
color: #ffffff;
}
</style>