Browse Source

feat(couponManagement): add coupon API functions and enhance UI components

- Introduced new API functions for fetching coupon statistics, claim records, and sales details.
- Updated the `CollectionQuery` component to utilize the new API for dynamic data loading and improved error handling.
- Enhanced the `RedPacketDataDialog` and `SalesDetailDialog` components to pass coupon IDs and display relevant data.
- Improved the layout and styling of various components for better user experience and clarity.
- Refactored data fetching logic in `TemplateData` to integrate with the new API for coupon statistics.
ylong 59 minutes ago
parent
commit
bd081316f6

+ 40 - 0
src/api/marketing/coupon.js

@@ -0,0 +1,40 @@
+import request from '@/utils/request';
+
+/**
+ * 红包模版数据
+ */
+export async function getCouponStatisticTemplate(params) {
+    const res = await request.get('/shop/couponInfo/getStatisticTemplate', {
+        params
+    });
+    if (res.data.code === 200) {
+        return res.data.data || {};
+    }
+    return Promise.reject(new Error(res.data.msg || '获取红包模板数据失败'));
+}
+
+/**
+ * 分页查询红包领取记录
+ */
+export async function getCouponClaimPage(params) {
+    const res = await request.get('/shop/couponInfo/getClaimPagelist', {
+        params
+    });
+    if (res.data.code === 200) {
+        return res.data;
+    }
+    return Promise.reject(new Error(res.data.msg || '获取领取记录失败'));
+}
+
+/**
+ * 红包销售明细
+ */
+export async function getCouponSalesDetail(params) {
+    const res = await request.get('/shop/shopOrder/getCouponSalesDetail', {
+        params
+    });
+    if (res.data.code === 200) {
+        return res.data;
+    }
+    return Promise.reject(new Error(res.data.msg || '获取销售明细失败'));
+}

+ 39 - 37
src/views/marketing/redPacket/components/CollectionQuery.vue

@@ -3,6 +3,7 @@
         <!-- Table -->
         <common-table
             ref="tableRef"
+            :flex-table="false"
             :columns="columns"
             :datasource="datasource"
             :page-config="{ rowKey: 'id', tool: false }"
@@ -23,9 +24,9 @@
                         style="width: 150px"
                         clearable
                     >
-                        <el-option label="已使用" value="used" />
-                        <el-option label="未使用" value="unused" />
-                        <el-option label="已过期" value="expired" />
+                        <el-option label="未使用" value="1" />
+                        <el-option label="已使用" value="2" />
+                        <el-option label="已过期" value="3" />
                     </el-select>
                     <el-button type="primary" @click="handleSearch"
                         >搜索</el-button
@@ -35,9 +36,9 @@
 
             <template #orderCount="{ row }">
                 <div class="flex items-center">
-                    <span>{{ row.orderCount }}</span>
+                    <span>{{ row.orderNum }}</span>
                     <el-button
-                        v-if="row.orderCount > 0"
+                        v-if="row.orderNum > 0"
                         type="warning"
                         circle
                         size="small"
@@ -58,9 +59,15 @@
 
 <script setup>
     import { ref, reactive } from 'vue';
+    import { EleMessage } from 'ele-admin-plus/es';
     import CommonTable from '@/components/CommonPage/CommonTable.vue';
     import { ArrowRight } from '@element-plus/icons-vue';
     import SalesDetailDialog from './SalesDetailDialog.vue';
+    import { getCouponClaimPage } from '@/api/marketing/coupon';
+    const props = defineProps({
+        couponId: { type: [String, Number], default: '' }
+    });
+    const STATUS_MAP = { 1: "未使用", 2: "已使用", 3: "已过期" };
 
     const searchForm = reactive({
         nickname: '',
@@ -68,43 +75,38 @@
     });
 
     const columns = ref([
-        { label: '用户昵称', prop: 'nickname' },
-        { label: '使用详情', prop: 'usageDetail' },
-        { label: '用户订单数量', prop: 'orderCount', slot: 'orderCount' },
-        { label: '红包金额', prop: 'amount' }
+        { label: "用户昵称", prop: "userNick" },
+        {
+            label: "使用详情",
+            prop: "status",
+            formatter: (row) => STATUS_MAP[row.status] || "-"
+        },
+        { label: "用户订单数量", prop: "orderNum", slot: "orderCount" },
+        { label: "红包金额", prop: "faceMoney" }
     ]);
 
-    const datasource = async ({ page, limit }) => {
-        // Mock data
-        await new Promise((resolve) => setTimeout(resolve, 300));
-        return [
-            {
-                id: 1,
-                nickname: '书嗨5465465465',
-                usageDetail: '已使用',
-                orderCount: 1,
-                amount: '0.25'
-            },
-            {
-                id: 2,
-                nickname: '书嗨5465465465',
-                usageDetail: '未使用',
-                orderCount: 1,
-                amount: '0.25'
-            },
-            {
-                id: 3,
-                nickname: '书嗨5465465465',
-                usageDetail: '已过期',
-                orderCount: 1,
-                amount: '0.25'
-            }
-        ];
+    const datasource = async ({ pages, page, limit }) => {
+        const pageNum = (pages && pages.pageNum) || page || 1;
+        const pageSize = (pages && pages.pageSize) || limit || 10;
+        try {
+            return await getCouponClaimPage({
+                couponId: props.couponId,
+                userNick: searchForm.nickname || undefined,
+                status: searchForm.status || undefined,
+                pageNum,
+                pageSize,
+                page: pageNum,
+                limit: pageSize
+            });
+        } catch (error) {
+            EleMessage.error(error.message || "获取领取记录失败");
+            return { rows: [], total: 0 };
+        }
     };
 
     const tableRef = ref(null);
     const handleSearch = () => {
-        tableRef.value?.reload();
+        tableRef.value?.reload({ search: true });
     };
 
     // Detail Dialog Logic
@@ -117,7 +119,7 @@
     };
 
     const handleRowClick = (row) => {
-        if (row.orderCount > 0) {
+        if (row.orderNum > 0) {
             handleShowDetail(row);
         }
     };

+ 9 - 2
src/views/marketing/redPacket/components/RedPacketDataDialog.vue

@@ -9,10 +9,16 @@
     >
         <el-tabs v-model="activeTab">
             <el-tab-pane label="模板数据" name="template">
-                <TemplateData v-if="visible && activeTab === 'template'" />
+                <TemplateData
+                    v-if="visible && activeTab === 'template'"
+                    :coupon-id="redPacketId"
+                />
             </el-tab-pane>
             <el-tab-pane label="领取查询" name="collection">
-                <CollectionQuery v-if="visible && activeTab === 'collection'" />
+                <CollectionQuery
+                    v-if="visible && activeTab === 'collection'"
+                    :coupon-id="redPacketId"
+                />
             </el-tab-pane>
         </el-tabs>
     </ele-modal>
@@ -30,6 +36,7 @@
 
     const open = (id) => {
         redPacketId.value = id;
+        activeTab.value = 'template';
         visible.value = true;
     };
 

+ 127 - 29
src/views/marketing/redPacket/components/SalesDetailDialog.vue

@@ -2,11 +2,13 @@
     <ele-modal
         v-model="visible"
         title="销售明细"
-        width="1000px"
+        width="1200px"
         :append-to-body="true"
+        top="5vh"
+        modal-class="sales-detail-dialog"
     >
         <div class="p-4">
-            <div class="mb-4 flex space-x-2">
+            <div class="sales-detail-filters">
                 <el-input
                     v-model="detailSearch.receiver"
                     placeholder="收货人名称"
@@ -39,15 +41,45 @@
                 style="width: 100%"
                 size="small"
             >
-                <el-table-column prop="orderNo" label="订单编号" />
-                <el-table-column prop="username" label="用户名称" />
-                <el-table-column prop="price" label="单价" />
-                <el-table-column prop="quantity" label="数量" />
-                <el-table-column prop="receiver" label="收货人" />
-                <el-table-column prop="phone" label="手机号" />
-                <el-table-column prop="address" label="收货地址" />
-                <el-table-column prop="status" label="订单状态" />
-                <el-table-column prop="time" label="提交时间" />
+                <el-table-column
+                    prop="orderNo"
+                    label="订单编号"
+                    width="88"
+                    show-overflow-tooltip
+                />
+                <el-table-column
+                    prop="username"
+                    label="用户名称"
+                    min-width="80"
+                    show-overflow-tooltip
+                />
+                <el-table-column prop="price" label="单价" width="92" />
+                <el-table-column prop="quantity" label="数量" width="80" />
+                <el-table-column
+                    prop="receiver"
+                    label="收货人"
+                    width="80"
+                    show-overflow-tooltip
+                />
+                <el-table-column
+                    prop="phone"
+                    label="手机号"
+                    width="102"
+                    show-overflow-tooltip
+                />
+                <el-table-column
+                    prop="address"
+                    label="收货地址"
+                    min-width="160"
+                    show-overflow-tooltip
+                />
+                <el-table-column prop="status" label="订单状态" width="84" />
+                <el-table-column
+                    prop="time"
+                    label="提交时间"
+                    width="138"
+                    show-overflow-tooltip
+                />
             </el-table>
         </div>
     </ele-modal>
@@ -55,6 +87,10 @@
 
 <script setup>
     import { ref, reactive, computed, watch } from 'vue';
+    import dayjs from 'dayjs';
+    import { EleMessage } from 'ele-admin-plus/es';
+    import { getCouponSalesDetail } from '@/api/marketing/coupon';
+    const ORDER_STATUS = { 1: "待付款", 2: "待发货", 3: "待收货", 4: "已签收", 5: "已完成", 6: "已取消" };
 
     const props = defineProps({
         modelValue: {
@@ -83,24 +119,43 @@
 
     const detailData = ref([]);
 
-    const fetchData = () => {
-        // Mock fetch logic using props.row
-        if (!props.row) return;
-
-        // Simulate API call
-        detailData.value = [
-            {
-                orderNo: '12345678',
-                username: props.row.nickname || 'Unknown',
-                price: '10.00',
-                quantity: 1,
-                receiver: '张三',
-                phone: '13800138000',
-                address: '北京市朝阳区',
-                status: '已完成',
-                time: '2024-10-10 12:00:00'
-            }
-        ];
+    const fetchData = async () => {
+        const userId = props.row && props.row.userId;
+        if (!userId) {
+            detailData.value = [];
+            return;
+        }
+        const range = detailSearch.dateRange || [];
+        const startTime = range[0] ? dayjs(range[0]).startOf("day").format("YYYY-MM-DD HH:mm:ss") : undefined;
+        const endTime = range[1] ? dayjs(range[1]).endOf("day").format("YYYY-MM-DD HH:mm:ss") : undefined;
+        try {
+            const res = await getCouponSalesDetail({
+                userId,
+                receiverName: detailSearch.receiver || undefined,
+                receiverMobile: detailSearch.phone || undefined,
+                receiverAddress: detailSearch.address || undefined,
+                startTime,
+                endTime,
+                pageNum: 1,
+                pageSize: 50,
+                page: 1,
+                limit: 50
+            });
+            detailData.value = (res.rows || []).map((item) => ({
+                orderNo: item.orderId,
+                username: item.userNick,
+                price: item.payMoney,
+                quantity: item.totalNum,
+                receiver: item.receiverName,
+                phone: item.receiverMobile,
+                address: item.receiverAddress,
+                status: ORDER_STATUS[item.status] || item.status || "-",
+                time: item.createTime ? dayjs(item.createTime).format("YYYY-MM-DD HH:mm:ss") : "-"
+            }));
+        } catch (error) {
+            detailData.value = [];
+            EleMessage.error(error.message || "获取销售明细失败");
+        }
     };
 
     const resetDetail = () => {
@@ -120,3 +175,46 @@
         }
     );
 </script>
+
+<style>
+    .sales-detail-dialog .el-dialog {
+        width: min(1200px, calc(100vw - 48px)) !important;
+        max-width: calc(100vw - 48px);
+        max-height: 90vh;
+        display: flex;
+        flex-direction: column;
+        margin-bottom: 0;
+        overflow: hidden;
+    }
+
+    .sales-detail-dialog .el-dialog__body {
+        flex: 1;
+        min-height: 0;
+        overflow-x: hidden;
+        overflow-y: auto;
+    }
+
+    .sales-detail-filters {
+        display: flex;
+        flex-wrap: wrap;
+        gap: 8px;
+        margin-bottom: 16px;
+    }
+
+    .sales-detail-dialog .el-table {
+        width: 100% !important;
+    }
+
+    .sales-detail-dialog .el-table .cell {
+        white-space: nowrap;
+    }
+
+    .sales-detail-dialog .el-table__body-wrapper,
+    .sales-detail-dialog .el-scrollbar__wrap {
+        overflow-x: hidden !important;
+    }
+
+    .sales-detail-dialog .el-scrollbar__bar.is-horizontal {
+        display: none !important;
+    }
+</style>

+ 167 - 59
src/views/marketing/redPacket/components/TemplateData.vue

@@ -1,5 +1,5 @@
 <template>
-    <div class="template-data">
+    <div v-loading="loading" class="template-data">
         <!-- Header Info -->
         <div
             class="bg-gray-50 p-4 rounded mb-4 text-sm text-gray-600 flex justify-between items-center"
@@ -47,7 +47,9 @@
                         range-separator="至"
                         start-placeholder="开始日期"
                         end-placeholder="结束日期"
+                        value-format="YYYY-MM-DD"
                         style="width: 240px"
+                        @change="handleDateChange"
                     />
                     <el-button type="primary" plain @click="fetchData"
                         >查询</el-button
@@ -77,15 +79,14 @@
                     <div class="text-lg font-bold mb-1">{{ metric.value }}</div>
                     <div class="text-xs flex items-center">
                         <span class="text-gray-400 mr-1">较前1日</span>
+                        <span v-if="metric.trend == null" class="text-gray-400">—</span>
                         <span
-                            :class="
-                                metric.trend > 0
-                                    ? 'text-green-500'
-                                    : 'text-red-500'
-                            "
+                            v-else
+                            :class="metric.trend > 0 ? 'text-green-500' : metric.trend < 0 ? 'text-red-500' : 'text-gray-400'"
                         >
-                            {{ metric.trend > 0 ? '↑' : '↓' }}
-                            {{ Math.abs(metric.trend) }}%
+                            <template v-if="metric.trend > 0">↑</template>
+                            <template v-else-if="metric.trend < 0">↓</template>
+                            {{ Math.abs(metric.trend).toFixed(2) }}%
                         </span>
                     </div>
                 </div>
@@ -100,51 +101,92 @@
 </template>
 
 <script setup>
-    import { ref, reactive, computed } from 'vue';
+    import { computed, onMounted, ref } from 'vue';
+    import dayjs from 'dayjs';
+    import { EleMessage } from 'ele-admin-plus/es';
     import BaseChart from '@/components/Chart/BaseChart.vue';
+    import { getCouponStatisticTemplate } from '@/api/marketing/coupon';
 
-    const info = reactive({
-        condition: '25.00元',
-        amount: '2-4元',
-        validity: '2024-10-01 00:00:00 至 2024-11-20 00:00:00',
-        updateTime: '2024-10-05'
+    const props = defineProps({
+        couponId: { type: [String, Number], default: '' }
     });
+    const loading = ref(false);
+    const detail = ref({});
+    const trendMap = ref({});
 
-    const stats = ref([
-        { label: '发行量 (张)', value: '658' },
-        { label: '累计领取量 (张)', value: '658' },
-        { label: '红包消耗金额 (元)', value: '658' },
-        { label: '累计使用量 (张)', value: '658' },
-        { label: '累计使用率', value: '65.8%' },
-        { label: '累计支付件数', value: '658' },
-        { label: '红包拉动成交金额', value: '658' }
-    ]);
+    function showValue(value) {
+        if (value == null || value === "") return 0;
+        return value;
+    }
+    function formatDateTime(value) {
+        if (!value) return "-";
+        const date = dayjs(value);
+        return date.isValid() ? date.format("YYYY-MM-DD HH:mm:ss") : value;
+    }
+    function formatAmount(value) {
+        const num = Number(value);
+        if (!Number.isFinite(num)) return "0";
+        return num.toFixed(2).replace(/\.00$/, "");
+    }
+    function formatThreshold(value) {
+        if (value == null || Number(value) === 0) return "无门槛";
+        return Number(value).toFixed(2) + "元";
+    }
+    function formatFace(data) {
+        const min = data.minMoney;
+        const max = data.maxMoney;
+        if (min != null && max != null && (Number(min) !== 0 || Number(max) !== 0)) {
+            return formatAmount(min) + "-" + formatAmount(max) + "元";
+        }
+        if (data.faceMoney != null) return formatAmount(data.faceMoney) + "元";
+        return "-";
+    }
+    function formatPercent(value) {
+        if (value == null || value === "") return "0%";
+        const text = String(value);
+        return text.includes("%") ? text : text + "%";
+    }
+    const info = computed(() => {
+        const data = detail.value || {};
+        return {
+            condition: formatThreshold(data.thresholdMoney),
+            amount: formatFace(data),
+            validity: formatDateTime(data.effectStartTime) + " 至 " + formatDateTime(data.effectEndTime),
+            updateTime: data.dataUpdateDate || "-"
+        };
+    });
+    const stats = computed(() => {
+        const data = detail.value || {};
+        return [
+            { label: "发行量 (张)", value: showValue(data.issueNum) },
+            { label: "累计领取量 (张)", value: showValue(data.cumulativeClaimNum) },
+            { label: "红包消耗金额 (元)", value: showValue(data.cumulativeCouponAmount) },
+            { label: "累计使用量 (张)", value: showValue(data.cumulativeUseNum) },
+            { label: "累计使用率", value: formatPercent(data.cumulativeUseRate) },
+            { label: "累计支付件数", value: showValue(data.cumulativePayNum) },
+            { label: "红包拉动成交金额", value: showValue(data.cumulativeDrivePayAmount) }
+        ];
+    });
 
     const dateRangeType = ref('day');
     const dateRange = ref([]);
-    const queryTime = ref('2024-06-28');
-
-    const activeMetric = ref('collection');
-    const metrics = ref([
-        { key: 'collection', label: '领取张数', value: '658', trend: -48.65 },
-        { key: 'usage', label: '使用张数', value: '658', trend: -48.65 },
-        {
-            key: 'consumption',
-            label: '红包消耗金额',
-            value: '658',
-            trend: -48.65
-        },
-        {
-            key: 'transaction',
-            label: '红包拉动支付金额',
-            value: '658.68',
-            trend: -48.65
-        },
-        { key: 'buyers', label: '支付买家数', value: '658', trend: 48.65 },
-        { key: 'items', label: '支付件数', value: '658', trend: 48.65 },
-        { key: 'price', label: '客单价', value: '658', trend: 48.65 }
-    ]);
+    const queryTime = ref("");
 
+    const METRICS = [
+        { key: "claimNum", label: "领取张数" },
+        { key: "useNum", label: "使用张数" },
+        { key: "couponAmount", label: "红包消耗金额" },
+        { key: "drivePayAmount", label: "红包拉动支付金额" },
+        { key: "payUserNum", label: "支付买家数" },
+        { key: "payNum", label: "支付件数" },
+        { key: "avgOrderValue", label: "客单价" }
+    ];
+    const activeMetric = ref("claimNum");
+    const metrics = computed(() => METRICS.map((item) => ({
+        ...item,
+        value: showValue(detail.value?.[item.key]),
+        trend: trendMap.value[item.key]
+    })));
     const chartOption = computed(() => {
         return {
             tooltip: {
@@ -159,7 +201,10 @@
             xAxis: {
                 type: 'category',
                 boundaryGap: false,
-                data: ['1月', '2月', '3月', '4月', '5月']
+                data: (detail.value.dailyList || []).map((item) => {
+                    const date = dayjs(item.statisticDate);
+                    return date.isValid() ? date.format("MM-DD") : item.statisticDate;
+                })
             },
             yAxis: {
                 type: 'value'
@@ -191,23 +236,86 @@
                             ]
                         }
                     },
-                    data: [100, 140, 220, 100, 130] // Mock data
+                    data: (detail.value.dailyList || []).map(
+                        (item) => Number(item[activeMetric.value]) || 0
+                    )
                 }
             ]
         };
     });
 
-    const handleDateTypeChange = (val) => {
-        // Logic to set dateRange based on val
-        console.log('Date type changed:', val);
-    };
-
-    const fetchData = () => {
-        console.log('Fetching data...');
-    };
-
-    const reset = () => {
-        dateRangeType.value = 'day';
+    function buildRange(type) {
+        const today = dayjs();
+        const endDate = today.format("YYYY-MM-DD");
+        if (type === "7days") return [today.subtract(6, "day").format("YYYY-MM-DD"), endDate];
+        if (type === "30days") return [today.subtract(29, "day").format("YYYY-MM-DD"), endDate];
+        return [endDate, endDate];
+    }
+    function resolveRange() {
+        if (dateRange.value && dateRange.value.length === 2) {
+            return { startDate: dateRange.value[0], endDate: dateRange.value[1] };
+        }
+        const pair = buildRange(dateRangeType.value || "day");
+        return { startDate: pair[0], endDate: pair[1] };
+    }
+    function calcTrend(current, previous) {
+        const cur = Number(current) || 0;
+        const prev = Number(previous) || 0;
+        if (prev === 0) return cur === 0 ? 0 : null;
+        return ((cur - prev) / prev) * 100;
+    }
+    function applyTrend(current, previous) {
+        const next = {};
+        METRICS.forEach((item) => {
+            next[item.key] = previous ? calcTrend(current && current[item.key], previous && previous[item.key]) : null;
+        });
+        trendMap.value = next;
+    }
+    async function fetchData() {
+        if (!props.couponId) return;
+        const range = resolveRange();
+        queryTime.value = range.startDate === range.endDate ? range.startDate : range.startDate + " 至 " + range.endDate;
+        loading.value = true;
+        try {
+            const data = await getCouponStatisticTemplate({
+                couponId: props.couponId,
+                startDate: range.startDate,
+                endDate: range.endDate
+            });
+            detail.value = data || {};
+            const list = Array.isArray(data && data.dailyList) ? data.dailyList : [];
+            if (list.length >= 2) {
+                applyTrend(list[list.length - 1], list[list.length - 2]);
+            } else {
+                const prevDay = dayjs(range.endDate).subtract(1, "day").format("YYYY-MM-DD");
+                const prevData = await getCouponStatisticTemplate({
+                    couponId: props.couponId,
+                    startDate: prevDay,
+                    endDate: prevDay
+                });
+                applyTrend(data, prevData);
+            }
+        } catch (error) {
+            detail.value = {};
+            trendMap.value = {};
+            EleMessage.error(error.message || "获取红包模板数据失败");
+        } finally {
+            loading.value = false;
+        }
+    }
+    function handleDateTypeChange() {
         dateRange.value = [];
-    };
+        fetchData();
+    }
+    function handleDateChange() {
+        if (dateRange.value && dateRange.value.length === 2) dateRangeType.value = "";
+    }
+    function reset() {
+        dateRangeType.value = "day";
+        dateRange.value = [];
+        activeMetric.value = "claimNum";
+        fetchData();
+    }
+    onMounted(() => { fetchData(); });
+
 </script>