Files
medical-mall/pages/mall/delivery/test/consumer-logistics-detail.uvue
2026-02-09 16:53:20 +08:00

522 lines
13 KiB
Plaintext

<template>
<view class="container">
<!-- 头部状态栏 (沉浸式) -->
<view class="status-header">
<text class="back-link-white" @click="goBack">⬅ 返回</text>
<view class="status-main">
<text class="status-icon">{{ getStatusIcon(order.status) }}</text>
<view class="status-text-wrap">
<text class="status-title">{{ getStatusText(order.status) }}</text>
<view class="status-desc-row">
<text v-for="(seg, i) in splitText(getLatestEventText(), order.status)" :key="i" class="status-desc-text" :class="{ 'highlight': seg.isPhone }" @click="seg.isPhone ? makeCall(seg.raw as string) : null">{{ seg.text }}</text>
</view>
</view>
</view>
</view>
<!-- 承运信息卡片 -->
<view class="card carrier-card">
<view class="carrier-info">
<image class="carrier-logo" src="/static/carrier-default.png" mode="aspectFit" />
<view class="carrier-detail">
<text class="carrier-name">{{ order.carrier }}快递</text>
<text v-if="order.tracking_no" class="waybill-no">运单号: {{ order.tracking_no }}</text>
<text v-else class="waybill-no">运单号: 暂无</text>
</view>
<button v-if="order.tracking_no" class="copy-btn" @click="copyTrackingNo">复制</button>
</view>
</view>
<!-- 物流轨迹时间线 -->
<view class="card timeline-card">
<view class="section-title">物流追踪</view>
<view class="timeline-container">
<view v-for="(event, index) in statusHistory" :key="index" class="timeline-item">
<!-- 时间轴线 -->
<view class="axis">
<view class="dot" :class="{active: index === 0}"></view>
<view class="line" v-if="index !== statusHistory.length - 1"></view>
</view>
<!-- 内容区 -->
<view class="content">
<view class="event-header">
<view class="status-time-row">
<text v-if="index === 0 || event.status_code !== statusHistory[index - 1].status_code" class="status-label" :class="{active: index === 0}">{{ getStatusLabel(event.status_code) }}</text>
<text class="event-time" :class="{active: index === 0, 'margin-left': index === 0 || event.status_code !== statusHistory[index - 1].status_code}">{{ event.event_time }}</text>
</view>
<view class="event-title-row">
<text v-for="(seg, i) in splitText(event.event_text, event.status_code)" :key="i" class="event-title-text" :class="{ 'highlight': seg.isPhone, 'active': index === 0 }" @click="seg.isPhone ? makeCall(seg.raw as string) : null">{{ seg.text }}</text>
</view>
</view>
<!-- 证据/签收图片 (POD) -->
<view v-if="event.evidence_urls.length > 0" class="evidence-grid">
<image v-for="(img, idx) in event.evidence_urls" :key="idx" :src="img" class="evidence-img" mode="aspectFill" @click="previewImage(img, event.evidence_urls)" />
</view>
</view>
</view>
<view v-if="statusHistory.length === 0" class="empty-state">
<text class="empty-text">暂无物流动态</text>
</view>
</view>
</view>
<!-- 订单交易信息 -->
<view class="card order-info-card">
<view class="section-title">订单信息</view>
<view class="info-item">
<text class="info-label">订单编号</text>
<text class="info-value">{{ order.order_no }}</text>
</view>
<view class="info-item">
<text class="info-label">下单时间</text>
<text class="info-value">{{ order.created_at }}</text>
</view>
<view class="info-item">
<text class="info-label">实付金额</text>
<text class="info-value">¥{{ order.amount || '0.00' }}</text>
</view>
<view v-if="order.address" class="info-item">
<text class="info-label">收货地址</text>
<text class="info-value">{{ order.address }}</text>
</view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-actions">
<button v-if="order.status === 'READY_FOR_PICKUP'" class="action-item primary" @click="confirmReceipt">确认取件</button>
<button class="action-item" @click="refresh">刷新物流</button>
<button class="action-item" @click="showSupport">物流客服</button>
</view>
</view>
</template>
<script uts>
import { mockService, MockOrder, MockTrackingEvent, LogisticsConstants } from './mock-service.uts'
export default {
data() {
return {
orderNo: 'ORD20260205002',
order: {
order_no: '',
status: '',
created_at: '',
receiver_name: '',
receiver_masked_phone: '',
address: '',
amount: '',
carrier: '',
tracking_no: ''
} as MockOrder,
statusHistory: [] as MockTrackingEvent[]
}
},
onLoad(options: any) {
if (options['order_no'] != null) {
this.orderNo = options['order_no'] as string
}
},
onShow() {
this.loadData()
},
methods: {
goBack() {
uni.navigateBack()
},
async loadData() {
const orders = await mockService.getMockOrders()
const found = orders.find((o: MockOrder): boolean => o.order_no === this.orderNo)
if (found != null) {
// 确保对象引用更新以触发响应式
this.order = { ...found } as MockOrder
}
// 确保数组引用更新
const tracking = await mockService.getMockTracking(this.orderNo)
this.statusHistory = [...tracking]
},
getStatusText(status: string): string {
return mockService.getStatusText(status)
},
getStatusIcon(status: string): string {
return mockService.getStatusIcon(status)
},
getStatusLabel(status: string): string {
const label = LogisticsConstants.STATUS_LABELS_SHORT[status] as string | null
return (label != null) ? label : '状态'
},
getLatestEventText(): string {
if (this.statusHistory.length > 0) {
return this.statusHistory[0].event_text
}
return '暂无物流信息'
},
shouldShowStatus(index: number): boolean {
if (index == 0) return true
const current = this.statusHistory[index].status_code
const prev = this.statusHistory[index - 1].status_code
// 如果当前状态与上一个状态不同,或者是状态变更点,则显示标签
return current != '' && current != prev
},
copyTrackingNo() {
if (!this.order.tracking_no || this.order.tracking_no === '') {
uni.showToast({ title: '暂无运单号', icon: 'none' })
return
}
uni.setClipboardData({
data: this.order.tracking_no,
success: () => uni.showToast({ title: '单号已复制' })
})
},
callCourier() {
uni.makePhoneCall({ phoneNumber: '13800138000' })
},
async refresh() {
if (this.order.status === 'DELIVERED') {
uni.showToast({ title: '已签收包裹无需刷新', icon: 'none' })
return
}
uni.showLoading({ title: '同步中...' })
const success = await mockService.syncFromCloud(this.orderNo)
uni.hideLoading()
if (success) {
this.loadData()
uni.showToast({ title: '已同步最新动态' })
} else {
uni.showToast({ title: '暂无更多更新', icon: 'none' })
}
},
async confirmReceipt() {
uni.showModal({
title: '确认取件',
content: '您是否已收到包裹?确认后订单将变更为已签收状态。',
success: async (res) => {
if (res.confirm) {
uni.showLoading({ title: '处理中...' })
const ok = await mockService.confirmReceipt(this.orderNo)
uni.hideLoading()
if (ok) {
uni.showToast({ title: '取件成功' })
this.loadData()
} else {
uni.showToast({ title: '操作失败,请重试', icon: 'none' })
}
}
}
})
},
previewImage(url: string, urls: string[]) {
uni.previewImage({ current: url, urls: urls })
},
showSupport() {
uni.showToast({ title: '接入平台客服中...', icon: 'none' })
},
getStatusLabel(code: string): string {
return mockService.getStatusText(code)
},
splitText(text: string, statusCode: string): UTSJSONObject[] {
// 1. 先进行描述泛化(运输中脱敏)
const processedText = mockService.getDisplayMessage(text, statusCode)
const res = [] as UTSJSONObject[]
const regex = /(1[3-9]\d{9})/g
const parts = processedText.split(regex)
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (i % 2 === 1) {
// 消费者端:仅在派送中环节展示手机号且必须脱敏
if (statusCode === 'OUT_FOR_DELIVERY') {
const masked = part.substring(0, 3) + '****' + part.substring(7)
res.push({ text: masked, raw: part, isPhone: true } as UTSJSONObject)
} else {
res.push({ text: '[联系方式已隐藏]', raw: '', isPhone: false } as UTSJSONObject)
}
} else if (part.length > 0) {
res.push({ text: part, raw: '', isPhone: false } as UTSJSONObject)
}
}
return res
},
makeCall(phone: string) {
// 获取原始电话拨打
uni.makePhoneCall({ phoneNumber: phone })
}
}
}
</script>
<style lang="scss">
.container {
background-color: #f3f4f6;
min-height: 100vh;
padding-bottom: 350rpx;
}
.status-header {
background: linear-gradient(135deg, #007AFF, #0056b3);
padding: 60rpx 40rpx;
color: #ffffff;
}
.back-link-white {
color: #ffffff;
font-size: 28rpx;
margin-bottom: 20rpx;
display: block;
}
.status-main {
display: flex;
align-items: center;
gap: 30rpx;
}
.status-icon {
font-size: 80rpx;
}
.status-title {
font-size: 44rpx;
font-weight: bold;
display: block;
}
.status-desc-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin-top: 10rpx;
}
.status-desc-text {
font-size: 26rpx;
color: #ffffff;
opacity: 0.9;
line-height: 1.4;
}
.status-desc-text.highlight {
color: #FFD700;
font-weight: bold;
text-decoration: underline;
opacity: 1;
}
.event-title-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.event-title-text {
font-size: 26rpx;
color: #666;
line-height: 1.6;
}
.event-title-text.active {
color: #1a1a1a;
font-weight: 500;
}
.event-title-text.highlight {
color: #007AFF;
font-weight: bold;
text-decoration: underline;
}
.header-call-btn {
/* Lines 258-267 omitted */
}
.card {
background-color: #ffffff;
margin: 20rpx;
border-radius: 20rpx;
padding: 30rpx;
}
.carrier-info {
display: flex;
align-items: center;
gap: 20rpx;
}
.carrier-logo {
width: 80rpx;
height: 80rpx;
background-color: #f0f0f0;
border-radius: 50%;
}
.carrier-detail {
flex: 1;
}
.carrier-name {
font-size: 30rpx;
font-weight: bold;
display: block;
}
.waybill-no {
font-size: 24rpx;
color: #666;
}
.copy-btn {
font-size: 22rpx;
padding: 0 20rpx;
height: 44rpx;
line-height: 44rpx;
border-radius: 22rpx;
background-color: #eef2f7;
color: #007AFF;
}
.courier-box {
margin-top: 30rpx;
padding-top: 30rpx;
border-top: 1rpx solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.courier-tip {
font-size: 26rpx;
color: #27ae60;
font-weight: bold;
}
.call-btn {
font-size: 24rpx;
background-color: #007AFF;
color: #fff;
margin: 0;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 30rpx;
}
.info-item {
display: flex;
justify-content: flex-start;
margin-bottom: 20rpx;
}
.info-label {
width: 160rpx;
font-size: 26rpx;
color: #999;
}
.info-value {
flex: 1;
font-size: 26rpx;
color: #333;
text-align: left;
word-break: break-all;
}
.empty-state {
padding: 40rpx;
text-align: center;
}
.empty-text {
color: #999;
font-size: 24rpx;
}
.timeline-item {
display: flex;
flex-direction: row;
}
.axis {
width: 30rpx;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
margin-right: 15rpx;
}
.line {
width: 2rpx;
flex: 1;
background-color: #eee;
}
.dot {
width: 12rpx;
height: 12rpx;
background-color: #ddd;
border-radius: 50%;
z-index: 1;
margin-top: 14rpx;
}
.dot.active {
background-color: #007AFF;
width: 16rpx;
height: 16rpx;
box-shadow: 0 0 8rpx rgba(0,122,255,0.3);
margin-top: 12rpx;
}
.content {
flex: 1;
padding-bottom: 24rpx;
}
.event-header {
margin-bottom: 4rpx;
}
.status-time-row {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: 12rpx;
margin-bottom: 6rpx;
}
.status-label {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.status-label.active {
color: #007AFF;
}
.event-time.margin-left {
margin-left: 0;
}
/* 当状态标签隐藏时,时间不需要左间距,但为了对齐建议保持一定偏离或者在没有标签时直接显示 */
.event-title {
font-size: 26rpx;
color: #666;
display: block;
line-height: 1.6;
text-align: left;
}
.event-title.active {
color: #1a1a1a;
font-weight: 500;
}
.event-time {
font-size: 24rpx;
color: #999;
text-align: left;
}
.event-time.active {
color: #007AFF;
}
.evidence-grid {
display: flex;
gap: 16rpx;
margin-top: 20rpx;
}
.evidence-img {
width: 140rpx;
height: 140rpx;
border-radius: 12rpx;
}
.bottom-actions {
position: fixed;
bottom: 0; left: 0; right: 0;
background-color: #fff;
padding: 16rpx 30rpx 30rpx;
display: flex;
flex-direction: column;
gap: 12rpx;
box-shadow: 0 -4rpx 16rpx rgba(0,0,0,0.08);
z-index: 100;
}
.action-item {
width: 100%;
font-size: 26rpx;
background-color: #f8fafc;
border: 1rpx solid #e2e8f0;
margin: 0;
height: 72rpx;
line-height: 72rpx;
border-radius: 36rpx;
}
.action-item.primary {
background-color: #409eff;
color: #fff;
border-color: #409eff;
font-weight: 500;
}
</style>