Browse Source

✨ feat: 增加退款功能

Pchen. 6 days ago
parent
commit
17ee90bedc

+ 219 - 0
src/components/store/OrderRefundPanel.vue

@@ -0,0 +1,219 @@
+<template>
+  <div class="refund-panel" :class="{ 'refund-panel--disabled': !canRefund }">
+    <div class="refund-panel__main">
+      <div class="refund-panel__icon-wrap" aria-hidden="true">
+        <icon-refresh />
+      </div>
+      <div class="refund-panel__text">
+        <div class="refund-panel__title">
+          {{ canRefund ? titleText : '暂无法退款' }}
+        </div>
+        <p class="refund-panel__desc">
+          {{ canRefund ? activeDesc : refundDisabledReason || '当前订单不满足退款条件' }}
+        </p>
+        <div v-if="canRefund && showSummary" class="refund-panel__summary">
+          <span class="refund-panel__chip">
+            退款 ¥{{ price }}
+          </span>
+          <span v-if="lepaoCount > 0" class="refund-panel__chip refund-panel__chip--muted">
+            <icon-thunderbolt /> 扣回 {{ lepaoCount }} 次
+          </span>
+          <span v-if="userLepaoCount != null" class="refund-panel__chip refund-panel__chip--muted">
+            剩余 {{ userLepaoCount }} 次
+          </span>
+        </div>
+      </div>
+    </div>
+    <div class="refund-panel__actions">
+      <a-button
+        v-if="canRefund"
+        type="primary"
+        status="warning"
+        size="large"
+        class="refund-panel__btn"
+        :loading="loading"
+        @click="$emit('refund')"
+      >
+        <template #icon><icon-refresh /></template>
+        {{ buttonText }}
+      </a-button>
+      <a-tooltip v-else :content="refundDisabledReason || '当前无法退款'">
+        <a-button size="large" class="refund-panel__btn" disabled>
+          <template #icon><icon-refresh /></template>
+          {{ buttonText }}
+        </a-button>
+      </a-tooltip>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+  canRefund: { type: Boolean, default: false },
+  refundDisabledReason: { type: String, default: '' },
+  loading: { type: Boolean, default: false },
+  price: { type: [Number, String], default: 0 },
+  lepaoCount: { type: Number, default: 0 },
+  userLepaoCount: { type: Number, default: null },
+  variant: {
+    type: String,
+    default: 'user',
+    validator: (v) => ['user', 'admin'].includes(v)
+  },
+  showSummary: { type: Boolean, default: true }
+})
+
+defineEmits(['refund'])
+
+const titleText = computed(() =>
+  props.variant === 'admin' ? '该订单支持发起退款' : '订单已完成,支持申请退款'
+)
+
+const buttonText = computed(() =>
+  props.variant === 'admin' ? '发起退款' : '申请退款'
+)
+
+const activeDesc = computed(() => {
+  if (props.variant === 'admin') {
+    return '确认后将原路退回款项,并扣回用户已发放的乐跑次数。'
+  }
+  if (props.lepaoCount > 0) {
+    return '退款成功后款项将原路退回,对应乐跑次数将从账户中扣回。'
+  }
+  return '退款成功后,款项将按原支付方式原路退回。'
+})
+</script>
+
+<style scoped lang="less">
+@import '@/styles/store-theme.less';
+
+.refund-panel {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-between;
+  gap: 20px;
+  padding: 20px 24px;
+  background: linear-gradient(135deg, #fff8f0 0%, #ffe8d6 55%, #ffd8bf 100%);
+  border: 1px solid #ffbb96;
+  border-radius: @store-radius;
+  box-shadow: 0 8px 24px rgba(232, 93, 4, 0.08);
+
+  &--disabled {
+    background: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%);
+    border-color: var(--color-border-2);
+    box-shadow: none;
+
+    .refund-panel__icon-wrap {
+      background: var(--color-fill-2);
+      color: var(--color-text-3);
+    }
+
+    .refund-panel__title {
+      color: var(--color-text-2);
+    }
+  }
+
+  &__main {
+    display: flex;
+    align-items: flex-start;
+    gap: 16px;
+    flex: 1 1 280px;
+    min-width: 0;
+  }
+
+  &__icon-wrap {
+    flex-shrink: 0;
+    width: 52px;
+    height: 52px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 14px;
+    font-size: 26px;
+    color: #d4380d;
+    background: rgba(255, 255, 255, 0.72);
+    border: 1px solid rgba(255, 187, 150, 0.8);
+    box-shadow: 0 4px 12px rgba(212, 56, 13, 0.1);
+  }
+
+  &__text {
+    min-width: 0;
+  }
+
+  &__title {
+    margin: 0;
+    font-size: 1.1rem;
+    font-weight: 600;
+    color: @store-primary;
+    line-height: 1.4;
+  }
+
+  &__desc {
+    margin: 6px 0 0;
+    font-size: 0.875rem;
+    line-height: 1.55;
+    color: @store-text-muted;
+  }
+
+  &__summary {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+    margin-top: 12px;
+  }
+
+  &__chip {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    padding: 4px 10px;
+    font-size: 0.8rem;
+    font-weight: 500;
+    color: #d4380d;
+    background: rgba(255, 255, 255, 0.75);
+    border: 1px solid rgba(255, 187, 150, 0.65);
+    border-radius: 999px;
+
+    &--muted {
+      color: @store-text-muted;
+      border-color: rgba(0, 0, 0, 0.06);
+      background: rgba(255, 255, 255, 0.55);
+      font-weight: 400;
+    }
+  }
+
+  &__actions {
+    flex-shrink: 0;
+  }
+
+  &__btn {
+    min-width: 148px;
+    border-radius: 999px !important;
+    font-weight: 600;
+    box-shadow: 0 4px 14px rgba(250, 140, 22, 0.28);
+
+    &:not(:disabled):hover {
+      box-shadow: 0 6px 18px rgba(250, 140, 22, 0.36);
+      transform: translateY(-1px);
+    }
+  }
+}
+
+@media (max-width: 768px) {
+  .refund-panel {
+    padding: 16px;
+
+    &__actions {
+      width: 100%;
+    }
+
+    &__btn {
+      width: 100%;
+      min-width: 0;
+    }
+  }
+}
+</style>

+ 21 - 22
src/pages/admin/goods/orderDetail.vue

@@ -20,6 +20,19 @@
           </div>
         </a-card>
 
+        <OrderRefundPanel
+          v-if="data.state === 2 && (data.canRefund || data.refundDisabledReason)"
+          class="refund-panel-wrap"
+          :can-refund="!!data.canRefund"
+          :refund-disabled-reason="data.refundDisabledReason"
+          :loading="refundLoading"
+          :price="data.price"
+          :lepao-count="data.lepao_count ?? 0"
+          :user-lepao-count="data.user_lepao_count"
+          variant="admin"
+          @refund="handleRefundOrder"
+        />
+
         <a-card :bordered="false" class="detail-card">
           <template #title>订单信息</template>
           <a-descriptions :column="{ xs: 1, sm: 2 }" bordered>
@@ -69,20 +82,6 @@
           <div class="goods-content" v-html="goodsContent" />
         </a-card>
 
-        <div v-if="data.orderId" class="admin-actions">
-          <a-button
-            v-if="data.state === 2 && data.canRefund"
-            type="primary"
-            status="warning"
-            :loading="refundLoading"
-            @click="handleRefundOrder"
-          >
-            发起退款
-          </a-button>
-          <a-tooltip v-else-if="data.state === 2 && data.refundDisabledReason" :content="data.refundDisabledReason">
-            <a-button status="warning" disabled>发起退款</a-button>
-          </a-tooltip>
-        </div>
         </div>
       </template>
     </a-skeleton>
@@ -96,6 +95,7 @@ import { adminOrderDetail, adminRefundOrder } from '@/api/order'
 import { Notification, Message, Modal } from '@arco-design/web-vue'
 import OrderProgressSteps from '@/components/store/OrderProgressSteps.vue'
 import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
+import OrderRefundPanel from '@/components/store/OrderRefundPanel.vue'
 import {
   formatStoreTimeFull,
   getPayTypeLabel,
@@ -214,13 +214,18 @@ onUnmounted(() => {
 .detail-grid {
   display: grid;
   grid-template-columns: 1fr;
-  gap: 16px;
+  gap: 20px;
+
+  :deep(.arco-card) {
+    margin-bottom: 0;
+  }
 
   @media (min-width: 960px) {
     grid-template-columns: 1fr 1fr;
 
     .detail-card--status,
-    .detail-card--full {
+    .detail-card--full,
+    .refund-panel-wrap {
       grid-column: 1 / -1;
     }
   }
@@ -249,12 +254,6 @@ onUnmounted(() => {
   }
 }
 
-.admin-actions {
-  display: flex;
-  gap: 12px;
-  flex-wrap: wrap;
-}
-
 @media (max-width: 768px) {
   .admin-order-detail {
     padding: 0 12px 16px;

+ 29 - 18
src/pages/store/orders/orderDetail/index.vue

@@ -8,7 +8,7 @@
         <template #content>
           <div class="detail-stack">
         <!-- 待支付横幅 -->
-        <div v-if="data?.state === 0 && hasPay" class="pay-banner">
+        <div v-if="data?.state === 0 && hasPay" class="pay-banner detail-stack__section">
           <div class="pay-banner__info">
             <icon-clock-circle class="pay-banner__icon" />
             <div>
@@ -31,7 +31,7 @@
           </div>
         </div>
 
-        <a-card :bordered="false" class="status-card">
+        <a-card :bordered="false" class="status-card detail-stack__section">
           <OrderProgressSteps
             class="steps"
             :mobile="isMobile"
@@ -45,7 +45,20 @@
           </div>
         </a-card>
 
-        <a-card :bordered="false" class="info-card" title="订单信息">
+        <OrderRefundPanel
+          v-if="data?.state === 2 && (data?.canRefund || data?.refundDisabledReason)"
+          class="detail-stack__section"
+          :can-refund="!!data?.canRefund"
+          :refund-disabled-reason="data?.refundDisabledReason"
+          :loading="refundLoading"
+          :price="data?.price"
+          :lepao-count="data?.lepao_count ?? 0"
+          :user-lepao-count="data?.user_lepao_count"
+          variant="user"
+          @refund="handleRefundOrder"
+        />
+
+        <a-card :bordered="false" class="info-card detail-stack__section" title="订单信息">
           <a-descriptions :column="1" bordered size="medium">
             <a-descriptions-item label="商品名称">{{ data?.name || '-' }}</a-descriptions-item>
             <a-descriptions-item v-if="data?.original_price && data?.discount_amount > 0" label="商品原价">
@@ -80,11 +93,11 @@
           </a-descriptions>
         </a-card>
 
-        <a-card v-if="content" :bordered="false" class="info-card" title="商品说明">
+        <a-card v-if="content" :bordered="false" class="info-card detail-stack__section" title="商品说明">
           <div class="rich-content" v-html="content" />
         </a-card>
 
-        <div class="detail-actions">
+        <div class="detail-actions detail-stack__section">
           <a-button
             v-if="data?.state === 0"
             status="danger"
@@ -93,17 +106,6 @@
           >
             取消订单
           </a-button>
-          <a-button
-            v-if="data?.state === 2 && data?.canRefund"
-            status="warning"
-            :loading="refundLoading"
-            @click="handleRefundOrder"
-          >
-            申请退款
-          </a-button>
-          <a-tooltip v-if="data?.state === 2 && !data?.canRefund && data?.refundDisabledReason" :content="data.refundDisabledReason">
-            <a-button status="warning" disabled>申请退款</a-button>
-          </a-tooltip>
           <a-button @click="$router.push('/store/myOrder')">返回订单列表</a-button>
           <a-button type="outline" @click="$router.push('/store/goodsList')">继续购物</a-button>
         </div>
@@ -122,6 +124,7 @@ import { Notification, Message, Modal } from '@arco-design/web-vue'
 import OrderStateTag from '@/components/store/OrderStateTag.vue'
 import OrderProgressSteps from '@/components/store/OrderProgressSteps.vue'
 import StoreDetailSkeleton from '@/components/skeleton/StoreDetailSkeleton.vue'
+import OrderRefundPanel from '@/components/store/OrderRefundPanel.vue'
 import {
   formatStoreTimeFull,
   getPayTypeLabel,
@@ -397,8 +400,16 @@ function openPaymentWindow(payUrl, formData) {
 .detail-stack {
   display: flex;
   flex-direction: column;
-  gap: 16px;
+  gap: 20px;
   width: 100%;
+
+  &__section {
+    margin: 0;
+  }
+
+  :deep(.arco-card) {
+    margin-bottom: 0;
+  }
 }
 
 .pay-banner {
@@ -490,7 +501,7 @@ function openPaymentWindow(payUrl, formData) {
   display: flex;
   gap: 12px;
   flex-wrap: wrap;
-  margin-top: 8px;
+  padding-top: 4px;
 }
 
 @media (max-width: 768px) {