Просмотр исходного кода

feat(frontend): 商品配送与履约前端适配

E2E Test Bot 1 месяц назад
Родитель
Сommit
d7a697a3b5

+ 61 - 0
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -52,6 +52,24 @@
         </view>
       </view>
 
+      <!-- 配送方式说明 -->
+      <view v-if="product.deliveryMethod > 1" class="delivery-info-section">
+        <text class="section-title">配送方式</text>
+        <view class="delivery-method-tag">{{ deliveryMethodLabel(product.deliveryMethod) }}</view>
+        <view v-if="product.deliveryMethod === 2 || product.deliveryMethod === 3" class="delivery-hint">
+          <text class="hint-icon">📍</text>
+          <text class="hint-text">支持到店自取,下单时可选择自取地点和时间段</text>
+        </view>
+        <view v-if="product.deliveryMethod === 4" class="delivery-hint">
+          <text class="hint-icon">👨‍⚕️</text>
+          <text class="hint-text">线上服务,可选择服务人员和预约时间</text>
+        </view>
+        <view v-if="product.deliveryMethod === 5" class="delivery-hint">
+          <text class="hint-icon">📅</text>
+          <text class="hint-text">线上预约服务,下单时选择时间段</text>
+        </view>
+      </view>
+
       <!-- 积分兑换优惠券 -->
       <view class="coupon-exchange-section" v-if="productCoupons.length > 0">
         <text class="section-title">积分兑换优惠券</text>
@@ -428,6 +446,10 @@ export default {
     goUpgrade: function() {
       uni.navigateTo({ url: '/pages/membership/upgrade' })
     },
+    deliveryMethodLabel: function(method) {
+      var map = { 1: '快递配送', 2: '到店自提', 3: '快递/自提', 4: '线上服务', 5: '线上预约' }
+      return map[method] || '快递配送'
+    },
 
     onBuy: function() {
       if (this.buyButtonDisabled) {
@@ -452,6 +474,12 @@ export default {
         productData.specDesc = this.buildSpecDesc()
       }
       uni.setStorageSync('checkoutItems', [productData])
+      var deliveryMethod = this.product.deliveryMethod || 1
+      if (deliveryMethod > 1 && this.product.deliveryConfig) {
+        productData.deliveryMethod = deliveryMethod
+        productData.deliveryConfig = this.product.deliveryConfig
+      }
+      uni.setStorageSync('checkoutItems', [productData])
       uni.navigateTo({
         url: '/pages/shop/checkout/checkout?from=product&id=' + this.product.id
       })
@@ -857,6 +885,39 @@ export default {
   margin-right: 16rpx;
   text-align: center;
 }
+
+/* 配送信息 */
+.delivery-info-section {
+  background: #fff;
+  margin: 20rpx;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+.delivery-method-tag {
+  display: inline-block;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+  font-size: 24rpx;
+  padding: 6rpx 20rpx;
+  border-radius: 20rpx;
+  margin-bottom: 12rpx;
+}
+.delivery-hint {
+  display: flex;
+  align-items: flex-start;
+  margin-top: 12rpx;
+}
+.hint-icon {
+  font-size: 28rpx;
+  margin-right: 10rpx;
+  flex-shrink: 0;
+}
+.hint-text {
+  font-size: 24rpx;
+  color: #666;
+  line-height: 1.6;
+  flex: 1;
+}
 .btn-share::after {
   border: none;
 }

+ 471 - 3
cfc-frontend/pages/shop/checkout/checkout.vue

@@ -24,6 +24,86 @@
         </view>
       </view>
 
+      <!-- 配送方式选择 -->
+      <view class="section" v-if="deliveryMethod > 1">
+        <text class="section-title">{{ deliveryMethodLabel(deliveryMethod) }}</text>
+        <!-- 自提/线上服务区域 -->
+        <view v-if="deliveryMethod === 2 || deliveryMethod === 3">
+          <view class="delivery-sub-label">自取地点</view>
+          <view class="picker-row" @click="showPickupLocationPicker">
+            <text class="picker-value" v-if="selectedPickupLocation">{{ selectedPickupLocation.name }}</text>
+            <text class="picker-placeholder" v-else>请选择自取地点</text>
+            <text class="picker-arrow">›</text>
+          </view>
+          <view v-if="selectedPickupLocation && selectedPickupLocation.timeSlots && selectedPickupLocation.timeSlots.length > 0" class="picker-row" @click="showTimeSlotPicker">
+            <text class="picker-value" v-if="selectedTimeSlot">{{ selectedTimeSlot.startTime }} - {{ selectedTimeSlot.endTime }}</text>
+            <text class="picker-placeholder" v-else>请选择取货时间</text>
+            <text class="picker-arrow">›</text>
+          </view>
+        </view>
+
+        <view v-if="deliveryMethod === 4">
+          <view class="picker-row" @click="showServicePersonPicker">
+            <text class="picker-value" v-if="selectedServicePerson">{{ selectedServicePerson.name }}({{ selectedServicePerson.title || '服务者' }})</text>
+            <text class="picker-placeholder" v-else>请选择服务者</text>
+            <text class="picker-arrow">›</text>
+          </view>
+          <view v-if="selectedServicePerson" class="picker-row" @click="showOnlineSlotPicker">
+            <text class="picker-value" v-if="selectedTimeSlot">{{ formatOnlineTime(selectedTimeSlot.startTime) }} - {{ formatOnlineTime(selectedTimeSlot.endTime) }}</text>
+            <text class="picker-placeholder" v-else>请选择服务时间</text>
+            <text class="picker-arrow">›</text>
+          </view>
+        </view>
+
+        <view v-if="deliveryMethod === 5">
+          <view class="picker-row" @click="showOnlineSlotPicker">
+            <text class="picker-value" v-if="selectedTimeSlot">{{ formatOnlineTime(selectedTimeSlot.startTime) }} - {{ formatOnlineTime(selectedTimeSlot.endTime) }}</text>
+            <text class="picker-placeholder" v-else>请选择预约时段</text>
+            <text class="picker-arrow">›</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 配送方式选择 -->
+      <view class="section" v-if="deliveryMethod > 1">
+        <text class="section-title">{{ deliveryMethodLabel(deliveryMethod) }}</text>
+        <!-- 自提/线上服务区域 -->
+        <view v-if="deliveryMethod === 2 || deliveryMethod === 3">
+          <view class="delivery-sub-label">自取地点</view>
+          <view class="picker-row" @click="showPickupLocationPicker">
+            <text class="picker-value" v-if="selectedPickupLocation">{{ selectedPickupLocation.name }}</text>
+            <text class="picker-placeholder" v-else>请选择自取地点</text>
+            <text class="picker-arrow">›</text>
+          </view>
+          <view v-if="selectedPickupLocation && selectedPickupLocation.timeSlots && selectedPickupLocation.timeSlots.length > 0" class="picker-row" @click="showTimeSlotPicker">
+            <text class="picker-value" v-if="selectedTimeSlot">{{ selectedTimeSlot.startTime }} - {{ selectedTimeSlot.endTime }}</text>
+            <text class="picker-placeholder" v-else>请选择取货时间</text>
+            <text class="picker-arrow">›</text>
+          </view>
+        </view>
+
+        <view v-if="deliveryMethod === 4">
+          <view class="picker-row" @click="showServicePersonPicker">
+            <text class="picker-value" v-if="selectedServicePerson">{{ selectedServicePerson.name }}({{ selectedServicePerson.title || '服务者' }})</text>
+            <text class="picker-placeholder" v-else>请选择服务者</text>
+            <text class="picker-arrow">›</text>
+          </view>
+          <view v-if="selectedServicePerson" class="picker-row" @click="showOnlineSlotPicker">
+            <text class="picker-value" v-if="selectedTimeSlot">{{ formatOnlineTime(selectedTimeSlot.startTime) }} - {{ formatOnlineTime(selectedTimeSlot.endTime) }}</text>
+            <text class="picker-placeholder" v-else>请选择服务时间</text>
+            <text class="picker-arrow">›</text>
+          </view>
+        </view>
+
+        <view v-if="deliveryMethod === 5">
+          <view class="picker-row" @click="showOnlineSlotPicker">
+            <text class="picker-value" v-if="selectedTimeSlot">{{ formatOnlineTime(selectedTimeSlot.startTime) }} - {{ formatOnlineTime(selectedTimeSlot.endTime) }}</text>
+            <text class="picker-placeholder" v-else>请选择预约时段</text>
+            <text class="picker-arrow">›</text>
+          </view>
+        </view>
+      </view>
+
       <!-- Purchase Info Section (when product requires it) -->
       <view class="section" v-if="requiredFields.length > 0">
         <text class="section-title">购买信息</text>
@@ -182,6 +262,113 @@
       </view>
     </view>
 
+    <!-- 自取地点选择器 -->
+    <view class="modal-mask" v-if="showPickupLocationModal" @click="showPickupLocationModal=false">
+      <view class="picker-modal" @click.stop>
+        <view class="picker-header">
+          <text class="picker-title">选择自取地点</text>
+          <text class="picker-close" @click="showPickupLocationModal=false">关闭</text>
+        </view>
+        <scroll-view scroll-y class="picker-list">
+          <view
+            v-for="(loc, idx) in pickupLocations"
+            :key="loc.id"
+            class="picker-item"
+            :class="selectedPickupLocation && selectedPickupLocation.id === loc.id ? 'picker-item-active' : ''"
+            @click="selectPickupLocation(loc)"
+          >
+            <view class="picker-item-left">
+              <text class="picker-item-name">{{ loc.name }}</text>
+              <text class="picker-item-addr">{{ loc.address }}</text>
+            </view>
+            <view class="picker-item-check" v-if="selectedPickupLocation && selectedPickupLocation.id === loc.id">
+              <text class="check-icon">✓</text>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+    </view>
+
+    <!-- 时间段选择器 -->
+    <view class="modal-mask" v-if="showTimeSlotModal" @click="showTimeSlotModal=false">
+      <view class="picker-modal" @click.stop>
+        <view class="picker-header">
+          <text class="picker-title">选择取货时间</text>
+          <text class="picker-close" @click="showTimeSlotModal=false">关闭</text>
+        </view>
+        <scroll-view scroll-y class="picker-list">
+          <view
+            v-for="(slot, idx) in (selectedPickupLocation && selectedPickupLocation.timeSlots) || []"
+            :key="slot.id"
+            class="picker-item"
+            :class="selectedTimeSlot && selectedTimeSlot.id === slot.id ? 'picker-item-active' : ''"
+            @click="selectTimeSlot(slot)"
+          >
+            <view class="picker-item-left">
+              <text class="picker-item-name">{{ slot.startTime }} - {{ slot.endTime }}</text>
+            </view>
+            <view class="picker-item-check" v-if="selectedTimeSlot && selectedTimeSlot.id === slot.id">
+              <text class="check-icon">✓</text>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+    </view>
+
+    <!-- 服务者选择器 -->
+    <view class="modal-mask" v-if="showServicePersonModal" @click="showServicePersonModal=false">
+      <view class="picker-modal" @click.stop>
+        <view class="picker-header">
+          <text class="picker-title">选择服务者</text>
+          <text class="picker-close" @click="showServicePersonModal=false">关闭</text>
+        </view>
+        <scroll-view scroll-y class="picker-list">
+          <view
+            v-for="(person, idx) in servicePersons"
+            :key="person.id"
+            class="picker-item"
+            :class="selectedServicePerson && selectedServicePerson.id === person.id ? 'picker-item-active' : ''"
+            @click="selectServicePerson(person)"
+          >
+            <view class="picker-item-left">
+              <text class="picker-item-name">{{ person.name }}</text>
+              <text class="picker-item-desc">{{ person.title || '' }}</text>
+              <text class="picker-item-price">{{ formatPriceWithSymbol(person.price) }}</text>
+            </view>
+            <view class="picker-item-check" v-if="selectedServicePerson && selectedServicePerson.id === person.id">
+              <text class="check-icon">✓</text>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+    </view>
+
+    <!-- 线上时间段选择器 -->
+    <view class="modal-mask" v-if="showOnlineSlotModal" @click="showOnlineSlotModal=false">
+      <view class="picker-modal" @click.stop>
+        <view class="picker-header">
+          <text class="picker-title">选择服务时间</text>
+          <text class="picker-close" @click="showOnlineSlotModal=false">关闭</text>
+        </view>
+        <scroll-view scroll-y class="picker-list">
+          <view
+            v-for="slot in onlineTimeSlots"
+            :key="slot.id"
+            class="picker-item"
+            :class="selectedTimeSlot && selectedTimeSlot.id === slot.id ? 'picker-item-active' : ''"
+            @click="selectOnlineSlot(slot)"
+          >
+            <view class="picker-item-left">
+              <text class="picker-item-name">{{ formatOnlineTime(slot.startTime) }} - {{ formatOnlineTime(slot.endTime) }}</text>
+            </view>
+            <view class="picker-item-check" v-if="selectedTimeSlot && selectedTimeSlot.id === slot.id">
+              <text class="check-icon">✓</text>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+    </view>
+
     <view class="bottom-bar">
       <view class="bottom-left">
         <text class="total-label">合计: </text>
@@ -199,7 +386,8 @@
 
 <script>
 import config from '@/config.js'
-import { getCouponList, addressList, getProductRequiredFields, getMyMembership } from '../../../utils/api.js'
+import { getCouponList, addressList, getProductRequiredFields, getMyMembership, productDetail } from '../../../utils/api.js'
+import { parseDate } from '@/utils/format.js'
 
 export default {
   data() {
@@ -220,7 +408,21 @@ export default {
       pointsUsed: 0,
       userTotalPoints: 0,
       pointsLoading: false,
-      isMember: false
+      isMember: false,
+      // 配送配置
+      deliveryMethod: 1,
+      productDeliveryConfig: null,
+      pickupLocations: [],
+      selectedPickupLocation: null,
+      selectedTimeSlot: null,
+      servicePersons: [],
+      selectedServicePerson: null,
+      onlineSlots: [],
+      // 弹窗控制
+      showPickupLocationModal: false,
+      showTimeSlotModal: false,
+      showServicePersonModal: false,
+      showOnlineSlotModal: false
     }
   },
   computed: {
@@ -255,6 +457,15 @@ export default {
         if (c.minSpend && c.minSpend > self.actualAmount) return false
         return true
       })
+    },
+    onlineTimeSlots() {
+      if (this.deliveryMethod === 4 && this.selectedServicePerson) {
+        return this.selectedServicePerson.slots || []
+      }
+      if (this.deliveryMethod === 5) {
+        return this.onlineSlots
+      }
+      return []
     }
   },
   onLoad(options) {
@@ -283,6 +494,7 @@ export default {
     this.loadRequiredFields()
     this.loadCoupons()
     this.loadUserPoints()
+    this.loadDeliveryConfig()
   },
   onShow() {
     // 从地址选择/编辑页返回后重新加载地址列表
@@ -421,6 +633,97 @@ export default {
     goAddAddress() {
       uni.navigateTo({ url: '/pages/shop/address-edit/address-edit' })
     },
+    // ========== 配送方式相关 ==========
+    loadDeliveryConfig() {
+      if (this.items.length === 0) return
+      var productId = this.items[0].productId
+      if (!productId) return
+      var stored = uni.getStorageSync('checkoutItems')
+      var storageData = stored && stored.length > 0 ? stored[0] : {}
+      var deliveryMethod = storageData.deliveryMethod
+      if (!deliveryMethod || deliveryMethod <= 1) return
+      this.deliveryMethod = deliveryMethod
+      this.productDeliveryConfig = storageData.deliveryConfig || null
+      // 如果来自产品详情,可能已有选中值
+      if (storageData.pickupLocationId) {
+        this.selectedPickupLocation = { id: storageData.pickupLocationId }
+      }
+      if (storageData.timeSlotId) {
+        this.selectedTimeSlot = { id: storageData.timeSlotId }
+      }
+      if (storageData.servicePersonId) {
+        this.selectedServicePerson = { id: storageData.servicePersonId }
+      }
+      // 加载完整配置
+      var that = this
+      productDetail({ id: productId }).then(function(res) {
+        if (res.code === 200 && res.data && res.data.deliveryConfig) {
+          that.productDeliveryConfig = res.data.deliveryConfig
+          that.deliveryMethod = res.data.deliveryMethod || that.deliveryMethod
+          that.pickupLocations = res.data.deliveryConfig.pickupLocations || []
+          that.servicePersons = res.data.deliveryConfig.servicePersons || []
+          that.onlineSlots = res.data.deliveryConfig.onlineSlots || []
+        }
+      }).catch(function() {})
+    },
+    onDeliveryMethodChange: function(e) {
+      this.deliveryMethod = parseInt(e.detail.value)
+      this.selectedPickupLocation = null
+      this.selectedTimeSlot = null
+      this.selectedServicePerson = null
+    },
+    onPickupLocationChange: function(e) {
+      var idx = parseInt(e.detail.value)
+      if (this.pickupLocations[idx]) {
+        this.selectedPickupLocation = this.pickupLocations[idx]
+        this.selectedTimeSlot = null
+      }
+    },
+    onTimeSlotChange: function(e) {
+      var idx = parseInt(e.detail.value)
+      if (this.selectedPickupLocation && this.selectedPickupLocation.timeSlots && this.selectedPickupLocation.timeSlots[idx]) {
+        this.selectedTimeSlot = this.selectedPickupLocation.timeSlots[idx]
+      }
+    },
+    onServicePersonChange: function(e) {
+      var idx = parseInt(e.detail.value)
+      if (this.servicePersons[idx]) {
+        this.selectedServicePerson = this.servicePersons[idx]
+        this.selectedTimeSlot = null
+      }
+    },
+    onOnlineSlotChange: function(e) {
+      var idx = parseInt(e.detail.value)
+      if (this.onlineSlots[idx]) {
+        this.selectedTimeSlot = this.onlineSlots[idx]
+      }
+    },
+    deliveryMethodLabel: function(method) {
+      var map = { 1: '快递配送', 2: '到店自提', 3: '快递/自提', 4: '线上服务', 5: '线上预约' }
+      return map[method] || '快递配送'
+    },
+    deliveryMethodHint: function(method) {
+      var map = { 1: '', 2: '请选择自取地点和时间段', 3: '请选择配送方式', 4: '请选择服务者和时间', 5: '请选择预约时段' }
+      return map[method] || ''
+    },
+    selectPickupLocation(loc) {
+      this.selectedPickupLocation = loc
+      this.selectedTimeSlot = null
+      this.showPickupLocationModal = false
+    },
+    selectTimeSlot(slot) {
+      this.selectedTimeSlot = slot
+      this.showTimeSlotModal = false
+    },
+    selectServicePerson(person) {
+      this.selectedServicePerson = person
+      this.selectedTimeSlot = null
+      this.showServicePersonModal = false
+    },
+    selectOnlineSlot(slot) {
+      this.selectedTimeSlot = slot
+      this.showOnlineSlotModal = false
+    }
     loadUserPoints() {
       var that = this
       uni.request({
@@ -468,7 +771,11 @@ export default {
           remark: this.remark,
           addressId: that.selectedAddress.id,
           purchaseInfo: that.purchaseForm,
-          pointsUsed: that.pointsUsed
+          pointsUsed: that.pointsUsed,
+          deliveryMethod: that.deliveryMethod,
+          pickupLocationId: that.selectedPickupLocation ? that.selectedPickupLocation.id : null,
+          timeSlotId: that.selectedTimeSlot ? that.selectedTimeSlot.id : null,
+          servicePersonId: that.selectedServicePerson ? that.selectedServicePerson.id : null
         },
         header: {
           'Content-Type': 'application/json',
@@ -508,6 +815,47 @@ export default {
       }
       return phone
     },
+    selectServicePerson(person) {
+      this.selectedServicePerson = person
+      this.selectedTimeSlot = null
+      this.showServicePersonModal = false
+    },
+    selectOnlineSlot(slot) {
+      this.selectedTimeSlot = slot
+      this.showOnlineSlotModal = false
+    },
+    formatOnlineTime: function(timeStr) {
+      if (!timeStr) return ''
+      try {
+        var d = parseDate(timeStr)
+        if (!d) return timeStr
+        return (d.getMonth() + 1) + '月' + d.getDate() + '日 ' + String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
+      } catch (e) {
+        return timeStr
+      }
+    },
+    showPickupLocationPicker: function() {
+      this.showPickupLocationModal = true
+    },
+    showTimeSlotPicker: function() {
+      if (!this.selectedPickupLocation) return
+      this.showTimeSlotModal = true
+    },
+    showServicePersonPicker: function() {
+      this.showServicePersonModal = true
+    },
+    showOnlineSlotPicker: function() {
+      this.showOnlineSlotModal = true
+    },
+    selectPickupLocation(loc) {
+      this.selectedPickupLocation = loc
+      this.selectedTimeSlot = null
+      this.showPickupLocationModal = false
+    },
+    selectTimeSlot(slot) {
+      this.selectedTimeSlot = slot
+      this.showTimeSlotModal = false
+    }
     getImageUrl: function(path) {
       return config.API_BASE_URL + path
     }
@@ -1029,4 +1377,124 @@ export default {
   color: #67c23a;
   margin-left: 8rpx;
 }
+
+/* 配送方式选择 */
+.delivery-sub-label {
+  font-size: 24rpx;
+  color: #999;
+  margin-bottom: 12rpx;
+  margin-top: 16rpx;
+}
+.picker-row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 20rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+.picker-row:last-child {
+  border-bottom: none;
+}
+.picker-value {
+  font-size: 28rpx;
+  color: #333;
+  flex: 1;
+}
+.picker-placeholder {
+  font-size: 28rpx;
+  color: #999;
+  flex: 1;
+}
+.picker-arrow {
+  font-size: 32rpx;
+  color: #ccc;
+  margin-left: 10rpx;
+}
+.modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0,0,0,0.5);
+  z-index: 999;
+  display: flex;
+  align-items: flex-end;
+}
+.picker-modal {
+  width: 100%;
+  background: #fff;
+  border-radius: 24rpx 24rpx 0 0;
+  max-height: 70vh;
+  display: flex;
+  flex-direction: column;
+}
+.picker-modal .picker-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 30rpx;
+  border-bottom: 1rpx solid #eee;
+}
+.picker-modal .picker-title {
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #333;
+}
+.picker-modal .picker-close {
+  font-size: 28rpx;
+  color: #999;
+}
+.picker-modal .picker-list {
+  flex: 1;
+  max-height: 50vh;
+  padding: 20rpx 30rpx;
+}
+.picker-modal .picker-item {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+.picker-modal .picker-item:last-child {
+  border-bottom: none;
+}
+.picker-modal .picker-item-active {
+  background: #fff7e6;
+  border-radius: 12rpx;
+  padding: 24rpx 16rpx;
+}
+.picker-modal .picker-item-left {
+  flex: 1;
+  min-width: 0;
+}
+.picker-modal .picker-item-name {
+  font-size: 28rpx;
+  color: #333;
+  display: block;
+}
+.picker-modal .picker-item-addr {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 6rpx;
+  display: block;
+}
+.picker-modal .picker-item-desc {
+  font-size: 24rpx;
+  color: #999;
+  margin-top: 4rpx;
+  display: block;
+}
+.picker-modal .picker-item-price {
+  font-size: 26rpx;
+  color: #F97316;
+  font-weight: bold;
+  margin-top: 4rpx;
+  display: block;
+}
+.picker-modal .picker-item-check {
+  margin-left: 16rpx;
+  color: #F97316;
+  font-size: 32rpx;
+}
 </style>

+ 107 - 2
cfc-frontend/pages/shop/order-detail/order-detail.vue

@@ -6,7 +6,11 @@
     <scroll-view v-else-if="order.id" scroll-y class="content">
       <view class="order-status-section">
         <text :class="['status-text', 'status-' + getDisplayStatus()]">{{ getDisplayStatusText() }}</text>
-        <text v-if="order.status === 'paid'" class="status-hint">商家正在准备中,请耐心等待</text>
+        <!-- 履约状态 -->
+        <text v-if="order.fulfillStatus" class="status-hint fulfill-status-hint">
+          {{ fulfillStatusLabel(order.fulfillStatus) }}
+        </text>
+        <text v-if="order.status === 'paid' && !order.fulfillStatus" class="status-hint">商家正在准备中,请耐心等待</text>
         <text v-if="order.status === 'completed'" class="status-hint">订单已完成,感谢购买</text>
         <text v-if="order.status === 'cancelled'" class="status-hint">订单已取消</text>
         <text v-if="order.refundStatus === 1" class="status-hint">退款申请处理中,请耐心等待</text>
@@ -71,6 +75,39 @@
         <text class="remark-text">{{ order.remark }}</text>
       </view>
 
+      <!-- 履约信息区块 -->
+      <view v-if="order.deliveryMethod === 2 || order.deliveryMethod === 3" class="section">
+        <text class="section-title">自取信息</text>
+        <view class="info-row" v-if="order.pickupLocationSnapshot">
+          <text class="info-label">自取地点</text>
+          <text class="info-value">{{ getJsonField(order.pickupLocationSnapshot, 'name') }}</text>
+        </view>
+        <view class="info-row" v-if="order.timeSlotSnapshot">
+          <text class="info-label">取货时间</text>
+          <text class="info-value">{{ getTimeSlotText(order.timeSlotSnapshot) }}</text>
+        </view>
+      </view>
+
+      <view v-if="order.deliveryMethod === 4" class="section">
+        <text class="section-title">服务信息</text>
+        <view class="info-row" v-if="order.servicePersonSnapshot">
+          <text class="info-label">服务人员</text>
+          <text class="info-value">{{ getJsonField(order.servicePersonSnapshot, 'name') }}</text>
+        </view>
+        <view class="info-row" v-if="order.timeSlotSnapshot">
+          <text class="info-label">服务时间</text>
+          <text class="info-value">{{ getOnlineTimeText(order.timeSlotSnapshot) }}</text>
+        </view>
+      </view>
+
+      <view v-if="order.deliveryMethod === 5" class="section">
+        <text class="section-title">服务时间</text>
+        <view class="info-row" v-if="order.timeSlotSnapshot">
+          <text class="info-label">预约时段</text>
+          <text class="info-value">{{ getOnlineTimeText(order.timeSlotSnapshot) }}</text>
+        </view>
+      </view>
+
       <view v-if="order.status === 'shipped' || order.status === 'completed'" class="section">
         <text class="section-title">物流信息</text>
         <view class="info-row">
@@ -93,6 +130,9 @@
     <view class="bottom-actions" v-if="!loading && order.id && order.status">
       <button v-if="order.status === 'pending'" class="btn-cancel" @click="onCancel">取消订单</button>
       <button v-if="order.status === 'pending'" class="btn-pay" @click="onPay">立即支付</button>
+      <!-- 履约确认收货按钮:线上服务/自取需要在供应商确认后用户才能确认 -->
+      <button v-if="order.fulfillStatus === 'confirmed_by_vendor' && (order.deliveryMethod === 4 || order.deliveryMethod === 5)" class="btn-confirm-receive" @click="onConfirmReceive">确认收货</button>
+      <button v-if="order.fulfillStatus === 'confirmed_by_vendor' && (order.deliveryMethod === 2 || order.deliveryMethod === 3)" class="btn-confirm-receive" @click="onConfirmReceive">确认收货</button>
       <button v-if="order.status === 'shipped'" class="btn-confirm-receive" @click="onConfirmReceive">确认收货</button>
       <button v-if="order.status === 'paid' || order.status === 'shipped' || order.status === 'pending_receipt'" class="btn-after-sales" @click="onApplyAfterSales">申请售后</button>
       <button v-if="order.status === 'completed'" class="btn-buy-again" @click="onBuyAgain">再次购买</button>
@@ -102,7 +142,7 @@
 </template>
 
 <script>
-import { productOrderDetail, productOrderConfirm, productOrderCancel } from '@/utils/api.js'
+import { productOrderDetail, productOrderConfirm, productOrderCancel, productOrderVendorConfirm } from '@/utils/api.js'
 import config from '@/config.js'
 import { parseDate } from '@/utils/format.js'
 
@@ -253,6 +293,62 @@ export default {
     },
     getImageUrl: function(path) {
       return config.API_BASE_URL + path
+    },
+    // 履约状态标签
+    fulfillStatusLabel: function(status) {
+      var map = {
+        'pending': '待履约',
+        'confirmed_by_vendor': '商家已确认,待您收货',
+        'received_by_user': '已确认收货',
+        'completed': '已完成'
+      }
+      return map[status] || status
+    },
+    // 从 JSON 快照中提取字段
+    getJsonField: function(snapshot, field) {
+      if (!snapshot) return '-'
+      try {
+        var obj = JSON.parse(snapshot)
+        return obj[field] || '-'
+      } catch (e) {
+        return snapshot
+      }
+    },
+    // 自取时间段文本
+    getTimeSlotText: function(snapshot) {
+      if (!snapshot) return '-'
+      try {
+        var obj = JSON.parse(snapshot)
+        var start = obj.startTime || ''
+        var end = obj.endTime || ''
+        if (start && end) return start + ' - ' + end
+        return obj.time || '-'
+      } catch (e) {
+        return snapshot
+      }
+    },
+    // 线上服务时间段文本
+    getOnlineTimeText: function(snapshot) {
+      if (!snapshot) return '-'
+      try {
+        var obj = JSON.parse(snapshot)
+        // 兼容时间戳和字符串格式
+        var start = obj.startTime || obj.start_time || ''
+        var end = obj.endTime || obj.end_time || ''
+        if (start && end) {
+          var sd = parseDate(start)
+          var ed = parseDate(end)
+          if (sd && ed) {
+            var startStr = sd.getFullYear() + '-' + String(sd.getMonth() + 1).padStart(2, '0') + '-' + String(sd.getDate()).padStart(2, '0') + ' ' + String(sd.getHours()).padStart(2, '0') + ':' + String(sd.getMinutes()).padStart(2, '0')
+            var endStr = ed.getFullYear() + '-' + String(ed.getMonth() + 1).padStart(2, '0') + '-' + String(ed.getDate()).padStart(2, '0') + ' ' + String(ed.getHours()).padStart(2, '0') + ':' + String(ed.getMinutes()).padStart(2, '0')
+            return startStr + ' - ' + endStr
+          }
+          return start + ' - ' + end
+        }
+        return start || end || '-'
+      } catch (e) {
+        return snapshot
+      }
     }
   }
 }
@@ -294,6 +390,12 @@ export default {
   font-size: 24rpx;
   color: rgba(255,255,255,0.85);
 }
+.order-status-section .fulfill-status-hint {
+  font-size: 22rpx;
+  color: rgba(255,255,255,0.7);
+  margin-top: 6rpx;
+  display: block;
+}
 .order-status-section { background: linear-gradient(135deg, #F97316, #FB923C); }
 .order-status-section .status-pending { background: linear-gradient(135deg, #F97316, #FB923C); }
 .order-status-section .status-paid { background: linear-gradient(135deg, #0284C7, #38BDF8); }
@@ -303,6 +405,9 @@ export default {
 .order-status-section .status-refunded { background: linear-gradient(135deg, #6B7280, #9CA3AF); }
 .order-status-section .status-cancelled { background: linear-gradient(135deg, #6B7280, #9CA3AF); }
 .order-status-section .status-refund-rejected { background: linear-gradient(135deg, #DC2626, #F87171); }
+.order-status-section .status-pending_receipt { background: linear-gradient(135deg, #0284C7, #38BDF8); }
+.order-status-section .status-confirmed_by_vendor { background: linear-gradient(135deg, #0284C7, #38BDF8); }
+.order-status-section .status-received_by_user { background: linear-gradient(135deg, #16A34A, #4ADE80); }
 .section {
   background: #fff;
   margin: 20rpx;