完善服务模块缺少付款页的bug

This commit is contained in:
2026-06-02 11:35:31 +08:00
parent c3324d459a
commit 881262940c
35 changed files with 29069 additions and 557 deletions

View File

@@ -85,7 +85,8 @@
</view>
</ServicePanel>
<ServicePanel title="Step3 上门时间" subtitle="先选日期和时间段,提交预约。">
<ServicePanel title="Step3 预约上门时间" subtitle="请选择服务人员预计到达的日期和时间段,提交前系统将重新校验可预约状态。">
<text class="booking-hint">请至少提前30分钟预约以便安排服务人员到达现场。</text>
<scroll-view class="booking-day-scroll" direction="horizontal" :show-scrollbar="false">
<view class="booking-day-row">
<view
@@ -110,6 +111,19 @@
<text :class="['booking-slot-label', selectedSlotId == item.id ? 'booking-slot-label-active' : '']">{{ item.label }}</text>
</view>
</view>
<view class="custom-slot-section">
<text class="custom-slot-title">其他上门时间</text>
<picker
mode="selector"
:range="customSlotLabels"
:value="customSlotIndex"
@change="handleCustomSlotChange"
>
<view class="custom-slot-picker">
<text class="custom-slot-picker-text">{{ customSlotDisplayText }}</text>
</view>
</picker>
</view>
</ServicePanel>
<ServicePanel title="Step4 服务对象信息" subtitle="补全被照护人情况,便于机构判断服务方案。">
@@ -221,7 +235,10 @@
@click="selectServicePackage(item.id)"
>
<view class="package-top-row">
<text :class="['package-name', item.selected ? 'package-name-active' : '']">{{ item.name }}</text>
<view class="package-name-row">
<text :class="['package-name', item.selected ? 'package-name-active' : '']">{{ item.name }}</text>
<text v-if="item.sourceTag != ''" class="package-source-tag">{{ item.sourceTag }}</text>
</view>
<text class="package-price">¥{{ formatPrice(item.price) }}</text>
</view>
<text class="package-desc">{{ item.desc }}</text>
@@ -271,7 +288,7 @@
<text class="booking-bottom-time">{{ selectedTimeText }}</text>
</view>
<view class="booking-submit-btn" @click="submitBooking">
<text class="booking-submit-btn-text">立即预约</text>
<text class="booking-submit-btn-text">提交预约</text>
</view>
</view>
</view>
@@ -279,25 +296,33 @@
<script setup lang="uts">
import { computed, ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { onLoad, onShow, onUnload } 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 { createHomeServiceApplication, fetchHomeServiceCatalog, fetchHomeServicePackages } from '@/services/homeServiceService.uts'
import { shouldUseCareTaskPath } from '@/services/serviceOrderService.uts'
import { HomeServiceApplicationDraftType, HomeServiceCatalogType, HomeServicePackageType, 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'
import {
BookingDayOptionType,
BookingTimeSlotType,
buildBookingDays,
buildCustomSlots,
buildPresetSlots,
formatAppointmentDisplayText,
formatStandardAppointmentTime,
hasAnyAvailableSlots,
validateSelectedAppointmentTime
} from '@/utils/homeServiceBookingTime.uts'
type CareRecipientInfoType = {
name: string
@@ -337,6 +362,7 @@ type ServicePackageOptionType = {
desc: string
price: number
duration: string
sourceTag: string
selected: boolean
}
@@ -397,9 +423,13 @@ const defaultCareAddress: CareAddressInfoType = {
const careNeedOptions = ref<Array<CareNeedOptionType>>([])
const careRiskOptions = ref<Array<CareRiskOptionType>>([])
const servicePackages = ref<Array<ServicePackageOptionType>>([])
const isSubmitting = ref<boolean>(false)
const selectedDayId = ref('day-1')
const selectedSlotId = ref('slot-1')
const selectedDayId = ref('')
const selectedSlotId = ref('')
const selectedCustomSlotId = ref('')
const customSlots = ref<Array<BookingTimeSlotType>>([])
const customSlotIndex = ref(-1)
const selectedAddress = ref<HomeServiceSelectedAddressType | null>(null)
const careAddress = ref(defaultCareAddress.address)
@@ -486,27 +516,20 @@ function buildDefaultRiskOptions(currentServiceId: string): Array<CareRiskOption
]
}
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 }
]
function mapServicePackages(packages: Array<HomeServicePackageType>): Array<ServicePackageOptionType> {
const result = [] as Array<ServicePackageOptionType>
for (let i = 0; i < packages.length; i++) {
result.push({
id: packages[i].id,
name: packages[i].packageName,
desc: packages[i].packageDesc,
price: packages[i].price,
duration: packages[i].durationText != '' ? packages[i].durationText : '待配置',
sourceTag: packages[i].dataSource == 'dev_seed' ? '测试数据' : '',
selected: i == 0
})
}
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 }
]
return result
}
const addressLineText = computed((): string => {
@@ -560,25 +583,60 @@ function loadCachedSelectedAddress(): void {
applySelectedAddress(null)
}
const selectedTimeText = computed((): string => {
let selectedDayLabel = ''
const selectedDay = computed((): BookingDayOptionType | null => {
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
return bookingDays.value[i]
}
}
let selectedSlotLabel = ''
return null
})
const selectedSlot = computed((): BookingTimeSlotType | null => {
for (let i = 0; i < bookingSlots.value.length; i++) {
if (bookingSlots.value[i].id == selectedSlotId.value) {
selectedSlotLabel = bookingSlots.value[i].label
break
return bookingSlots.value[i]
}
}
if (selectedDayLabel == '' || selectedSlotLabel == '') {
return '请选择上门时间'
for (let i = 0; i < customSlots.value.length; i++) {
if (customSlots.value[i].id == selectedSlotId.value) {
return customSlots.value[i]
}
}
return selectedDayLabel + ' ' + selectedSlotLabel
return null
})
const selectedPackage = computed((): ServicePackageOptionType | null => {
for (let i = 0; i < servicePackages.value.length; i++) {
if (servicePackages.value[i].selected) {
return servicePackages.value[i]
}
}
return null
})
const selectedTimeText = computed((): string => {
return formatAppointmentDisplayText(selectedDay.value, selectedSlot.value)
})
const customSlotLabels = computed((): Array<string> => {
const labels: Array<string> = []
for (let i = 0; i < customSlots.value.length; i++) {
labels.push(customSlots.value[i].label)
}
return labels
})
const customSlotDisplayText = computed((): string => {
if (selectedCustomSlotId.value == '') {
return '请选择其他时间段'
}
for (let i = 0; i < customSlots.value.length; i++) {
if (customSlots.value[i].id == selectedCustomSlotId.value) {
return customSlots.value[i].label
}
}
return '请选择其他时间段'
})
const minPriceValue = computed((): number => {
@@ -674,23 +732,45 @@ function buildDemandSummary(): string {
function setUnavailableServiceState() {
serviceTitle.value = '服务暂未配置'
serviceSubtitle.value = '当前服务目录未找到该项目,请稍后再试。'
serviceSubtitle.value = '当前服务目录或套餐未配置,请稍后再试。'
servicePrice.value = 0
serviceDuration.value = '待配置'
serviceSuitableFor.value = '请联系管理员初始化服务目录。'
serviceSuitableFor.value = '请联系管理员初始化服务目录与正式套餐。'
serviceImageText.value = '服务'
servicePackages.value = [] as Array<ServicePackageOptionType>
}
function initDefaultDaySelection(now: Date) {
let firstAvailableIndex = -1
for (let i = 0; i < bookingDays.value.length; i++) {
if (hasAnyAvailableSlots(bookingDays.value[i].dateKey, now)) {
firstAvailableIndex = i
break
}
}
if (firstAvailableIndex >= 0) {
selectedDayId.value = bookingDays.value[firstAvailableIndex].id
bookingSlots.value = buildPresetSlots(bookingDays.value[firstAvailableIndex].dateKey, now)
customSlots.value = buildCustomSlots(bookingDays.value[firstAvailableIndex].dateKey, now)
} else {
selectedDayId.value = ''
bookingSlots.value = [] as Array<BookingTimeSlotType>
customSlots.value = [] as Array<BookingTimeSlotType>
}
selectedSlotId.value = ''
selectedCustomSlotId.value = ''
customSlotIndex.value = -1
}
async function loadData() {
bookingDays.value = getBookingDayOptions()
bookingSlots.value = getBookingTimeSlots()
const now = new Date()
bookingDays.value = buildBookingDays(now)
initDefaultDaySelection(now)
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
@@ -706,14 +786,21 @@ async function loadData() {
}
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)
const packages = await fetchHomeServicePackages(serviceId.value)
servicePackages.value = mapServicePackages(packages)
if (servicePackages.value.length == 0) {
setUnavailableServiceState()
return
}
const firstPackage = servicePackages.value[0]
servicePrice.value = firstPackage.price
serviceDuration.value = firstPackage.duration
}
async function ensureLogin(): Promise<boolean> {
@@ -748,14 +835,44 @@ function handleAgencyNavigate() {
function selectDay(dayId: string) {
selectedDayId.value = dayId
selectedSlotId.value = ''
selectedCustomSlotId.value = ''
customSlotIndex.value = -1
const day = selectedDay.value
if (day != null) {
const now = new Date()
bookingSlots.value = buildPresetSlots(day.dateKey, now)
customSlots.value = buildCustomSlots(day.dateKey, now)
}
}
function selectSlot(slotId: string, available: boolean) {
if (!available) {
uni.showToast({ title: '该时段暂不可约', icon: 'none' })
uni.showToast({ title: '该时间段已无法预约,请选择稍晚时间', icon: 'none' })
return
}
selectedSlotId.value = slotId
selectedCustomSlotId.value = ''
customSlotIndex.value = -1
}
function handleCustomSlotChange(e: any) {
const detail = e.detail
const index = typeof detail.value == 'number' ? detail.value : parseInt(detail.value)
if (isNaN(index) || index < 0 || index >= customSlots.value.length) {
return
}
const slot = customSlots.value[index]
if (slot == null) {
return
}
if (!slot.available) {
uni.showToast({ title: '该时间段已无法预约,请选择稍晚时间', icon: 'none' })
return
}
selectedCustomSlotId.value = slot.id
selectedSlotId.value = slot.id
customSlotIndex.value = index
}
function setRecipientGender(gender: string) {
@@ -808,9 +925,33 @@ function selectServicePackage(packageId: string) {
for (let i = 0; i < servicePackages.value.length; i++) {
servicePackages.value[i].selected = servicePackages.value[i].id == packageId
}
const current = selectedPackage.value
if (current != null) {
servicePrice.value = current.price
serviceDuration.value = current.duration
}
}
function goToServicePayment(orderId: string): void {
if (orderId == '') {
uni.showToast({
title: '订单创建成功,但订单编号异常,请从订单详情继续付款',
icon: 'none'
})
return
}
uni.navigateTo({
url: '/pages/mall/consumer/payment'
+ '?orderId=' + encodeURIComponent(orderId)
+ '&source=service'
+ '&bizType=service'
})
}
async function submitBooking() {
if (isSubmitting.value) {
return
}
if (!(await ensureLogin())) {
return
}
@@ -839,32 +980,79 @@ async function submitBooking() {
return
}
const now = new Date()
const day = selectedDay.value
const slot = selectedSlot.value
const validation = validateSelectedAppointmentTime(day, slot, now)
if (!validation.valid) {
uni.showToast({ title: validation.message, icon: 'none' })
if (day != null && slot != null && !slot.available) {
bookingSlots.value = buildPresetSlots(day.dateKey, now)
customSlots.value = buildCustomSlots(day.dateKey, now)
selectedSlotId.value = ''
selectedCustomSlotId.value = ''
customSlotIndex.value = -1
}
return
}
let parsedAge = parseInt(recipientAgeText.value)
if (isNaN(parsedAge)) {
parsedAge = 0
}
const standardTime = formatStandardAppointmentTime(day, slot)
const draft: HomeServiceApplicationDraftType = {
serviceId: serviceId.value,
serviceName: serviceTitle.value,
selectedPackageId: selectedPackage.value != null ? selectedPackage.value.id : '',
selectedPackageName: selectedPackage.value != null ? selectedPackage.value.name : '',
applicantName: contactName.value,
elderName: recipientName.value,
age: parsedAge,
gender: recipientGender.value,
phone: contactPhone.value,
address: addressLineText.value,
preferredTime: selectedTimeText.value,
preferredTime: standardTime,
appointmentStartAt: slot != null ? slot.startAt : 0,
appointmentEndAt: slot != null ? slot.endAt : 0,
appointmentDate: day != null ? day.dateKey : '',
slotSource: slot != null ? slot.source : '',
minAdvanceMinutesSnapshot: 30,
demandSummary: buildDemandSummary(),
serviceAddressSnapshot: selectedAddress.value
}
const created = await createHomeServiceApplication(draft)
if (created == null) {
uni.showToast({ title: '预约提交失败,请稍后重试', icon: 'none' })
return
isSubmitting.value = true
try {
const created = await createHomeServiceApplication(draft)
if (created == null) {
uni.showToast({ title: '预约提交失败,请稍后重试', icon: 'none' })
return
}
// TODO(SERVICE_PAYMENT_ROUTE_UNIFY):
// 当前仅因 ec_care_tasks 新链尚未接入支付能力,临时使用订单链路类型区分。
// 长期应依据服务订单真实 payment_status / payment_required / payment_order_id
// 判断是否进入支付,而不是仅依据 ID 是否为 UUID。
const isCareTaskPath = shouldUseCareTaskPath(created.id)
if (!isCareTaskPath) {
// hss_service_orders 套餐订单,进入 payment
uni.showToast({
title: '订单已创建,请完成付款',
icon: 'success'
})
goToServicePayment(created.id)
return
}
// 当前兼容处理:对于后端明确没有支付能力的新链任务,只保持原有详情跳转。
// 不得把这种兼容逻辑扩展为所有服务均无需付款。
uni.showToast({ title: '预约已提交', icon: 'success' })
uni.navigateTo({ url: '/pages/mall/consumer/home-service/order-detail?id=' + created.id })
} finally {
isSubmitting.value = false
}
uni.showToast({ title: '预约已提交', icon: 'success' })
uni.navigateTo({ url: '/pages/mall/consumer/home-service/order-detail?id=' + created.id })
}
onLoad((options) => {
@@ -879,8 +1067,62 @@ onLoad((options) => {
loadData()
})
let refreshTimerId: number = 0
function startRefreshTimer(): void {
stopRefreshTimer()
refreshTimerId = setInterval(() => {
refreshBookingSlots()
}, 60000)
}
function stopRefreshTimer(): void {
if (refreshTimerId > 0) {
clearInterval(refreshTimerId)
refreshTimerId = 0
}
}
function refreshBookingSlots(): void {
const now = new Date()
const day = selectedDay.value
if (day == null) {
return
}
const todayKey = buildBookingDays(now)[0].dateKey
if (day.dateKey == todayKey) {
bookingSlots.value = buildPresetSlots(day.dateKey, now)
customSlots.value = buildCustomSlots(day.dateKey, now)
const slot = selectedSlot.value
if (slot != null && !slot.available) {
selectedSlotId.value = ''
selectedCustomSlotId.value = ''
customSlotIndex.value = -1
uni.showToast({ title: '当前选择的上门时间已失效,请重新选择稍晚的时间段', icon: 'none' })
}
}
}
onShow(() => {
loadCachedSelectedAddress()
const now = new Date()
const day = selectedDay.value
if (day != null) {
bookingSlots.value = buildPresetSlots(day.dateKey, now)
customSlots.value = buildCustomSlots(day.dateKey, now)
const slot = selectedSlot.value
if (slot != null && !slot.available) {
selectedSlotId.value = ''
selectedCustomSlotId.value = ''
customSlotIndex.value = -1
uni.showToast({ title: '当前选择的上门时间已失效,请重新选择稍晚的时间段', icon: 'none' })
}
}
startRefreshTimer()
})
onUnload(() => {
stopRefreshTimer()
})
</script>
@@ -1011,6 +1253,7 @@ onShow(() => {
.booking-slot-grid,
.agency-top-row,
.action-row,
.package-name-row,
.package-top-row {
flex-direction: row;
align-items: center;
@@ -1023,6 +1266,14 @@ onShow(() => {
justify-content: space-between;
}
.package-name-row {
flex: 1;
min-width: 0;
align-items: center;
flex-wrap: wrap;
margin-right: 16rpx;
}
.summary-cover {
width: 144rpx;
height: 144rpx;
@@ -1085,6 +1336,16 @@ onShow(() => {
box-sizing: border-box;
}
.package-source-tag {
padding: 6rpx 12rpx;
border-radius: 999rpx;
font-size: 20rpx;
color: #b45309;
background: #fff7ed;
margin-left: 12rpx;
margin-bottom: 12rpx;
}
.summary-tag {
background: #ffffff;
color: #ff5a7a;
@@ -1390,4 +1651,39 @@ onShow(() => {
font-weight: 700;
color: #ffffff;
}
.booking-hint {
font-size: 24rpx;
color: #64748b;
margin-bottom: 18rpx;
line-height: 34rpx;
}
.custom-slot-section {
margin-top: 10rpx;
}
.custom-slot-title {
font-size: 26rpx;
font-weight: 600;
color: #16324f;
margin-bottom: 14rpx;
}
.custom-slot-picker {
height: 82rpx;
border-radius: 22rpx;
background: #f8fbfc;
border-width: 1rpx;
border-style: solid;
border-color: #eef2f6;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.custom-slot-picker-text {
font-size: 24rpx;
color: #476072;
}
</style>