Explorar el Código

退款功能修改

skyline hace 1 mes
padre
commit
03bfcde00c

+ 30 - 2
haha-admin-web/src/api/order.ts

@@ -1,4 +1,4 @@
-import { http } from "@/utils/http";
+import { http } from "@/utils/http";
 
 type Result = {
   code: number;
@@ -38,6 +38,34 @@ export const getOrderStatistics = () => {
   return http.request<Result>("get", "/order/statistics");
 };
 
-export const refundOrder = (id: number, data: { reason: string }) => {
+export const refundOrder = (
+  id: number,
+  data: {
+    reason: string;
+    products?: { productId: number; productName: string; quantity: number; price: number }[];
+  }
+) => {
   return http.request<Result>("post", `/order/${id}/refund`, { data });
 };
+
+export const getRefundApplicationList = (params: {
+  page?: number;
+  pageSize?: number;
+  orderNo?: string;
+  status?: number;
+  startDate?: string;
+  endDate?: string;
+}) => {
+  return http.request<ResultTable>("get", "/refund/list", { params });
+};
+
+export const getRefundApplicationDetail = (id: number) => {
+  return http.request<Result>("get", `/refund/${id}`);
+};
+
+export const reviewRefundApplication = (
+  id: number,
+  data: { approved: boolean; remark?: string }
+) => {
+  return http.request<Result>("post", `/refund/${id}/review`, { data });
+};

+ 9 - 0
haha-admin-web/src/router/modules/order.ts

@@ -15,6 +15,15 @@ export default {
         icon: "ri:file-list-2-line",
         title: "订单管理"
       }
+    },
+    {
+      path: "/order/refund",
+      name: "OrderRefund",
+      component: () => import("@/views/order/refund/index.vue"),
+      meta: {
+        icon: "ri:refund-2-line",
+        title: "退款管理"
+      }
     }
   ]
 } satisfies RouteConfigsTable;

+ 163 - 0
haha-admin-web/src/views/order/refund/index.vue

@@ -0,0 +1,163 @@
+<script setup lang="ts">
+import { ref } from "vue";
+import { useRefund } from "./utils/hook";
+import { PureTableBar } from "@/components/RePureTableBar";
+import { useRenderIcon } from "@/components/ReIcon/src/hooks";
+import Refresh from "~icons/ep/refresh";
+import View from "~icons/ep/view";
+
+defineOptions({
+  name: "OrderRefund"
+});
+
+const formRef = ref();
+const tableRef = ref();
+
+const {
+  form,
+  loading,
+  columns,
+  dataList,
+  pagination,
+  onSearch,
+  resetForm,
+  handleDetail,
+  handleApprove,
+  handleReject,
+  handleSizeChange,
+  handleCurrentChange
+} = useRefund(tableRef);
+</script>
+
+<template>
+  <div class="main">
+    <el-form
+      ref="formRef"
+      :inline="true"
+      :model="form"
+      class="search-form bg-bg_color w-full pl-8 pt-[12px] overflow-auto"
+    >
+      <el-form-item label="订单编号:" prop="orderNo">
+        <el-input
+          v-model="form.orderNo"
+          placeholder="请输入订单编号"
+          clearable
+          class="w-[180px]!"
+        />
+      </el-form-item>
+      <el-form-item label="退款状态:" prop="status">
+        <el-select
+          v-model="form.status"
+          placeholder="请选择"
+          clearable
+          class="w-[180px]!"
+        >
+          <el-option label="待审核" :value="0" />
+          <el-option label="已通过" :value="1" />
+          <el-option label="已拒绝" :value="2" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="申请时间:" prop="dateRange">
+        <el-date-picker
+          v-model="form.dateRange"
+          type="daterange"
+          range-separator="至"
+          start-placeholder="开始日期"
+          end-placeholder="结束日期"
+          value-format="YYYY-MM-DD"
+          class="w-[240px]!"
+          @change="(val: any) => {
+            form.startDate = val ? val[0] : '';
+            form.endDate = val ? val[1] : '';
+          }"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button
+          type="primary"
+          :icon="useRenderIcon('ri/search-line')"
+          :loading="loading"
+          @click="onSearch"
+        >
+          搜索
+        </el-button>
+        <el-button :icon="useRenderIcon(Refresh)" @click="resetForm(formRef)">
+          重置
+        </el-button>
+      </el-form-item>
+    </el-form>
+
+    <PureTableBar
+      title="退款管理"
+      :columns="columns"
+      @refresh="onSearch"
+    >
+      <template v-slot="{ size, dynamicColumns }">
+        <pure-table
+          ref="tableRef"
+          row-key="id"
+          adaptive
+          :adaptiveConfig="{ offsetBottom: 108 }"
+          align-whole="center"
+          table-layout="auto"
+          :loading="loading"
+          :size="size"
+          :data="dataList"
+          :columns="dynamicColumns"
+          :pagination="{ ...pagination, size }"
+          :header-cell-style="{
+            background: 'var(--el-fill-color-light)',
+            color: 'var(--el-text-color-primary)'
+          }"
+          @page-size-change="handleSizeChange"
+          @page-current-change="handleCurrentChange"
+        >
+          <template #operation="{ row }">
+            <el-button
+              class="reset-margin"
+              link
+              type="primary"
+              :size="size"
+              :icon="useRenderIcon(View)"
+              @click="handleDetail(row)"
+            >
+              详情
+            </el-button>
+            <el-button
+              v-if="row.status === 0"
+              class="reset-margin"
+              link
+              type="success"
+              :size="size"
+              @click="handleApprove(row)"
+            >
+              通过
+            </el-button>
+            <el-button
+              v-if="row.status === 0"
+              class="reset-margin"
+              link
+              type="danger"
+              :size="size"
+              @click="handleReject(row)"
+            >
+              拒绝
+            </el-button>
+          </template>
+        </pure-table>
+      </template>
+    </PureTableBar>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.main-content {
+  margin: 24px 24px 0 !important;
+}
+
+.search-form {
+  :deep(.el-form-item) {
+    margin-bottom: 12px;
+  }
+}
+</style>

+ 139 - 40
haha-admin-web/src/views/order/utils/hook.tsx

@@ -1,4 +1,4 @@
-import dayjs from "dayjs";
+import dayjs from "dayjs";
 import { message } from "@/utils/message";
 import { addDialog } from "@/components/ReDialog";
 import type { PaginationProps } from "@pureadmin/table";
@@ -8,7 +8,7 @@ import {
   getOrderById,
   refundOrder
 } from "@/api/order";
-import { type Ref, ref, toRaw, reactive, onMounted } from "vue";
+import { type Ref, ref, computed, reactive, onMounted } from "vue";
 import {
   ElForm,
   ElFormItem,
@@ -22,7 +22,7 @@ import {
   ElTableColumn,
   ElMessageBox
 } from "element-plus";
-import type { OrderItem, OrderSearchForm } from "./types";
+import type { OrderItem, OrderSearchForm, RefundProductItem } from "./types";
 import { 
   initPagination, 
   handlePageSizeChange, 
@@ -104,7 +104,6 @@ export function useOrder(tableRef: Ref) {
       prop: "paidAmount",
       minWidth: 100,
       formatter: ({ paidAmount, totalAmount, discountAmount }) => {
-        // 优先使用paidAmount,如果没有则计算
         const amount = paidAmount || (totalAmount || 0) - (discountAmount || 0);
         return `¥${amount}`;
       },
@@ -194,7 +193,6 @@ export function useOrder(tableRef: Ref) {
     }
   ];
 
-  // 搜索
   async function onSearch() {
     loading.value = true;
     try {
@@ -203,7 +201,6 @@ export function useOrder(tableRef: Ref) {
         pageSize: pagination.pageSize
       };
       
-      // 只添加非空的搜索条件
       if (form.orderNo) searchParams.orderNo = form.orderNo;
       if (form.deviceId) searchParams.deviceId = form.deviceId;
       if (form.payStatus) searchParams.payStatus = form.payStatus;
@@ -223,9 +220,7 @@ export function useOrder(tableRef: Ref) {
         startDate?: string;
         endDate?: string;
       });
-      // @ts-ignore - data 是 PageResult 结构
       dataList.value = data.list || [];
-      // @ts-ignore - data 是 PageResult 结构
       pagination.total = Number(data.total) || 0;
     } catch (error) {
       console.error("获取订单列表失败:", error);
@@ -238,14 +233,12 @@ export function useOrder(tableRef: Ref) {
     }
   }
 
-  // 重置表单
   const resetForm = formEl => {
     if (!formEl) return;
     formEl.resetFields();
     resetPagination(pagination, onSearch);
   };
 
-  // 分页
   function handleSizeChange(val: number) {
     handlePageSizeChange(val, pagination, onSearch);
   }
@@ -254,26 +247,22 @@ export function useOrder(tableRef: Ref) {
     handleCurrentPageChange(val, pagination, onSearch);
   }
 
-  // 查看详情
   async function handleDetail(row: OrderItem) {
     try {
       const { data } = await getOrderById(row.id);
       const order = data;
   
-      // 支付状态样式
       const payStatusStyle = order.payStatus === "PAID"
         ? "success" : order.payStatus === "REFUND"
         ? "danger" : "warning";
       const payStatusText = order.payStatusLabel || (order.payStatus === "PAID" ? "已支付" : order.payStatus === "REFUND" ? "已退款" : "待支付");
   
-      // 订单状态样式
       const statusStyle = order.status === 1
         ? "success" : order.status === 3
         ? "danger" : order.status === 2
         ? "info" : "warning";
       const statusText = order.statusText || (order.status === 0 ? "待支付" : order.status === 1 ? "已完成" : order.status === 2 ? "已取消" : "已退款");
   
-      // 支付方式样式
       const payTypeMap: Record<string, { text: string; type: string }> = {
         "wechat": { text: "微信支付", type: "success" },
         "alipay": { text: "支付宝", type: "primary" },
@@ -289,7 +278,6 @@ export function useOrder(tableRef: Ref) {
         closeOnClickModal: false,
         contentRenderer: () => (
           <div style="max-height: 70vh; overflow-y: auto; padding: 0 4px;">
-            {/* 基本信息 */}
             <div style="font-size: 15px; font-weight: 600; margin-bottom: 10px; color: var(--el-text-color-primary);">基本信息</div>
             <ElDescriptions column={3} border size="small">
               <ElDescriptionsItem label="订单编号" span={2}>{order.orderNo}</ElDescriptionsItem>
@@ -300,7 +288,6 @@ export function useOrder(tableRef: Ref) {
               <ElDescriptionsItem label="用户 ID">{order.userId || "-"}</ElDescriptionsItem>
             </ElDescriptions>
   
-            {/* 金额与状态 */}
             <div style="font-size: 15px; font-weight: 600; margin: 14px 0 10px; color: var(--el-text-color-primary);">金额与状态</div>
             <ElDescriptions column={3} border size="small">
               <ElDescriptionsItem label="订单金额">
@@ -329,7 +316,6 @@ export function useOrder(tableRef: Ref) {
               <ElDescriptionsItem label="支付时间" span={2}>{order.payTime ? dayjs(order.payTime).format("YYYY-MM-DD HH:mm:ss") : "-"}</ElDescriptionsItem>
             </ElDescriptions>
   
-            {/* 视频与置信度 */}
             {(order.videoUrl || order.confidence) && (
               <>
                 <div style="font-size: 15px; font-weight: 600; margin: 14px 0 10px; color: var(--el-text-color-primary);">其他信息</div>
@@ -346,7 +332,6 @@ export function useOrder(tableRef: Ref) {
               </>
             )}
   
-            {/* 订单商品 */}
             {order.products && order.products.length > 0 && (
               <>
                 <div style="font-size: 15px; font-weight: 600; margin: 14px 0 10px; color: var(--el-text-color-primary);">订单商品</div>
@@ -402,45 +387,159 @@ export function useOrder(tableRef: Ref) {
     }
   }
 
-  // 退款
-  const refundForm = reactive({ reason: "" });
-
   async function handleRefund(row: OrderItem) {
     if (row.status !== 1) {
       message("只有已完成的订单才能退款", { type: "warning" });
       return;
     }
 
-    refundForm.reason = "";
+    const refundState = reactive({
+      loadingDetail: true,
+      orderDetail: null as any,
+      selectedProducts: [] as number[],
+      refundQuantities: {} as Record<number, number>,
+      reason: ""
+    });
+
+    const refundFormRef = ref();
+
+    const refundTotalAmount = computed(() => {
+      if (!refundState.orderDetail?.products) return 0;
+      return refundState.selectedProducts.reduce((sum, i) => {
+        const p = refundState.orderDetail.products[i];
+        return sum + (p.price || 0) * (refundState.refundQuantities[i] || 1);
+      }, 0);
+    });
+
+    try {
+      const { data } = await getOrderById(row.id);
+      refundState.orderDetail = data;
+      if (data.products && data.products.length > 0) {
+        data.products.forEach((p: any, i: number) => {
+          refundState.selectedProducts.push(i);
+          refundState.refundQuantities[i] = p.productNum || 1;
+        });
+      }
+    } catch {
+      message("加载订单商品失败", { type: "error" });
+    } finally {
+      refundState.loadingDetail = false;
+    }
 
     addDialog({
       title: `订单退款 - ${row.orderNo}`,
-      width: "30%",
+      width: "56%",
       draggable: true,
       closeOnClickModal: false,
       fullscreen: deviceDetection(),
       contentRenderer: () => (
-        <ElForm ref={ruleFormRef} model={refundForm} label-width="80px">
-          <ElFormItem
-            label="退款原因"
-            prop="reason"
-            rules={[{ required: true, message: "请输入退款原因", trigger: "blur" }]}
-          >
-            <ElInput
-              v-model={refundForm.reason}
-              type="textarea"
-              placeholder="请输入退款原因"
-              rows={3}
-            />
-          </ElFormItem>
-        </ElForm>
+        <div style="max-height: 60vh; overflow-y: auto; padding: 0 4px;">
+          {refundState.loadingDetail ? (
+            <div style="text-align: center; padding: 40px; color: #999;">正在加载订单商品...</div>
+          ) : (
+            <>
+              <div style="font-size: 15px; font-weight: 600; margin-bottom: 10px; color: var(--el-text-color-primary);">退款商品</div>
+              <ElTable data={refundState.orderDetail?.products || []} border size="small" style="width: 100%">
+                <ElTableColumn width={55} align="center"
+                  v-slots={{
+                    default: ({ $index }: any) => (
+                      <el-checkbox
+                        model-value={refundState.selectedProducts.includes($index)}
+                        onChange={(val: boolean) => {
+                          if (val) {
+                            refundState.selectedProducts.push($index);
+                            if (!refundState.refundQuantities[$index]) {
+                              refundState.refundQuantities[$index] = refundState.orderDetail.products[$index].productNum || 1;
+                            }
+                          } else {
+                            const idx = refundState.selectedProducts.indexOf($index);
+                            if (idx > -1) refundState.selectedProducts.splice(idx, 1);
+                          }
+                        }}
+                      />
+                    )
+                  }}
+                />
+                <ElTableColumn prop="productName" label="商品名称" min-width={140} />
+                <ElTableColumn label="单价" width={90} align="right"
+                  v-slots={{ default: ({ row: r }: any) => <span>¥{(r.price || 0).toFixed(2)}</span> }}
+                />
+                <ElTableColumn label="购买数量" width={90} align="center"
+                  v-slots={{ default: ({ row: r }: any) => <span>×{r.productNum || 1}</span> }}
+                />
+                <ElTableColumn label="退款数量" width={160} align="center"
+                  v-slots={{
+                    default: ({ $index, row: r }: any) => (
+                      refundState.selectedProducts.includes($index) ? (
+                        <el-input-number
+                          model-value={refundState.refundQuantities[$index] || 1}
+                          min={1}
+                          max={r.productNum || 1}
+                          size="small"
+                          controls-position="right"
+                          style="width: 120px"
+                          onUpdate:modelValue={(val: number) => { refundState.refundQuantities[$index] = val; }}
+                        />
+                      ) : <span style="color: #999">-</span>
+                    )
+                  }}
+                />
+                <ElTableColumn label="退款金额" width={110} align="right"
+                  v-slots={{
+                    default: ({ $index, row: r }: any) => (
+                      refundState.selectedProducts.includes($index) ? (
+                        <span style="color: #e6a23c; font-weight: 600; font-size: 14px;">
+                          ¥{((r.price || 0) * (refundState.refundQuantities[$index] || 1)).toFixed(2)}
+                        </span>
+                      ) : <span style="color: #999">¥0.00</span>
+                    )
+                  }}
+                />
+              </ElTable>
+
+              <div style="text-align: right; padding: 14px 0; font-size: 15px; border-bottom: 1px solid #eee; margin-bottom: 12px;">
+                退款总金额:
+                <span style="color: #f56c6c; font-weight: 700; font-size: 18px;">¥{refundTotalAmount.value.toFixed(2)}</span>
+              </div>
+
+              <div style="font-size: 15px; font-weight: 600; margin-bottom: 10px; color: var(--el-text-color-primary);">退款原因</div>
+              <ElForm ref={refundFormRef} model={refundState}>
+                <ElFormItem
+                  prop="reason"
+                  rules={[{ required: true, message: "请输入退款原因", trigger: "blur" }]}
+                >
+                  <ElInput
+                    v-model={refundState.reason}
+                    type="textarea"
+                    placeholder="请输入退款原因"
+                    rows={3}
+                  />
+                </ElFormItem>
+              </ElForm>
+            </>
+          )}
+        </div>
       ),
       beforeSure: async (done) => {
-        const valid = await ruleFormRef.value.validate().catch(() => false);
+        if (refundState.selectedProducts.length === 0) {
+          message("请选择至少一件退款商品", { type: "warning" });
+          return;
+        }
+        const valid = await refundFormRef.value?.validate().catch(() => false);
         if (!valid) return;
 
+        const products: RefundProductItem[] = refundState.selectedProducts.map((i) => {
+          const p = refundState.orderDetail.products[i];
+          return {
+            productId: p.productId || p.id,
+            productName: p.productName,
+            quantity: refundState.refundQuantities[i] || 1,
+            price: p.price || 0
+          };
+        });
+
         try {
-          const res = await refundOrder(row.id, { reason: refundForm.reason });
+          const res = await refundOrder(row.id, { reason: refundState.reason, products });
           if (res.code === 200) {
             message(`订单 ${row.orderNo} 退款成功`, { type: "success" });
             done();
@@ -472,4 +571,4 @@ export function useOrder(tableRef: Ref) {
     handleSizeChange,
     handleCurrentChange
   };
-}
+}

+ 32 - 0
haha-admin-web/src/views/order/utils/types.ts

@@ -54,3 +54,35 @@ export interface OrderSearchForm {
   startDate: string;
   endDate: string;
 }
+
+// 退款商品项
+export interface RefundProductItem {
+  productId: number;
+  productName: string;
+  quantity: number;
+  price: number;
+}
+
+// 退款申请记录
+export interface RefundApplicationItem {
+  id: number;
+  applicationNo: string;
+  orderId: number;
+  orderNo: string;
+  userId: number;
+  refundAmount: number;
+  reason: string;
+  refundProducts: RefundProductItem[];
+  status: number;
+  createTime: string;
+  reviewTime?: string;
+  reviewRemark?: string;
+}
+
+// 退款申请搜索表单
+export interface RefundApplicationSearchForm {
+  orderNo: string;
+  status: number | string;
+  startDate: string;
+  endDate: string;
+}