feat(buyer): 更新电子卡券(E_COUPON)相关逻辑与展示

- 优化购物车与支付页面,确保电子卡券只能通过立即购买结算
- 增强商品详情页,添加限时折扣与第N件优惠展示
- 更新促销逻辑,支持拼团与积分购买选项
- 规范化商品类型与库存提示,提升用户体验
This commit is contained in:
田香琪
2026-08-02 13:55:19 +08:00
parent 1ae7ab3036
commit 1accdf5826
24 changed files with 920 additions and 311 deletions

View File

@@ -248,6 +248,7 @@ import Promotion from "@/components/goodsDetail/Promotion";
import Search from "@/components/Search";
import * as APICart from "@/api/cart";
import * as APIMember from "@/api/member";
import { isECoupon } from "@/constants/goodsType";
export default {
name: "Cart",
beforeRouteEnter(to, from, next) {
@@ -407,11 +408,20 @@ export default {
},
// 跳转支付页面
pay() {
if (this.checkedNum) {
this.$router.push({ path: "/pay", query: { way: "CART" } });
} else {
if (!this.checkedNum) {
Message.warning("请至少选择一件商品");
return;
}
const hasECoupon = this.cartList.some((shop) =>
(shop.skuList || []).some(
(goods) => goods.checked && isECoupon(goods.goodsSku && goods.goodsSku.goodsType)
)
);
if (hasECoupon) {
Message.warning("电子卡券请使用立即购买,不可通过购物车结算");
return;
}
this.$router.push({ path: "/pay", query: { way: "CART" } });
},
// 展示优惠券
showCoupon(storeId, index) {

View File

@@ -132,35 +132,59 @@
</template>
<div v-else style="color: #999; margin-left: 5px">未开发票</div>
</div>
<!-- 电子卡券卡密信息PC 内嵌 -->
<div class="order-card ecoupon-card-keys" v-if="isECouponOrder">
<!-- 卡密信息E_COUPON 主单或满赠子单,以 cardKeyFulfillStatus 为准 -->
<div class="order-card ecoupon-card-keys" v-if="hasCardKeySection">
<h3>卡密信息</h3>
<el-alert
v-if="!ecouponCardKeyDelivered"
type="info"
show-icon
:closable="false"
title="卡密尚未发放,请稍后刷新或联系商家"
/>
<template v-else-if="ecouponCardKeyRows.length">
<div class="ecoupon-card-keys-mobile">
<el-button type="primary" @click="cardKeyDialogVisible = true">查看卡密</el-button>
<div
v-for="line in cardKeyFulfillLines"
:key="line.key"
class="ecoupon-fulfill-block"
>
<div v-if="cardKeyFulfillLines.length > 1" class="ecoupon-fulfill-title">
{{ line.goodsName || (line.isGift ? "满赠电子卡券" : "电子卡券") }}
</div>
<div class="ecoupon-card-keys-pc">
<el-table border :data="ecouponCardKeyRows" style="width: 100%">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="cardNo" label="卡号" min-width="140" show-overflow-tooltip />
<el-table-column prop="cardSecret" label="卡密" min-width="120" show-overflow-tooltip />
<el-table-column prop="allocatedTime" label="发卡时间" width="170" />
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button v-if="row" link type="primary" @click="copyCardKey(row)">复制</el-button>
</template>
</el-table-column>
</el-table>
<el-button class="mt_10" size="small" @click="copyAllCardKeys">复制全部卡密</el-button>
</div>
</template>
<el-alert
v-if="line.status !== 'DELIVERED'"
:type="cardKeyFulfillAlertType(line.status)"
show-icon
:closable="false"
:title="resolveCardKeyFulfillMessage(line)"
class="mb_10"
/>
<template v-else-if="line.cardKeys.length">
<div class="ecoupon-card-keys-mobile">
<el-button type="primary" @click="cardKeyDialogVisible = true">查看卡密</el-button>
</div>
<div class="ecoupon-card-keys-pc">
<el-table border :data="lineCardKeyRows(line)" style="width: 100%">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column
v-if="cardKeyFulfillLines.length > 1"
prop="goodsName"
label="商品"
min-width="120"
show-overflow-tooltip
/>
<el-table-column prop="cardNo" label="卡号" min-width="140" show-overflow-tooltip />
<el-table-column prop="cardSecret" label="卡密" min-width="120" show-overflow-tooltip />
<el-table-column prop="allocatedTime" label="发卡时间" width="170" />
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button v-if="row" link type="primary" @click="copyCardKey(row)">复制</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
<el-button
v-if="ecouponCardKeyRows.length"
class="mt_10 ecoupon-card-keys-pc"
size="small"
@click="copyAllCardKeys"
>
复制全部卡密
</el-button>
</div>
<!-- 订单商品 -->
@@ -378,7 +402,14 @@ import {
getPackage
} from "@/api/order.js";
import { afterSaleReason, receiptDetail } from "@/api/member";
import { isECouponOrder as checkECouponOrder } from "@/constants/goodsType";
import {
isECouponOrder as checkECouponOrder,
collectCardKeyFulfillLines,
orderHasCardKeySection,
flattenOrderCardKeys,
cardKeyFulfillAlertType,
resolveCardKeyFulfillMessage,
} from "@/constants/goodsType";
/**
* 订单详情E_COUPON 展示 orderItems[].cardKeys屏蔽售后/物流FR-B-03 / FR-B-04
@@ -415,20 +446,37 @@ export default {
isNonPhysicalOrder() {
return this.isVirtualOrder || this.isECouponOrder;
},
ecouponCardKeyDelivered() {
return (this.order.orderItems || []).some((item) => item.cardKeyDelivered);
/** 是否展示卡密区块(含满赠 E_COUPON 子单 giftECouponOrders */
hasCardKeySection() {
return orderHasCardKeySection({
orderItems: this.order.orderItems,
giftSummaries: this.order.giftECouponOrders,
isECouponOrder: this.isECouponOrder,
});
},
cardKeyFulfillLines() {
return collectCardKeyFulfillLines(
this.order.orderItems,
this.order.giftECouponOrders
);
},
ecouponCardKeyRows() {
const rows = [];
(this.order.orderItems || []).forEach((item) => {
(item.cardKeys || []).forEach((ck) => {
rows.push({ ...ck });
});
});
return rows;
return flattenOrderCardKeys(
this.order.orderItems,
false,
this.order.giftECouponOrders
);
},
},
methods: {
cardKeyFulfillAlertType,
resolveCardKeyFulfillMessage,
lineCardKeyRows(line) {
return (line.cardKeys || []).map((ck) => ({
...ck,
goodsName: line.goodsName,
}));
},
copyCardKey(row) {
const text = `卡号:${row.cardNo || ""}\n卡密${row.cardSecret || ""}`;
this.copyText(text);
@@ -767,6 +815,18 @@ table {
display: none;
}
.ecoupon-fulfill-block + .ecoupon-fulfill-block {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #eee;
}
.ecoupon-fulfill-title {
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.ecoupon-card-keys-pc {
display: block;
}

View File

@@ -151,8 +151,8 @@
</div>
</div>
</div>
<!-- 发票信息 -->
<div class="invoice">
<!-- 发票信息E_COUPON 虚拟型结算不开发票 -->
<div class="invoice" v-if="!isECouponCheckout">
<div class="card-head mt_20 mb_20">
<span class="relative">发票信息<span class="inv-tips">
<el-icon><Warning /></el-icon>开企业抬头发票须填写纳税人识别号以免影响报销
@@ -167,8 +167,8 @@
<span @click="editInvoice">编辑</span>
</div>
</div>
<!-- 优惠券E_COUPON 不参与促销 M-01 -->
<div class="invoice" v-if="!isECouponCheckout">
<!-- 优惠券 -->
<div class="invoice">
<div class="card-head mt_20 mb_20">
<span class="relative">优惠券</span>
</div>
@@ -204,7 +204,7 @@
</ul>
</div>
<!-- 礼品卡 -->
<div class="invoice pay-gcc-module" v-if="!isECouponCheckout">
<div class="invoice pay-gcc-module">
<div class="pay-gcc-head">
<div class="pay-gcc-title">
使用礼品卡
@@ -284,13 +284,13 @@
<!-- 底部支付栏 -->
<div class="order-footer">
<div class="pay ml_20" @click="pay">提交订单</div>
<div class="pay-address" v-if="addressList.length && selectedDeliverMethod === 'LOGISTICS'">
<div class="pay-address" v-if="!isVirtualLikeCheckout && addressList.length && selectedDeliverMethod === 'LOGISTICS'">
配送至{{ $filters.unitAddress(selectedAddress.consigneeAddressPath) }}
{{ selectedAddress.detail }}&nbsp;&nbsp;收货人{{
selectedAddress.name
}}&nbsp;&nbsp;{{ selectedAddress.mobile }}
</div>
<div class="pay-address" v-if="addressList.length && selectedDeliverMethod === 'SELF_PICK_UP'">
<div class="pay-address" v-if="!isVirtualLikeCheckout && addressList.length && selectedDeliverMethod === 'SELF_PICK_UP'">
自提地点{{selectedStoreAddress.address}} &nbsp;&nbsp;联系方式{{ selectedStoreAddress.mobile }}
</div>
</div>
@@ -329,7 +329,7 @@ import {
} from "@/api/cart";
import { getStoreAddress } from "@/api/shopentry.js"
import { canUseCouponList } from "@/api/member.js";
import { isECoupon } from "@/constants/goodsType";
import { isECoupon, shouldSkipPaymentPage } from "@/constants/goodsType";
export default {
name: "Pay",
@@ -379,7 +379,7 @@ export default {
);
return Number.isFinite(n) ? n : 0;
},
/** 电子卡券结算:免地址、隐藏优惠券/礼品卡/发票M-01 / O-11 */
/** 电子卡券结算:免地址、隐藏发票;优惠券/礼品卡/促销明细可用M-02 */
isECouponCheckout() {
return isECoupon(this.goodsType);
},
@@ -855,15 +855,15 @@ export default {
.then((res) => {
Spin.hide();
if (res.success) {
if (params.way === "POINTS") {
// 积分支付不需要跳转支付页面
const way = params.way;
if (shouldSkipPaymentPage(res.result, way)) {
this.$router.push("/payDone");
} else {
this.$router.push({
path: "/payment",
query: { orderType: "TRADE", sn: res.result.sn },
});
return;
}
this.$router.push({
path: "/payment",
query: { orderType: "TRADE", sn: res.result.sn },
});
} else if (res.message) {
Message.warning(res.message);
}

View File

@@ -37,7 +37,7 @@
</template>
<script>
import {tradeDetail, pay} from '@/api/pay.js';
import { tradeDetail, pay } from '@/api/pay.js';
import { Message, Modal } from "@/utils/message";
export default {
@@ -78,6 +78,10 @@ export default {
this.isStart = true
this.support = this.payDetail.support
this.walletValue = this.payDetail.walletValue
const price = Number(this.payDetail.price);
if (!Number.isNaN(price) && price <= 0) {
this.$router.replace('/payDone');
}
}
}).catch((err) => {
this.$router.push({name: 'MyOrder'});

View File

@@ -2,18 +2,14 @@
<div style="background:#fff;">
<BaseHeader></BaseHeader>
<Search style="border-bottom:2px solid red;"></Search>
<!-- <drawer></drawer> -->
<div class="base-width cate-container">
<el-breadcrumb>
<el-breadcrumb-item to="/">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{goodsMsg.pointsGoodsCategoryName}}</el-breadcrumb-item>
<el-breadcrumb-item>{{ goodsMsg.pointsGoodsCategoryName }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<!-- 商品信息展示 -->
<div class="item-detail-show">
<!-- 详情左侧展示数据图片收藏举报 -->
<div class="item-detail-left">
<!-- 大图放大镜 -->
<div class="item-detail-big-img">
<el-image
v-if="goodsSku.thumbnail"
@@ -29,22 +25,22 @@
</div>
</div>
</div>
<!-- 右侧商品信息活动信息操作展示 -->
<div class="item-detail-right">
<div class="item-detail-title">
<p>{{ goodsSku.goodsName }}</p>
<div class="item-detail-title-row">
<p>{{ goodsSku.goodsName }}</p>
<el-tag v-if="isECouponSku" class="goods-type-tag" size="small">电子卡券</el-tag>
</div>
</div>
<div class="sell-point">
{{goodsSku.sellingPoint}}
{{ goodsSku.sellingPoint }}
</div>
<!-- 商品详细 -->
<div class="item-detail-price-row">
<div class="item-price-left">
<!-- 商品原价 -->
<div class="item-price-row">
<p>
<span class="item-price-title"> &nbsp;&nbsp;&nbsp;&nbsp;</span>
<span class="item-price">{{goodsMsg.points}}</span>
<span class="item-price">{{ goodsMsg.points }}</span>
</p>
</div>
</div>
@@ -55,25 +51,37 @@
<p>数量</p>
</div>
<div class="item-select-row">
<el-input-number :min="1" :disabled="goodsSku.quantity === 0" v-model="count"></el-input-number>
<span class="inventory"> 库存{{goodsSku.quantity}}</span>
<el-input-number
:min="1"
:max="quantityMax"
:disabled="isOutOfStock"
v-model="count"
/>
<span class="inventory"> 库存{{ displayStock }}</span>
</div>
</div>
<div class="item-select" v-if="goodsSku.goodsType !== 'VIRTUAL_GOODS' && goodsSku.weight !== 0">
<div
class="item-select"
v-if="!isECouponSku && goodsSku.goodsType !== 'VIRTUAL_GOODS' && goodsSku.weight !== 0"
>
<div class="item-select-title">
<p>重量</p>
</div>
<div class="item-select-row">
<span class="inventory"> {{goodsSku.weight}}kg</span>
<span class="inventory"> {{ goodsSku.weight }}kg</span>
</div>
</div>
<div class="add-buy-car">
<el-button type="danger" :loading="loading" :disabled="goodsSku.quantity === 0" @click="pointBuy">积分购买</el-button>
<el-button
type="danger"
:loading="loading"
:disabled="isOutOfStock"
@click="pointBuy"
>积分购买</el-button>
</div>
</div>
</div>
</div>
<!-- 商品详情 -->
<div class="base-width item-intro" ref="itemIntroGoods">
<div>商品介绍</div>
<div v-html="goodsSku.intro" class="mt_10 ml_10" v-if="goodsSku.intro"></div>
@@ -86,101 +94,140 @@
<script>
import { Message } from "@/utils/message";
import Search from '@/components/Search';
import { addCartGoods } from '@/api/cart.js';
import { pointGoodsDetail } from '@/api/promotion';
import Search from "@/components/Search";
import { addCartGoods } from "@/api/cart.js";
import { pointGoodsDetail } from "@/api/promotion";
import {
isECoupon,
getECouponStock,
getECouponMaxBuyNum,
E_COUPON_CART_POINTS,
} from "@/constants/goodsType";
export default {
name: 'PointGoodsDetail',
beforeRouteEnter (to, from, next) {
name: "PointGoodsDetail",
components: { Search },
beforeRouteEnter(to, from, next) {
window.scrollTo(0, 0);
next();
},
created () {
created() {
this.getGoodsDetail();
},
mounted () {
window.addEventListener('scroll', this.handleScroll)
mounted() {
window.addEventListener("scroll", this.handleScroll);
},
data () {
data() {
return {
goodsMsg: {}, // 商品信息
goodsSku: {}, // 商品sku
isLoading: false, // 加载状态
categoryBar: [], // 分类
onceFlag: true, // 只调用一次
count: 1, // 购买商品数量
loading: false // 提交加载状态
goodsMsg: {},
goodsSku: {},
isLoading: false,
onceFlag: true,
count: 1,
loading: false,
};
},
methods: {
// 获取积分商品详情
getGoodsDetail () {
this.isLoading = true;
pointGoodsDetail(this.$route.query.id).then((res) => {
this.isLoading = false;
if (res.success) {
this.goodsMsg = res.result;
this.goodsSku = res.result.goodsSku
} else {
Message.error(res.message)
this.$router.push('/')
}
}).catch(() => {
this.$router.push('/')
});
computed: {
isECouponSku() {
return isECoupon(this.goodsSku.goodsType);
},
pointBuy () {
displayStock() {
if (this.isECouponSku) {
return getECouponStock(this.goodsSku);
}
return Number(this.goodsSku.quantity) || 0;
},
quantityMax() {
if (this.isECouponSku) {
return getECouponMaxBuyNum(this.goodsSku);
}
const qty = Number(this.goodsSku.quantity) || 0;
return qty > 0 ? qty : 1;
},
isOutOfStock() {
return this.displayStock <= 0;
},
},
methods: {
getGoodsDetail() {
this.isLoading = true;
pointGoodsDetail(this.$route.query.id)
.then((res) => {
this.isLoading = false;
if (res.success) {
this.goodsMsg = res.result;
this.goodsSku = res.result.goodsSku || {};
this.syncCountWithQuantity();
} else {
Message.error(res.message);
this.$router.push("/");
}
})
.catch(() => {
this.$router.push("/");
});
},
syncCountWithQuantity() {
const max = this.quantityMax;
if (max <= 0) {
this.count = 1;
return;
}
if (this.count > max) this.count = max;
if (this.count < 1) this.count = 1;
},
pointBuy() {
const params = {
num: this.count,
skuId: this.goodsMsg.skuId,
cartType: 'POINTS'
cartType: E_COUPON_CART_POINTS,
};
this.loading = true;
addCartGoods(params).then(res => {
this.loading = false;
if (res.success) {
this.$router.push({path: '/pay', query: {way: params.cartType}});
} else {
Message.warning(res.message);
}
}).catch(() => {
this.loading = false;
});
addCartGoods(params)
.then((res) => {
this.loading = false;
if (res.success) {
this.$router.push({ path: "/pay", query: { way: params.cartType } });
} else {
Message.warning(res.message);
}
})
.catch(() => {
this.loading = false;
});
},
handleScroll () { // 监听页面滚动
handleScroll() {
if (this.onceFlag) {
this.$nextTick(() => {
this.changeHeight()
this.changeHeight();
});
this.onceFlag = false
this.onceFlag = false;
}
},
changeHeight () { // 设置商品详情高度
let goodsDetailCon = document.querySelector('.item-intro')
changeHeight() {
const goodsDetailCon = document.querySelector(".item-intro");
if (!goodsDetailCon || !this.$refs.itemIntroGoods) return;
let heightCss = window.getComputedStyle(goodsDetailCon).height;
heightCss = parseInt(heightCss.substr(0, heightCss.length - 2)) + 89;
this.$refs.itemIntroGoods.style.height = heightCss + 'px';
}
heightCss = parseInt(heightCss.substr(0, heightCss.length - 2), 10) + 89;
this.$refs.itemIntroGoods.style.height = heightCss + "px";
},
},
components: {
Search,
}
};
</script>
<style scoped lang="scss">
.base-width{
.base-width {
width: 1200px;
margin: 0 auto;
position: relative;
}
.cate-container{
.cate-container {
background-color: #eee;
height: 30px;
line-height: 30px;
padding-left: 10px;
margin-top: 10px;
}
// 商品图片,价格等
.item-detail-show {
width: 1200px;
margin: 0 auto;
@@ -192,7 +239,6 @@ export default {
width: 350px;
margin-right: 30px;
}
.item-detail-big-img {
width: 350px;
height: 350px;
@@ -202,12 +248,10 @@ export default {
width: 100%;
}
}
.item-detail-img-row {
margin-top: 15px;
display: flex;
}
.item-detail-img-small {
width: 68px;
height: 68px;
@@ -219,20 +263,27 @@ export default {
width: 100%;
}
}
/*商品选购详情*/
.item-detail-right {
flex: 1;
display: flex;
flex-direction: column;
}
.item-detail-title-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.item-detail-title p {
@include content_color($light_content_color);
font-weight: bold;
font-size: 20px;
padding: 8px 0;
margin: 0;
}
.goods-type-tag {
flex-shrink: 0;
}
.item-detail-express {
font-size: 14px;
padding: 2px 3px;
@@ -240,15 +291,6 @@ export default {
background-color: $theme_color;
color: #fff;
}
/*商品标签*/
.item-detail-tag {
padding: 8px 0;
font-size: 12px;
color: $theme_color;
}
/*价格详情等*/
.item-detail-price-row {
padding: 10px;
display: flex;
@@ -256,42 +298,31 @@ export default {
justify-content: space-between;
background: url("../../assets/images/goodsDetail/price-bg.png");
}
.item-price-left {
display: flex;
flex-direction: column;
}
.item-price-title {
color: #999999;
font-size: 14px;
margin-right: 15px;
}
.item-price-row {
margin: 5px 0px;
}
.item-price {
color: $theme_color;
font-size: 20px;
cursor: pointer;
}
.item-price-old {
color: gray;
text-decoration: line-through;
font-size: 14px;
margin-left: 5px;
}
.add-buy-car-box {
width: 100%;
margin-top: 15px;
border-top: 1px dotted $border_color;
}
.add-buy-car {
margin-top: 15px;
>*{
> * {
margin: 0 4px;
}
}
@@ -300,57 +331,23 @@ export default {
flex-direction: row;
margin-top: 15px;
}
.item-select-title {
@include content_color($light_content_color);
font-size: 14px;
margin-right: 15px;
width: 60px;
}
.item-select-column {
display: flex;
flex-wrap: wrap;
flex: 1;
}
.item-select-row {
margin-bottom: 8px;
}
.item-select-box {
display: flex;
flex-direction: row;
align-items: center;
padding: 5px;
margin-right: 8px;
@include background_color($light_background_color);
border: 0.5px solid $border_color;
cursor: pointer;
@include content_color($light_content_color);
}
.item-select-box:hover {
border: 0.5px solid $theme_color;
}
.item-select-box-active {
border: 0.5px solid $theme_color;
}
.item-select-intro p {
margin: 0px;
padding: 5px;
}
.sell-point {
font-size: 12px;
color: red;
margin-bottom: 5px;
}
// 商品详情
.item-intro {
margin-top: 10px;
>div:nth-child(1) {
> div:nth-child(1) {
height: 40px;
line-height: 40px;
background-color: #eee;