feat(promotion): 新增满折满赠活动及阶梯优惠规则支持

- 满额活动列表页支持满减、满折、满赠三种类型筛选与标签展示
- 新增阶梯优惠规则列,按活动类型格式化展示各级优惠详情
- 活动详情页支持1至3级阶梯优惠配置及满赠赠品选择
- 页面装修组件增加满折、满赠活动类型标签展示
- 隐藏"添加活动优惠券"按钮入口
- 优化活动类型、状态、时间等表格列宽与样式
This commit is contained in:
pikachu1995@126.com
2026-11-10 15:55:43 +08:00
parent 2be2227644
commit eb017244d3
21 changed files with 1135 additions and 540 deletions

View File

@@ -49,6 +49,24 @@ const menuList = computed(() => appStore.menuList);
const navList = computed(() => appStore.navList);
const currNav = computed(() => appStore.currNav);
const activeMenuName = computed(() => {
if (route.name === "full-discount-detail") {
const type = route.query?.activityType;
const candidates =
type === "FULL_GIFT"
? ["manager-full-gift", "full-gift", "full-discount", "full-cut-activity"]
: type === "FULL_RATE"
? ["manager-full-rate", "full-rate", "full-discount", "full-cut-activity"]
: ["full-discount", "full-cut-activity"];
for (const candidate of candidates) {
for (const item of menuList.value) {
for (const menu of item.children || []) {
if (menu.name === candidate || menu.path === candidate) {
return menu.name;
}
}
}
}
}
const routePath = util.normalizeRoutePath(route.path);
for (const item of menuList.value) {
for (const menu of item.children || []) {

View File

@@ -433,9 +433,15 @@
<!-- 当选择完活动之后的其他回调 -->
<span v-if="item.url.___type == 'marketing'">
<span v-if="item.url.___promotion == 'SECKILL'"> 秒杀 </span>
<span v-if="item.url.___promotion == 'FULL_DISCOUNT'">
<span v-if="item.url.___promotion == 'FULL_DISCOUNT' || item.url.___promotion == 'FULL_MINUS'">
满减
</span>
<span v-if="item.url.___promotion == 'FULL_RATE'">
满折
</span>
<span v-if="item.url.___promotion == 'FULL_GIFT'">
满赠
</span>
<span v-if="item.url.___promotion == 'PINTUAN'"> 拼团 </span>
{{ item.url.title || item.url.goodsName }}</span>
<!-- 当选择完活动之后的其他回调 -->

View File

@@ -1,3 +1,8 @@
<!--
管理端-满减/满折/满赠活动详情只读视图(适配 1~3 级阶梯及满赠多赠品展示)
@author Bulbasaur
@since 2026-11-09
-->
<template>
<div class="full-discount-detail-page">
<!-- 顶部标题与返回栏 -->
@@ -5,7 +10,7 @@
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>满优惠活动详情</span>
<span>{{ activityTypeTitle }}详情</span>
<el-tag type="info" size="small" effect="plain" class="mode-tag">
只读模式
</el-tag>
@@ -30,13 +35,17 @@
<span class="font-semibold text-dark">{{ form.promotionName || "-" }}</span>
</el-descriptions-item>
<el-descriptions-item label="活动类型">
<el-tag size="small" :type="activityTagType">{{ activityTypeTitle }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="活动范围">
<el-tag size="small" effect="light" :type="form.scopeType === 'ALL' ? 'success' : 'primary'">
{{ form.scopeType === "ALL" ? "全品类通用" : "指定商品适用" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="活动时间" :span="2">
<el-descriptions-item label="活动时间">
<span class="font-mono text-dark">
{{ form.startTime || "-" }} 至 {{ form.endTime || "-" }}
</span>
@@ -48,67 +57,50 @@
</el-descriptions>
</el-card>
<!-- 优惠规则与赠品权益卡片 -->
<!-- 阶梯优惠规则卡片 -->
<el-card shadow="never" class="info-card mt_10">
<div class="section-header">
<span class="section-indicator"></span>
<span class="section-title">优惠规则与权益</span>
<span class="section-title">阶梯优惠规则</span>
<span class="section-badge">共 {{ (form.ruleList || []).length }} 级阶梯(仅生效满足的最高一级)</span>
</div>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item label="优惠门槛">
<el-descriptions :column="1" border class="custom-descriptions">
<el-descriptions-item
v-for="(tier, idx) in form.ruleList || []"
:key="idx"
:label="`第 ${idx + 1} 级阶梯`"
>
<span class="money-highlight font-mono font-semibold">
满 ¥ {{ Number(form.fullMoney || 0).toFixed(2) }}
满 ¥ {{ Number(tier.fullMoney || 0).toFixed(2) }}
</span>
<span class="text-secondary ml_10" style="font-size: 12px">(达到当前金额可享优惠)</span>
</el-descriptions-item>
<el-descriptions-item label="优惠形式">
<template v-if="form.discountType === 'fullMinusFlag'">
<el-tag size="small" effect="light" type="danger">减现金</el-tag>
<template v-if="form.activityType === 'FULL_MINUS'">
<el-tag size="small" effect="light" type="danger" class="ml_10">减现金</el-tag>
<span class="money-highlight font-mono font-semibold ml_10">
减 ¥ {{ Number(form.fullMinus || 0).toFixed(2) }}
立减 ¥ {{ Number(tier.fullMinus || 0).toFixed(2) }}
</span>
</template>
<template v-else-if="form.discountType === 'fullRateFlag'">
<el-tag size="small" effect="light" type="warning">打折</el-tag>
<template v-else-if="form.activityType === 'FULL_RATE'">
<el-tag size="small" effect="light" type="warning" class="ml_10">打折</el-tag>
<span class="money-highlight font-mono font-semibold ml_10">
打 {{ form.fullRate }} 折
打 {{ tier.fullRate }} 折
</span>
</template>
<template v-else>
<span class="text-secondary">无直接立减/打折</span>
<template v-else-if="form.activityType === 'FULL_GIFT'">
<el-tag size="small" effect="light" type="success" class="ml_10">满赠</el-tag>
<div class="tier-gift-tags">
<el-tag
v-for="(gift, gIdx) in tier.giftItems || []"
:key="gIdx"
size="small"
type="success"
effect="plain"
>
{{ formatGiftName(gift) }} × {{ gift.num || 1 }}件
</el-tag>
</div>
</template>
</el-descriptions-item>
<el-descriptions-item label="附加赠送权益" :span="2">
<div class="extra-rights-row">
<el-tag :type="form.freeFreightFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.freeFreightFlag ? "✓ 包邮(免运费)" : "不包邮" }}
</el-tag>
<el-tag :type="form.couponFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.couponFlag ? "✓ 赠送优惠券" : "不送券" }}
</el-tag>
<el-tag :type="form.giftFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.giftFlag ? "✓ 赠送赠品" : "不送赠品" }}
</el-tag>
<el-tag :type="form.pointFlag ? 'success' : 'info'" effect="plain" size="small">
{{ form.pointFlag ? "✓ 赠送积分" : "不送积分" }}
</el-tag>
</div>
</el-descriptions-item>
<el-descriptions-item v-if="form.couponFlag" label="赠送优惠券" :span="2">
<span class="font-semibold text-dark">{{ couponNameText }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="form.giftFlag" label="赠品信息" :span="2">
<span class="font-semibold text-dark">{{ giftNameText }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="form.pointFlag" label="赠送积分数" :span="2">
<span class="font-mono font-semibold text-dark">{{ form.point ?? 0 }} 积分</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
@@ -186,7 +178,7 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getPlatformCouponList, getFullDiscountById } from "@/api/promotion";
import { getFullDiscountById } from "@/api/promotion";
import { getGoodsSkuData } from "@/api/goods";
import { useAppStore } from "@/stores/app";
@@ -198,64 +190,83 @@ const appStore = useAppStore();
const id = ref(route.query.id);
const form = ref({
discountType: "fullMinusFlag",
activityType: "FULL_MINUS",
scopeType: "ALL",
promotionGoodsList: [],
ruleList: [],
});
const couponList = ref([]);
const giftList = ref([]);
const couponNameText = computed(() => {
if (!form.value.couponId) return "未指定优惠券";
const found = couponList.value.find((c) => String(c.id) === String(form.value.couponId));
return found ? found.couponName : `优惠券 ID: ${form.value.couponId}`;
const activityTypeTitle = computed(() => {
if (form.value.activityType === "FULL_RATE") return "满折活动";
if (form.value.activityType === "FULL_GIFT") return "满赠活动";
return "满减活动";
});
const giftNameText = computed(() => {
if (!form.value.giftId) return "未指定赠品";
const found = giftList.value.find((g) => String(g.id) === String(form.value.giftId));
return found ? found.goodsName : `赠品 ID: ${form.value.giftId}`;
const activityTagType = computed(() => {
if (form.value.activityType === "FULL_RATE") return "warning";
if (form.value.activityType === "FULL_GIFT") return "success";
return "danger";
});
onMounted(() => {
if (id.value) {
getDetail();
}
getCouponList();
getGiftList();
});
function formatGiftName(gift) {
if (gift?.goodsName) return gift.goodsName;
const found = giftList.value.find((g) => String(g.id) === String(gift?.skuId));
return found ? found.goodsName : `赠品 ID: ${gift?.skuId || "-"}`;
}
function getDetail() {
getFullDiscountById(id.value).then((res) => {
if (res && res.success && res.result) {
let data = res.result;
const data = res.result;
if (data.scopeType !== "PORTION_GOODS") {
data.promotionGoodsList = [];
}
if (data.fullRateFlag) {
data.discountType = "fullRateFlag";
} else {
data.discountType = "fullMinusFlag";
if (!data.activityType) {
if (data.fullRateFlag) {
data.activityType = "FULL_RATE";
} else if (!data.fullMinusFlag && data.giftFlag) {
data.activityType = "FULL_GIFT";
} else {
data.activityType = "FULL_MINUS";
}
}
if (!Array.isArray(data.ruleList) || !data.ruleList.length) {
if (data.rulesJson) {
try {
data.ruleList = JSON.parse(data.rulesJson);
} catch (e) {
data.ruleList = [];
}
}
if (!data.ruleList || !data.ruleList.length) {
data.ruleList = [
{
fullMoney: data.fullMoney,
fullMinus: data.fullMinus,
fullRate: data.fullRate,
giftItems: data.giftId
? String(data.giftId)
.split(",")
.filter(Boolean)
.map((skuId) => ({ skuId, goodsName: data.giftSkuName, num: 1 }))
: [],
},
];
}
}
form.value = data;
}
});
}
function getCouponList() {
const params = {
pageSize: 50,
pageNumber: 1,
getType: "ACTIVITY",
promotionStatus: "START",
};
getPlatformCouponList(params).then((res) => {
if (res && res.success && res.result) {
couponList.value = res.result.records || [];
}
});
}
function getGiftList() {
const params = {
pageSize: 50,
@@ -357,11 +368,11 @@ function back() {
font-size: 14px;
}
.extra-rights-row {
.tier-gift-tags {
margin-top: 8px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
gap: 8px;
}
.custom-table {

View File

@@ -1,3 +1,8 @@
<!--
管理端-满额活动列表页(支持满减活动 FULL_MINUS、满折活动 FULL_RATE、满赠活动 FULL_GIFT)
@author Bulbasaur
@since 2026-11-09
-->
<template>
<div class="search full-cut">
<el-card>
@@ -55,16 +60,23 @@
class="mt_10"
style="width: 100%"
>
<el-table-column prop="promotionName" label="活动名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="startTime" label="开始时间" width="170" />
<el-table-column prop="endTime" label="结束时间" width="170" />
<el-table-column prop="storeName" label="店铺名称" min-width="100" show-overflow-tooltip />
<el-table-column label="活动类型" min-width="80">
<el-table-column prop="promotionName" label="活动名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="storeName" label="店铺名称" min-width="120" show-overflow-tooltip />
<el-table-column label="阶梯优惠规则" min-width="240" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row">{{ row.fullMinusFlag ? "满减" : "满折" }}</span>
<span v-if="row">{{ formatRowRules(row) }}</span>
</template>
</el-table-column>
<el-table-column label="活动状态" min-width="100">
<el-table-column prop="startTime" label="开始时间" width="170" />
<el-table-column prop="endTime" label="结束时间" width="170" />
<el-table-column label="活动类型" width="90">
<template #default="{ row }">
<el-tag v-if="row" size="small" effect="plain" :type="activityTagType(row)">
{{ activityTypeText(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="活动状态" width="100">
<template #default="{ row }">
<el-tag v-if="row" :type="promotionStatusTagType(row.promotionStatus)">
{{ promotionStatusText(row.promotionStatus) }}
@@ -101,14 +113,39 @@
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getFullDiscountList, updateFullDiscount } from "@/api/promotion.js";
import { Message, Modal } from "@/utils/message";
const props = defineProps({
/**
* 固定活动子类型(FULL_MINUS-满减,FULL_RATE-满折,FULL_GIFT-满赠)
*/
defaultActivityType: {
type: String,
default: "",
},
});
const route = useRoute();
const router = useRouter();
// 模板 ref(script 未使用,仅避免与 data 同名互相覆盖)
const currentActivityType = computed(() => {
if (props.defaultActivityType) {
return props.defaultActivityType;
}
const routePath = String(route.path || "");
const routeName = String(route.name || "");
if (routePath.includes("full-rate") || routeName.includes("full-rate")) {
return "FULL_RATE";
}
if (routePath.includes("full-gift") || routeName.includes("full-gift")) {
return "FULL_GIFT";
}
return "FULL_MINUS";
});
const searchFormRef = ref(null);
const tableRef = ref(null);
@@ -120,9 +157,67 @@ const searchForm = reactive({
pageSize: 20,
sort: "createTime",
order: "desc",
promotionName: "",
promotionStatus: "",
});
const data = ref([]);
watch(currentActivityType, () => {
searchForm.pageNumber = 1;
getDataList();
});
function resolveRowType(row) {
if (row?.activityType) return row.activityType;
if (row?.fullRateFlag) return "FULL_RATE";
if (!row?.fullMinusFlag && row?.giftFlag) return "FULL_GIFT";
return "FULL_MINUS";
}
function activityTypeText(row) {
const type = resolveRowType(row);
if (type === "FULL_RATE") return "满折";
if (type === "FULL_GIFT") return "满赠";
return "满减";
}
function activityTagType(row) {
const type = resolveRowType(row);
if (type === "FULL_RATE") return "warning";
if (type === "FULL_GIFT") return "success";
return "danger";
}
function formatRowRules(row) {
if (row?.title) return row.title;
if (row?.rulesJson) {
try {
const rules = JSON.parse(row.rulesJson);
if (Array.isArray(rules) && rules.length) {
const type = resolveRowType(row);
return rules
.map((r) => {
if (type === "FULL_RATE") return `满${r.fullMoney}元打${r.fullRate}折`;
if (type === "FULL_GIFT") {
const count = Array.isArray(r.giftItems) ? r.giftItems.length : 1;
return `满${r.fullMoney}元赠${count}种商品`;
}
return `满${r.fullMoney}元减${r.fullMinus}元`;
})
.join(";");
}
} catch (e) {
// ignore
}
}
if (row?.fullMoney != null) {
if (row.fullRateFlag) return `满${row.fullMoney}元打${row.fullRate}折`;
if (row.giftFlag) return `满${row.fullMoney}元享赠品`;
return `满${row.fullMoney}元减${row.fullMinus}元`;
}
return "-";
}
function promotionStatusText(status) {
const map = {
NEW: "未开始",
@@ -145,18 +240,16 @@ function init() {
getDataList();
}
function openOrClose(row) {
let name = "开启";
if (row.promotionStatus === "NEW" || row.promotionStatus === "START") {
name = "关闭";
Modal.confirm({
title: "提示",
content: `确认${name}此活动吗?需要一定时间才能生效,请耐心等待`,
content: "确认关闭此活动吗?需要一定时间才能生效,请耐心等待",
loading: true,
onOk: () => {
updateFullDiscount(row.id).then((res) => {
Modal.remove();
if (res.success) {
Message.success(`${name}成功`);
Message.success("关闭成功");
getDataList();
}
});
@@ -185,7 +278,11 @@ function getDataList() {
searchForm.startTime = null;
searchForm.endTime = null;
}
getFullDiscountList(searchForm).then((res) => {
const query = {
...searchForm,
activityType: currentActivityType.value,
};
getFullDiscountList(query).then((res) => {
loading.value = false;
if (res.success) {
data.value = res.result.records;
@@ -194,7 +291,10 @@ function getDataList() {
});
}
function view(row) {
router.push({ name: "full-discount-detail", query: { id: row.id } });
router.push({
name: "full-discount-detail",
query: { id: row.id, activityType: resolveRowType(row) },
});
}
onMounted(() => {

View File

@@ -0,0 +1,14 @@
<!--
管理端-满赠活动列表页
@author Bulbasaur
@since 2026-11-09
-->
<template>
<FullDiscountList default-activity-type="FULL_GIFT" />
</template>
<script setup>
import FullDiscountList from "./full-discount.vue";
defineOptions({ name: "manager-full-gift" });
</script>

View File

@@ -0,0 +1,14 @@
<!--
管理端-满折活动列表页
@author Bulbasaur
@since 2026-11-09
-->
<template>
<FullDiscountList default-activity-type="FULL_RATE" />
</template>
<script setup>
import FullDiscountList from "./full-discount.vue";
defineOptions({ name: "manager-full-rate" });
</script>

View File

@@ -57,7 +57,9 @@ const activityMetrics = ref([
{ key: "couponActiveNum", label: "进行中优惠券" },
{ key: "seckillActiveNum", label: "进行中秒杀" },
{ key: "pintuanActiveNum", label: "进行中拼团" },
{ key: "fullDiscountActiveNum", label: "进行中满减" },
{ key: "fullMinusActiveNum", label: "进行中满减" },
{ key: "fullRateActiveNum", label: "进行中满折" },
{ key: "fullGiftActiveNum", label: "进行中满赠" },
{ key: "kanjiaActiveNum", label: "进行中砍价商品" },
{ key: "pointsGoodsActiveNum", label: "进行中积分商品" },
]);
@@ -128,7 +130,7 @@ h4 {
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.metric-item {