Bläddra i källkod

feat(shop): after-sales system — apply/list/detail pages + mock logistics + profile/order entries

Xiaogang Liao 2 månader sedan
förälder
incheckning
fcfb4a67ed

+ 2 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/AfterSalesService.java

@@ -29,8 +29,8 @@ public class AfterSalesService {
         if (!order.getBuyerId().equals(userId)) {
             return Result.error("无权操作此订单");
         }
-        if (!"paid".equals(order.getStatus())) {
-            return Result.error("仅已支付的订单可申请售后");
+        if (!"paid".equals(order.getStatus()) && !"shipped".equals(order.getStatus()) && !"pending_receipt".equals(order.getStatus())) {
+            return Result.error("仅已支付、未完成的订单可申请售后");
         }
         request.setUserId(userId);
         request.setOrderNo(order.getOrderNo());

+ 46 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -15,6 +15,7 @@ import com.etotem.cfc.service.CommissionService;
 import org.springframework.stereotype.Service;
 
 import javax.annotation.Resource;
+import java.text.SimpleDateFormat;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
@@ -445,10 +446,54 @@ public class ProductOrderService {
         result.put("logisticsNo", order.getLogisticsNo());
         result.put("logisticsCompany", order.getLogisticsCompany());
         result.put("status", order.getStatus());
-        result.put("trackingData", new ArrayList<>());
+        result.put("trackingData", generateTrackingData(order));
         return Result.success(result);
     }
 
+    private List<Map<String, Object>> generateTrackingData(ProductOrder order) {
+        List<Map<String, Object>> list = new ArrayList<>();
+        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
+        String status = order.getStatus();
+        Date paidAt = order.getPaidAt() != null ? order.getPaidAt() : order.getCreatedAt();
+        // base timestamp in ms
+        long base = paidAt.getTime();
+
+        if ("paid".equals(status)) {
+            // paid only — no tracking yet
+            addTrack(list, sdf.format(new Date(base)), "订单已支付,等待发货");
+            return list;
+        }
+        if ("cancelled".equals(status)) {
+            addTrack(list, sdf.format(new Date(base)), "订单已取消");
+            return list;
+        }
+
+        // shipped / pending_receipt / completed share first steps
+        addTrack(list, sdf.format(new Date(base + 3600000L)), "包裹已揽收");
+
+        if ("pending_receipt".equals(status) || "completed".equals(status)) {
+            addTrack(list, sdf.format(new Date(base + 7200000L)), "包裹已到达中转站");
+            addTrack(list, sdf.format(new Date(base + 14400000L)), "正在派送中,请保持电话畅通");
+        }
+        if ("completed".equals(status)) {
+            addTrack(list, sdf.format(new Date(base + 18000000L)), "包裹已签收,感谢购买");
+        } else if ("pending_receipt".equals(status)) {
+            addTrack(list, sdf.format(new Date(base + 14400000L)), "快件已到达,派送员正在派送");
+        } else {
+            // shipped but not yet delivered
+            addTrack(list, sdf.format(new Date(base + 4800000L)), "包裹正在运输中");
+        }
+
+        return list;
+    }
+
+    private void addTrack(List<Map<String, Object>> list, String time, String desc) {
+        Map<String, Object> item = new HashMap<>();
+        item.put("time", time);
+        item.put("content", desc);
+        list.add(item);
+    }
+
     private String generateOrderNo() {
         return "PO" + System.currentTimeMillis();
     }

+ 4 - 0
cfc-frontend/pages.json

@@ -441,6 +441,10 @@
         {
           "path": "after-sales-detail/after-sales-detail",
           "style": { "navigationBarTitleText": "售后详情" }
+        },
+        {
+          "path": "after-sales-apply/after-sales-apply",
+          "style": { "navigationBarTitleText": "申请售后" }
         }
       ]
     },

+ 8 - 1
cfc-frontend/pages/profile/components/ProfileMenu.vue

@@ -58,6 +58,10 @@
           <text>🛒 订单管理</text>
           <text class="arrow">›</text>
         </view>
+        <view class="menu-item" @click="goToAfterSales">
+          <text>🔄 售后记录</text>
+          <text class="arrow">›</text>
+        </view>
       </view>
 
       <!-- ===== 设置 ===== -->
@@ -180,7 +184,10 @@ export default {
       uni.navigateTo({ url: '/pages/vendor/center' })
     },
     goToOrders() {
-      uni.showToast({ title: '即将上线', icon: 'none' })
+      uni.navigateTo({ url: '/pages/shop/order-list/order-list' })
+    },
+    goToAfterSales() {
+      uni.navigateTo({ url: '/pages/shop/after-sales/after-sales' })
     },
     goToPromotion() {
       uni.navigateTo({ url: '/pages/promotion/index' })

+ 317 - 0
cfc-frontend/pages/shop/after-sales-apply/after-sales-apply.vue

@@ -0,0 +1,317 @@
+<template>
+  <view class="container">
+    <scroll-view scroll-y class="form-scroll">
+      <view class="section">
+        <text class="section-title">售后类型</text>
+        <view class="type-options">
+          <view
+            :class="['type-card', form.type === 'refund' ? 'active' : '']"
+            @click="form.type = 'refund'"
+          >
+            <text class="type-icon">💰</text>
+            <text class="type-name">仅退款</text>
+            <text class="type-desc">未收到货或与描述不符</text>
+          </view>
+          <view
+            :class="['type-card', form.type === 'return' ? 'active' : '']"
+            @click="form.type = 'return'"
+          >
+            <text class="type-icon">📦</text>
+            <text class="type-name">退货退款</text>
+            <text class="type-desc">需要将商品寄回后退款</text>
+          </view>
+        </view>
+      </view>
+
+      <view class="section">
+        <text class="section-title">退款原因</text>
+        <picker :range="reasons" @change="onReasonChange">
+          <view class="picker-value">
+            <text :class="['reason-text', form.reason ? '' : 'placeholder']">{{ form.reason || '请选择退款原因' }}</text>
+            <text class="arrow">›</text>
+          </view>
+        </picker>
+      </view>
+
+      <view class="section" v-if="showDescription">
+        <text class="section-title">问题描述(可选)</text>
+        <textarea
+          v-model="form.description"
+          class="desc-textarea"
+          placeholder="请详细描述遇到的问题,以便商家快速处理"
+          maxlength="500"
+        />
+        <text class="char-count">{{ (form.description || '').length }}/500</text>
+      </view>
+
+      <view class="section">
+        <text class="section-title">订单信息</text>
+        <view class="info-row">
+          <text class="info-label">订单编号</text>
+          <text class="info-value">{{ order.orderNo || '-' }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">商品名称</text>
+          <text class="info-value">{{ order.productName || '-' }}</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">退款金额</text>
+          <text class="info-value price">{{ formatPrice(refundAmount) }}</text>
+        </view>
+      </view>
+
+      <view class="submit-wrap">
+        <button class="submit-btn" :disabled="submitting" @click="onSubmit">{{ submitting ? '提交中...' : '提交申请' }}</button>
+      </view>
+    </scroll-view>
+  </view>
+</template>
+
+<script>
+import config from '@/config.js'
+
+export default {
+  data() {
+    return {
+      orderId: null,
+      orderNo: '',
+      order: {},
+      form: {
+        type: 'refund',
+        reason: '',
+        description: ''
+      },
+      reasons: [
+        '商品质量问题',
+        '与描述不符',
+        '发错货',
+        '缺件/少发',
+        '不想要了',
+        '商家发错地址',
+        '其他原因'
+      ],
+      submitting: false,
+      refundAmount: 0
+    }
+  },
+  computed: {
+    showDescription() {
+      return this.form.reason && this.form.reason !== ''
+    }
+  },
+  onLoad(options) {
+    if (options.orderId) {
+      this.orderId = parseInt(options.orderId)
+    }
+    if (options.orderNo) {
+      this.orderNo = options.orderNo
+    }
+    this.loadOrder()
+  },
+  methods: {
+    loadOrder() {
+      if (!this.orderNo) return
+      var that = this
+      uni.request({
+        url: config.api('/api/product/order/detail'),
+        method: 'POST',
+        data: { orderNo: this.orderNo },
+        header: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer ' + uni.getStorageSync('token')
+        },
+        success: function(res) {
+          if (res.data && res.data.code === 200) {
+            that.order = res.data.data || {}
+            that.refundAmount = that.order.totalAmount || 0
+          }
+        }
+      })
+    },
+    onReasonChange(e) {
+      this.form.reason = this.reasons[e.detail.value]
+    },
+    onSubmit() {
+      if (!this.form.type) {
+        uni.showToast({ title: '请选择售后类型', icon: 'none' })
+        return
+      }
+      if (!this.form.reason) {
+        uni.showToast({ title: '请选择退款原因', icon: 'none' })
+        return
+      }
+      if (!this.orderId) {
+        uni.showToast({ title: '订单信息缺失', icon: 'none' })
+        return
+      }
+      this.submitting = true
+      var that = this
+      uni.request({
+        url: config.api('/api/shop/after-sales/create'),
+        method: 'POST',
+        data: {
+          orderId: this.orderId,
+          type: this.form.type,
+          reason: this.form.reason,
+          description: this.form.description
+        },
+        header: {
+          'Content-Type': 'application/json',
+          'Authorization': 'Bearer ' + uni.getStorageSync('token')
+        },
+        success: function(res) {
+          that.submitting = false
+          if (res.data && res.data.code === 200) {
+            uni.showToast({ title: '申请已提交', icon: 'success' })
+            setTimeout(function() {
+              uni.navigateTo({ url: '/pages/shop/after-sales/after-sales' })
+            }, 1000)
+          } else {
+            uni.showToast({ title: res.data.message || '提交失败', icon: 'none' })
+          }
+        },
+        fail: function() {
+          that.submitting = false
+          uni.showToast({ title: '网络请求失败', icon: 'none' })
+        }
+      })
+    },
+    formatPrice(amount) {
+      if (amount === null || amount === undefined) return '¥0.00'
+      return '¥' + (Number(amount) / 100).toFixed(2)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: #f5f5f5;
+}
+.form-scroll {
+  padding-bottom: 40rpx;
+}
+.section {
+  background: #fff;
+  margin: 20rpx;
+  border-radius: 16rpx;
+  padding: 24rpx;
+}
+.section-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 20rpx;
+  display: block;
+}
+.type-options {
+  display: flex;
+  gap: 20rpx;
+}
+.type-card {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 30rpx 20rpx;
+  border: 2rpx solid #eee;
+  border-radius: 12rpx;
+  text-align: center;
+}
+.type-card.active {
+  border-color: #F97316;
+  background: #fff7e6;
+}
+.type-icon {
+  font-size: 48rpx;
+  margin-bottom: 12rpx;
+}
+.type-name {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 8rpx;
+}
+.type-desc {
+  font-size: 22rpx;
+  color: #999;
+}
+.picker-value {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 20rpx;
+  background: #f8f8f8;
+  border-radius: 8rpx;
+}
+.reason-text {
+  font-size: 26rpx;
+  color: #333;
+}
+.reason-text.placeholder {
+  color: #ccc;
+}
+.arrow {
+  font-size: 36rpx;
+  color: #ccc;
+}
+.desc-textarea {
+  width: 100%;
+  min-height: 160rpx;
+  padding: 20rpx;
+  background: #f8f8f8;
+  border-radius: 8rpx;
+  font-size: 26rpx;
+  color: #333;
+  box-sizing: border-box;
+}
+.char-count {
+  display: block;
+  text-align: right;
+  font-size: 22rpx;
+  color: #ccc;
+  margin-top: 8rpx;
+}
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  padding: 12rpx 0;
+  border-bottom: 1rpx solid #f5f5f5;
+}
+.info-row:last-child {
+  border-bottom: none;
+}
+.info-label {
+  font-size: 26rpx;
+  color: #999;
+}
+.info-value {
+  font-size: 26rpx;
+  color: #333;
+}
+.price {
+  color: #F97316;
+  font-weight: bold;
+  font-size: 30rpx;
+}
+.submit-wrap {
+  padding: 30rpx;
+}
+.submit-btn {
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #fff;
+  font-size: 30rpx;
+  font-weight: bold;
+  height: 88rpx;
+  line-height: 88rpx;
+  border-radius: 44rpx;
+  border: none;
+}
+.submit-btn::after {
+  border: none;
+}
+.submit-btn[disabled] {
+  opacity: 0.6;
+}
+</style>

+ 2 - 0
cfc-frontend/pages/shop/logistics/logistics.vue

@@ -80,6 +80,7 @@ export default {
       var map = {
         pending: '待发货',
         shipped: '已发货',
+        pending_receipt: '派送中',
         signing: '派送中',
         completed: '已签收'
       }
@@ -189,6 +190,7 @@ export default {
   background: #e6f7ff;
   color: #1890ff;
 }
+.status-pending_receipt,
 .status-signing {
   background: #fff7e6;
   color: #fa8c16;

+ 25 - 0
cfc-frontend/pages/shop/order-detail/order-detail.vue

@@ -80,7 +80,9 @@
         <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.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>
+        <button v-if="order.status === 'completed' || order.status === 'refunding' || order.status === 'refunded'" class="btn-after-sales" @click="onAfterSales">售后记录</button>
       </view>
     </scroll-view>
     <view v-else class="empty-wrap">
@@ -169,6 +171,16 @@ export default {
         uni.navigateTo({ url: '/pages/shop/detail/detail?id=' + this.order.items[0].productId })
       }
     },
+    onAfterSales() {
+      uni.navigateTo({ url: '/pages/shop/after-sales/after-sales' })
+    },
+    onApplyAfterSales() {
+      if (this.order && this.order.id) {
+        uni.navigateTo({ url: '/pages/shop/after-sales-apply/after-sales-apply?orderId=' + this.order.id + '&orderNo=' + this.order.orderNo })
+      } else {
+        uni.showToast({ title: '订单信息缺失', icon: 'none' })
+      }
+    },
     statusLabel(status) {
       const map = {
         pending: '待支付',
@@ -340,6 +352,19 @@ export default {
 .btn-cancel::after {
   border: none;
 }
+.btn-after-sales {
+  background: #fff;
+  color: #F97316;
+  font-size: 24rpx;
+  padding: 0 24rpx;
+  height: 60rpx;
+  line-height: 60rpx;
+  border-radius: 30rpx;
+  border: 2rpx solid #F97316;
+}
+.btn-after-sales::after {
+  border: none;
+}
 .btn-pay {
   background: linear-gradient(135deg, #F97316, #FB923C);
   color: #fff;

+ 8 - 1
cfc-frontend/pages/shop/order-list/order-list.vue

@@ -68,7 +68,7 @@
               <button
                 v-if="(item.status === 'paid' || item.status === 'shipped') && item.refundStatus !== 1 && item.refundStatus !== 2"
                 class="btn-small btn-refund"
-                @click.stop="onApplyRefund(item.orderNo)"
+                @click.stop="goAfterSales(item.id, item.orderNo)"
               >申请退款</button>
             </view>
           </view>
@@ -222,6 +222,13 @@ export default {
         }
       })
     },
+    goAfterSales(orderId, orderNo) {
+      var url = '/pages/shop/after-sales-apply/after-sales-apply?orderId=' + orderId
+      if (orderNo) {
+        url = url + '&orderNo=' + orderNo
+      }
+      uni.navigateTo({ url: url })
+    },
     statusLabel(status) {
       const map = {
         pending: '待支付',