604 lines
18 KiB
Plaintext
604 lines
18 KiB
Plaintext
<template>
|
||
<ServicePageScaffold title="服务单详情" fallback-url="/pages/mall/consumer/home-service/index">
|
||
<view v-if="detail == null" class="empty-box">
|
||
<text class="empty-text">未找到对应服务单</text>
|
||
</view>
|
||
<view v-else>
|
||
<view class="summary-card">
|
||
<view class="summary-top-row">
|
||
<view>
|
||
<text class="summary-title">{{ detail.serviceName }}</text>
|
||
<text class="summary-case-no">服务单号:{{ detail.caseNo }}</text>
|
||
</view>
|
||
<ServiceStatusTag :text="detail.statusText" :tone="detail.statusTone"></ServiceStatusTag>
|
||
</view>
|
||
<text class="summary-desc">{{ detail.summary }}</text>
|
||
<view class="summary-price-row">
|
||
<text class="summary-price-prefix">¥</text>
|
||
<text class="summary-price">{{ detail.amount }}</text>
|
||
<text class="summary-price-unit">服务金额</text>
|
||
</view>
|
||
<view class="summary-meta-grid">
|
||
<view class="summary-meta-item">
|
||
<text class="summary-meta-label">上门时间</text>
|
||
<text class="summary-meta-value">{{ detail.serviceTime }}</text>
|
||
</view>
|
||
<view class="summary-meta-item">
|
||
<text class="summary-meta-label">当前进度</text>
|
||
<text class="summary-meta-value">第 {{ detail.currentStep }} / {{ detail.totalSteps }} 步</text>
|
||
</view>
|
||
<view class="summary-meta-item">
|
||
<text class="summary-meta-label">服务机构 / 人员</text>
|
||
<text class="summary-meta-value">{{ detail.staffName }}</text>
|
||
</view>
|
||
<view class="summary-meta-item">
|
||
<text class="summary-meta-label">联系电话</text>
|
||
<text class="summary-meta-value">{{ detail.staffPhone }}</text>
|
||
</view>
|
||
</view>
|
||
<view v-if="detail.statusText == '派单未成功' && detail.summary != ''" class="dispatch-fail-banner">
|
||
<text class="dispatch-fail-text">{{ detail.summary }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<ServicePanel title="预约信息" subtitle="围绕联系人、地址和服务对象展示当前预约信息。">
|
||
<ServiceInfoList
|
||
:items="[
|
||
{ label: '联系人:', value: detail.applicantName },
|
||
{ label: '服务对象:', value: detail.elderName + ',' + detail.age + ' 岁' },
|
||
{ label: '联系电话:', value: detail.phone },
|
||
{ label: '服务地址:', value: detail.address },
|
||
{ label: '预约备注:', value: detail.summary }
|
||
]"
|
||
></ServiceInfoList>
|
||
</ServicePanel>
|
||
|
||
<ServicePanel title="服务保障" subtitle="用户在预约后仍可看到平台保障与追溯承诺。">
|
||
<view class="guarantee-row">
|
||
<text class="guarantee-chip">平台认证</text>
|
||
<text class="guarantee-chip">明码标价</text>
|
||
<text class="guarantee-chip">服务可追溯</text>
|
||
<text class="guarantee-chip">异常可申诉</text>
|
||
</view>
|
||
</ServicePanel>
|
||
|
||
<ServicePanel title="服务过程" subtitle="基于真实状态日志展示预约受理、派单、上门与验收进度。">
|
||
<ServiceInfoList
|
||
:items="[
|
||
{ label: '签到时间:', value: detail.checkinTime != '' ? detail.checkinTime : '暂未签到' },
|
||
{ label: '签到地点:', value: detail.checkinAddress != '' ? detail.checkinAddress : '暂未记录' },
|
||
{ label: '开始服务:', value: detail.serviceStartedAt != '' ? detail.serviceStartedAt : '暂未开始' },
|
||
{ label: '完成服务:', value: detail.serviceFinishedAt != '' ? detail.serviceFinishedAt : '暂未完成' },
|
||
{ label: '执行摘要:', value: detail.executionSummary != '' ? detail.executionSummary : '服务人员暂未提交执行摘要' },
|
||
{ label: '证据数量:', value: detail.evidenceCount > 0 ? String(detail.evidenceCount) + ' 份' : '暂未上传' }
|
||
]"
|
||
></ServiceInfoList>
|
||
<ServiceTimeline :items="detail.timeline"></ServiceTimeline>
|
||
</ServicePanel>
|
||
|
||
<view v-if="consumerViewState.showExceptionPanel" class="exception-panel">
|
||
<text class="exception-title">{{ consumerViewState.exceptionTitle }}</text>
|
||
<text class="exception-desc">{{ consumerViewState.exceptionDesc }}</text>
|
||
<view v-if="consumerViewState.exceptionReason != ''" class="exception-reason">
|
||
<text class="exception-reason-label">异常原因:</text>
|
||
<text class="exception-reason-value">{{ consumerViewState.exceptionReason }}</text>
|
||
</view>
|
||
<text class="exception-update-time">状态更新时间:{{ consumerViewState.statusUpdatedAt }}</text>
|
||
</view>
|
||
|
||
<view v-if="isServicePaymentExpired" class="action-row">
|
||
<view class="secondary-btn" @click="goHome">返回首页</view>
|
||
<view class="primary-btn" @click="bookAgain">再次预约</view>
|
||
</view>
|
||
<view v-else class="action-row">
|
||
<view v-if="detail.statusText == '派单未成功'" class="secondary-btn" @click="bookAgain">再次预约</view>
|
||
<view v-else class="secondary-btn" @click="bookAgain">再次预约</view>
|
||
<view v-if="detail.status == 'pending_acceptance'" class="primary-btn" @click="goFeedback">去验收反馈</view>
|
||
<view v-else-if="consumerViewState.showRescheduleBtn" class="primary-btn" @click="bookAgain">重新选择时间</view>
|
||
<view v-else-if="detail.statusText == '派单未成功'" class="primary-btn" @click="retryDispatch">重新派单</view>
|
||
<view v-else class="primary-btn" @click="contactService">联系客服</view>
|
||
</view>
|
||
</view>
|
||
</ServicePageScaffold>
|
||
</template>
|
||
|
||
<script setup lang="uts">
|
||
import { computed, ref } from 'vue'
|
||
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
|
||
import ServicePageScaffold from '@/components/homeService/ServicePageScaffold.uvue'
|
||
import ServiceInfoList from '@/components/homeService/ServiceInfoList.uvue'
|
||
import ServicePanel from '@/components/homeService/ServicePanel.uvue'
|
||
import ServiceStatusTag from '@/components/homeService/ServiceStatusTag.uvue'
|
||
import ServiceTimeline from '@/components/homeService/ServiceTimeline.uvue'
|
||
import { fetchConsumerHomeServiceCaseDetail } from '@/services/homeServiceService.uts'
|
||
import { dispatchPaidHomecareOrder, HOMECARE_DISPATCH_STATUS_FAILED, showHomecareDispatchFailureModal } from '@/services/serviceOrderService.uts'
|
||
import { HomeServiceCaseType } from '@/types/home-service.uts'
|
||
import { getCurrentUser, getCurrentUserId } from '@/utils/store.uts'
|
||
import { goToLogin } from '@/utils/utils.uts'
|
||
|
||
const caseId = ref('')
|
||
const detail = ref<HomeServiceCaseType | null>(null)
|
||
|
||
async function ensureLogin(): Promise<boolean> {
|
||
const user = await getCurrentUser()
|
||
if (user == null || getCurrentUserId() == '') {
|
||
goToLogin('/pages/mall/consumer/home-service/order-detail?id=' + caseId.value)
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
async function loadData() {
|
||
if (caseId.value == '') {
|
||
return
|
||
}
|
||
if (!(await ensureLogin())) {
|
||
detail.value = null
|
||
return
|
||
}
|
||
detail.value = await fetchConsumerHomeServiceCaseDetail(caseId.value)
|
||
}
|
||
|
||
function goFeedback() {
|
||
if (caseId.value == '') {
|
||
return
|
||
}
|
||
ensureLogin().then((ok) => {
|
||
if (!ok) {
|
||
return
|
||
}
|
||
uni.navigateTo({ url: '/pages/mall/consumer/home-service/feedback?id=' + caseId.value })
|
||
})
|
||
}
|
||
|
||
async function goPayment(): Promise<void> {
|
||
if (caseId.value == '') {
|
||
uni.showToast({ title: '订单信息异常', icon: 'none' })
|
||
return
|
||
}
|
||
if (detail.value == null) {
|
||
uni.showToast({ title: '订单信息加载失败,请稍后重试', icon: 'none' })
|
||
return
|
||
}
|
||
// 刷新一次订单状态
|
||
await loadData()
|
||
if (detail.value == null) {
|
||
uni.showToast({ title: '订单信息加载失败,请稍后重试', icon: 'none' })
|
||
return
|
||
}
|
||
if (isServicePaymentExpired.value) {
|
||
uni.showToast({ title: '订单已超时未支付,请重新预约', icon: 'none' })
|
||
return
|
||
}
|
||
if (!canPayServiceOrder.value) {
|
||
uni.showToast({ title: '当前订单已不可支付,请刷新查看最新状态', icon: 'none' })
|
||
return
|
||
}
|
||
uni.navigateTo({
|
||
url: '/pages/mall/consumer/payment'
|
||
+ '?orderId=' + encodeURIComponent(detail.value.id)
|
||
+ '&source=service'
|
||
+ '&bizType=service'
|
||
})
|
||
}
|
||
|
||
function bookAgain() {
|
||
if (detail.value == null) {
|
||
return
|
||
}
|
||
let serviceTargetId = 'svc-001'
|
||
if (detail.value.serviceName == '康复训练指导') {
|
||
serviceTargetId = 'svc-002'
|
||
}
|
||
if (detail.value.serviceName == '慢病健康随访') {
|
||
serviceTargetId = 'svc-003'
|
||
}
|
||
uni.navigateTo({ url: '/pages/mall/consumer/home-service/service-detail?id=' + serviceTargetId + '&mode=booking' })
|
||
}
|
||
|
||
function contactService() {
|
||
uni.showToast({ title: '即将接入专属客服入口', icon: 'none' })
|
||
}
|
||
|
||
function goHome() {
|
||
uni.navigateTo({ url: '/pages/mall/consumer/home-service/index' })
|
||
}
|
||
|
||
function getPayExpireMs(caseDetail: HomeServiceCaseType): number {
|
||
if (caseDetail.payExpireAt == null || caseDetail.payExpireAt == '') {
|
||
return 0
|
||
}
|
||
const parsed = Date.parse(caseDetail.payExpireAt)
|
||
return isNaN(parsed) ? 0 : parsed
|
||
}
|
||
|
||
function isPaymentTimeExpired(caseDetail: HomeServiceCaseType): boolean {
|
||
if (caseDetail.paymentStatus != 1 || caseDetail.status != 'created') {
|
||
return false
|
||
}
|
||
const expireMs = getPayExpireMs(caseDetail)
|
||
if (expireMs <= 0) {
|
||
return false
|
||
}
|
||
return expireMs <= Date.now()
|
||
}
|
||
|
||
const isServicePaymentExpired = computed<boolean>(() => {
|
||
if (detail.value == null) {
|
||
return false
|
||
}
|
||
return isPaymentTimeExpired(detail.value)
|
||
})
|
||
|
||
let isRetryDispatching = false
|
||
|
||
function retryDispatch() {
|
||
if (isRetryDispatching || detail.value == null) {
|
||
return
|
||
}
|
||
const currentId = detail.value.id
|
||
isRetryDispatching = true
|
||
uni.showLoading({ title: '正在重新派单', mask: true })
|
||
dispatchPaidHomecareOrder(currentId).then((result) => {
|
||
uni.hideLoading()
|
||
if (result.success) {
|
||
uni.showToast({ title: '派单成功', icon: 'success' })
|
||
loadData()
|
||
return
|
||
}
|
||
showHomecareDispatchFailureModal(currentId, result, (id: string) => {
|
||
retryDispatch()
|
||
})
|
||
}).catch((e) => {
|
||
uni.hideLoading()
|
||
console.error('[retryDispatch] 重新派单异常:', e)
|
||
uni.showModal({
|
||
title: '派单服务异常',
|
||
content: '派单服务暂时异常,请稍后重试',
|
||
showCancel: true,
|
||
cancelText: '稍后再试',
|
||
confirmText: '重新派单',
|
||
success: (res) => {
|
||
if (res.confirm) {
|
||
retryDispatch()
|
||
}
|
||
}
|
||
})
|
||
}).finally(() => {
|
||
isRetryDispatching = false
|
||
})
|
||
}
|
||
|
||
function getLatestTimelineRemark(caseDetail: HomeServiceCaseType): string {
|
||
if (caseDetail.timeline.length > 0) {
|
||
return caseDetail.timeline[0].description
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function isTerminalStatus(status: string): boolean {
|
||
return status == 'accepted_by_user' || status == 'reviewed' || status == 'settled' || status == 'cancelled' || status == 'exception'
|
||
}
|
||
|
||
const consumerViewState = computed(() => {
|
||
const defaultState = {
|
||
showExceptionPanel: false,
|
||
exceptionTitle: '',
|
||
exceptionDesc: '',
|
||
exceptionReason: '',
|
||
statusUpdatedAt: '',
|
||
showRescheduleBtn: false,
|
||
showCancelBtn: false,
|
||
showRefundBtn: false
|
||
}
|
||
if (detail.value == null) {
|
||
return defaultState
|
||
}
|
||
const status = detail.value.status
|
||
const remark = getLatestTimelineRemark(detail.value)
|
||
const result = { ...defaultState }
|
||
|
||
if (isPaymentTimeExpired(detail.value)) {
|
||
result.showExceptionPanel = true
|
||
result.exceptionTitle = '订单已超时未支付'
|
||
result.exceptionDesc = '支付时间已结束,请返回首页重新预约或刷新查看最新状态。'
|
||
result.statusUpdatedAt = detail.value.payExpireAt != null && detail.value.payExpireAt != '' ? detail.value.payExpireAt : detail.value.serviceTime
|
||
return result
|
||
}
|
||
|
||
if (detail.value.statusText == '派单未成功') {
|
||
result.showExceptionPanel = true
|
||
result.exceptionTitle = '派单未成功'
|
||
result.exceptionDesc = detail.value.summary != '' ? detail.value.summary : '当前暂无匹配的服务人员,请稍后重试或联系客服。'
|
||
result.statusUpdatedAt = detail.value.serviceTime
|
||
} else if (status == 'created' || status == 'assigned') {
|
||
result.exceptionTitle = '正在安排服务人员'
|
||
result.exceptionDesc = '您的预约申请已提交,平台正在为您匹配可上门的服务人员,请耐心等待。'
|
||
result.statusUpdatedAt = detail.value.serviceTime
|
||
} else if (status == 'accepted') {
|
||
result.exceptionTitle = '服务人员已接单'
|
||
result.exceptionDesc = '服务人员已确认接单,正在准备上门,请保持电话畅通。'
|
||
result.statusUpdatedAt = detail.value.serviceTime
|
||
} else if (status == 'departed') {
|
||
result.exceptionTitle = '服务人员正在前往'
|
||
result.exceptionDesc = '服务人员已出发,正在前往服务地点,请做好接待准备。'
|
||
result.statusUpdatedAt = detail.value.serviceTime
|
||
} else if (status == 'arrived' || status == 'in_service') {
|
||
result.exceptionTitle = '服务人员已到达'
|
||
result.exceptionDesc = '服务人员已到达服务地点,服务正在进行中。'
|
||
result.statusUpdatedAt = detail.value.serviceTime
|
||
} else if (status == 'rejected') {
|
||
result.showExceptionPanel = true
|
||
result.exceptionTitle = '当前预约暂未安排到服务人员'
|
||
result.exceptionDesc = '很抱歉,服务人员未接受该工单。您可以重新选择服务时间,或取消本次服务申请。'
|
||
result.exceptionReason = remark != '' ? remark : '服务人员未接单'
|
||
result.statusUpdatedAt = detail.value.serviceTime
|
||
result.showRescheduleBtn = true
|
||
} else if (status == 'exception') {
|
||
result.showExceptionPanel = true
|
||
result.exceptionTitle = '当前预约暂未安排到服务人员'
|
||
result.exceptionDesc = '很抱歉,当前所选预约时间暂未匹配到可服务人员。您可以重新选择服务时间,或取消本次服务申请。'
|
||
result.exceptionReason = remark != '' ? remark : '履约异常,请稍后重试或联系客服'
|
||
result.statusUpdatedAt = detail.value.serviceTime
|
||
result.showRescheduleBtn = true
|
||
} else if (status == 'cancelled') {
|
||
result.showExceptionPanel = true
|
||
result.exceptionTitle = '服务申请已取消'
|
||
result.exceptionDesc = '该服务申请已被取消。如有疑问,请联系客服了解详情。'
|
||
result.exceptionReason = remark != '' ? remark : '已取消'
|
||
result.statusUpdatedAt = detail.value.serviceTime
|
||
} else if (status == 'pending_acceptance') {
|
||
result.exceptionTitle = '服务已完成,等待验收'
|
||
result.exceptionDesc = '服务人员已提交服务记录,请您确认服务结果并进行评价。'
|
||
result.statusUpdatedAt = detail.value.serviceTime
|
||
} else if (status == 'completed' || status == 'accepted_by_user' || status == 'reviewed' || status == 'settled') {
|
||
result.exceptionTitle = '服务已完成'
|
||
result.exceptionDesc = '本次服务已结束,感谢您的使用。'
|
||
result.statusUpdatedAt = detail.value.serviceTime
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
const canPayServiceOrder = computed<boolean>(() => {
|
||
if (detail.value == null) {
|
||
return false
|
||
}
|
||
return detail.value.paymentStatus == 1
|
||
&& detail.value.status == 'created'
|
||
&& !isPaymentTimeExpired(detail.value)
|
||
})
|
||
|
||
let detailRefreshTimerId: number = 0
|
||
|
||
function startDetailRefreshTimer(): void {
|
||
stopDetailRefreshTimer()
|
||
if (detail.value != null && !isTerminalStatus(detail.value.status)) {
|
||
detailRefreshTimerId = setInterval(() => {
|
||
loadData()
|
||
}, 15000)
|
||
}
|
||
}
|
||
|
||
function stopDetailRefreshTimer(): void {
|
||
if (detailRefreshTimerId > 0) {
|
||
clearInterval(detailRefreshTimerId)
|
||
detailRefreshTimerId = 0
|
||
}
|
||
}
|
||
|
||
onLoad((options) => {
|
||
const id = options['id']
|
||
if (id != null) {
|
||
caseId.value = id as string
|
||
loadData().then(() => {
|
||
startDetailRefreshTimer()
|
||
})
|
||
}
|
||
})
|
||
|
||
onShow(() => {
|
||
loadData().then(() => {
|
||
startDetailRefreshTimer()
|
||
})
|
||
})
|
||
|
||
onUnload(() => {
|
||
stopDetailRefreshTimer()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
|
||
.summary-card {
|
||
background: #ffffff;
|
||
border-radius: 32rpx;
|
||
padding: 28rpx;
|
||
box-shadow: 0 12rpx 24rpx rgba(15, 23, 42, 0.06);
|
||
margin-bottom: 24rpx;
|
||
}
|
||
|
||
.summary-top-row,
|
||
.summary-price-row,
|
||
.action-row,
|
||
.guarantee-row {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.summary-top-row,
|
||
.action-row {
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.summary-title {
|
||
font-size: 34rpx;
|
||
font-weight: 700;
|
||
color: #16324f;
|
||
}
|
||
|
||
.summary-case-no,
|
||
.summary-desc,
|
||
.summary-meta-label,
|
||
.summary-meta-value,
|
||
.empty-text {
|
||
margin-top: 10rpx;
|
||
font-size: 24rpx;
|
||
line-height: 34rpx;
|
||
color: #66788a;
|
||
}
|
||
|
||
.summary-desc {
|
||
margin-top: 18rpx;
|
||
}
|
||
|
||
.summary-price-row {
|
||
margin-top: 18rpx;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.summary-price-prefix,
|
||
.summary-price {
|
||
font-size: 40rpx;
|
||
font-weight: 700;
|
||
color: #0f766e;
|
||
}
|
||
|
||
.summary-price-unit {
|
||
font-size: 22rpx;
|
||
color: #64748b;
|
||
margin-left: 10rpx;
|
||
margin-bottom: 6rpx;
|
||
}
|
||
|
||
.summary-meta-grid {
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
margin-top: 22rpx;
|
||
}
|
||
|
||
.summary-meta-item {
|
||
width: 48%;
|
||
padding: 22rpx;
|
||
border-radius: 24rpx;
|
||
background: #f8fbfd;
|
||
box-sizing: border-box;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.guarantee-row {
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.guarantee-chip {
|
||
padding: 12rpx 18rpx;
|
||
border-radius: 999rpx;
|
||
background: #eef6ff;
|
||
font-size: 22rpx;
|
||
color: #476072;
|
||
margin-right: 12rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.action-row {
|
||
margin-top: 10rpx;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.secondary-btn,
|
||
.primary-btn {
|
||
width: 48%;
|
||
height: 78rpx;
|
||
border-radius: 999rpx;
|
||
font-size: 26rpx;
|
||
font-weight: 700;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.secondary-btn {
|
||
background: #ffffff;
|
||
border-width: 2rpx;
|
||
border-style: solid;
|
||
border-color: #cbd5e1;
|
||
color: #476072;
|
||
}
|
||
|
||
.primary-btn {
|
||
background: #16a085;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.empty-text {
|
||
margin-top: 10rpx;
|
||
}
|
||
|
||
.empty-box {
|
||
padding: 120rpx 0;
|
||
align-items: center;
|
||
}
|
||
|
||
.exception-panel {
|
||
background: #ffffff;
|
||
border-radius: 32rpx;
|
||
padding: 28rpx;
|
||
box-shadow: 0 12rpx 24rpx rgba(15, 23, 42, 0.06);
|
||
margin-bottom: 24rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.exception-title {
|
||
font-size: 30rpx;
|
||
font-weight: 700;
|
||
color: #b45309;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.exception-desc {
|
||
font-size: 24rpx;
|
||
color: #66788a;
|
||
line-height: 36rpx;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.exception-reason {
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.exception-reason-label {
|
||
font-size: 24rpx;
|
||
color: #66788a;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.exception-reason-value {
|
||
font-size: 24rpx;
|
||
color: #b45309;
|
||
}
|
||
|
||
.exception-update-time {
|
||
font-size: 22rpx;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.dispatch-fail-banner {
|
||
margin-top: 18rpx;
|
||
padding: 18rpx 24rpx;
|
||
background: #fff7ed;
|
||
border-radius: 16rpx;
|
||
border-width: 1rpx;
|
||
border-style: solid;
|
||
border-color: #fed7aa;
|
||
}
|
||
|
||
.dispatch-fail-text {
|
||
font-size: 26rpx;
|
||
color: #c2410c;
|
||
line-height: 40rpx;
|
||
}
|
||
</style>
|