feat(order,goods): 详情页促销模块独立拆分与实物赠品多端随单履约展示

- 详情页重构:ShowGoods.vue 价格卡片与促销卡片解耦,促销卡片独立分行展示优惠券与满额活动;
- 买家端结算页:Pay.vue 清单中直观展示随单 0 元实物赠品明细行与数量;
- 买家端订单中心:MyOrder.vue 与 OrderDetail.vue 标注【赠品】红色徽标、原价划线展示并屏蔽单独售后申请;
- 管理端与商家端后台:orderDetail.vue 商品表格与待发货弹窗中醒目标注【赠品】及满赠活动类型。
This commit is contained in:
pikachu1995@126.com
2026-11-10 15:20:26 +08:00
parent f33c969d1e
commit 9761479c82
6 changed files with 826 additions and 145 deletions

View File

@@ -70,7 +70,7 @@
v-if="promotionMap['SECKILL']" v-if="promotionMap['SECKILL']"
:time="promotionMap['SECKILL'].endTime" :time="promotionMap['SECKILL'].endTime"
></Promotion> ></Promotion>
<!-- 商品详细 价格、优惠券、促销 --> <!-- 商品详细 价格 -->
<div class="item-detail-price-row"> <div class="item-detail-price-row">
<div class="item-price-left"> <div class="item-price-left">
<!-- 秒杀价格 --> <!-- 秒杀价格 -->
@@ -89,76 +89,6 @@
<div class="item-price-row" v-else> <div class="item-price-row" v-else>
<span class="item-price">{{ $filters.unitPrice(skuDetail.price, "¥") }}</span> <span class="item-price">{{ $filters.unitPrice(skuDetail.price, "¥") }}</span>
</div> </div>
<!-- 优惠券展示 -->
<div class="item-price-coupon-row" v-if="promotionMap['COUPON'].length">
<span class="item-price-title">优惠券</span>
<div class="coupon-tags">
<span
class="item-coupon"
v-for="(item, index) in promotionMap['COUPON'].slice(0, 6)"
:key="index"
@click="handleReceiveCoupon(item.id)"
>
<span v-if="item.couponType == 'PRICE'"
>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '减' + item.price : '无门槛,立减' + item.price + '元' }}</span>
<span v-if="item.couponType == 'DISCOUNT'"
>满{{ item.consumeThreshold }}打{{
item.couponDiscount
}}折</span>
</span>
<div class="dropdown" v-if="promotionMap['COUPON'].length > 6">
<span class="dropdown-more">展开更多 ▾</span>
<div class="dropdown-content">
<span
class="item-coupon"
v-for="(item, index) in promotionMap['COUPON'].slice(6, promotionMap['COUPON'].length)"
:key="index"
@click="handleReceiveCoupon(item.id)"
>
<span v-if="item.couponType == 'PRICE'"
>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '减' + item.price : '无门槛,立减' + item.price + '元' }}</span>
<span v-if="item.couponType == 'DISCOUNT'"
>满{{ item.consumeThreshold }}打{{
item.couponDiscount
}}折</span>
</span>
</div>
</div>
</div>
</div>
<!-- 拼团 -->
<div class="item-price-row" v-if="promotionMap['PINTUAN']">
<p>
<span class="item-price-title">拼团</span>
<span class="item-promotion">拼团价</span>
<span class="item-desc-pintuan" v-if="promotionMap['PINTUAN'].price">
{{ $filters.unitPrice(promotionMap["PINTUAN"].price, "¥") }}
</span>
</p>
</div>
<!-- 满减展示 -->
<div class="item-price-row" v-if="promotionMap['FULL_DISCOUNT']">
<p>
<span class="item-price-title">促销</span>
<span class="item-promotion">满减</span>
<span
class="item-desc-pintuan"
v-if="promotionMap['FULL_DISCOUNT'].fullMinus"
>满{{ promotionMap["FULL_DISCOUNT"].fullMoney }}元,立减现金{{
promotionMap["FULL_DISCOUNT"].fullMinus
}}元</span>
<span
class="item-desc-pintuan"
v-if="
promotionMap['FULL_DISCOUNT'].fullRate &&
promotionMap['FULL_DISCOUNT'].fullRateFlag
"
>满{{ promotionMap["FULL_DISCOUNT"].fullMoney }}元,立享{{
promotionMap["FULL_DISCOUNT"].fullRate
}}折</span>
</p>
</div>
</div> </div>
<div class="item-price-right"> <div class="item-price-right">
<div class="item-remarks-sum"> <div class="item-remarks-sum">
@@ -170,6 +100,69 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 独立促销与优惠模块 -->
<div class="goods-promotions-box" v-if="hasPromotions">
<!-- 优惠券展示 -->
<div class="promo-item-row" v-if="promotionMap['COUPON'] && promotionMap['COUPON'].length">
<span class="promo-title">优惠券</span>
<div class="coupon-tags">
<span
class="item-coupon"
v-for="(item, index) in promotionMap['COUPON'].slice(0, 6)"
:key="index"
@click="handleReceiveCoupon(item.id)"
>
<span v-if="item.couponType == 'PRICE'"
>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '减' + item.price : '无门槛,立减' + item.price + '元' }}</span>
<span v-if="item.couponType == 'DISCOUNT'"
>满{{ item.consumeThreshold }}打{{
item.couponDiscount
}}折</span>
</span>
<div class="dropdown" v-if="promotionMap['COUPON'].length > 6">
<span class="dropdown-more">展开更多 ▾</span>
<div class="dropdown-content">
<span
class="item-coupon"
v-for="(item, index) in promotionMap['COUPON'].slice(6, promotionMap['COUPON'].length)"
:key="index"
@click="handleReceiveCoupon(item.id)"
>
<span v-if="item.couponType == 'PRICE'"
>{{ item.consumeThreshold > 0 ? '满' + item.consumeThreshold + '减' + item.price : '无门槛,立减' + item.price + '元' }}</span>
<span v-if="item.couponType == 'DISCOUNT'"
>满{{ item.consumeThreshold }}打{{
item.couponDiscount
}}折</span>
</span>
</div>
</div>
</div>
</div>
<!-- 促销活动行(满减/满折/满赠、拼团) -->
<div class="promo-item-row" v-if="promotionMap['FULL_DISCOUNT'] || promotionMap['PINTUAN']">
<span class="promo-title">促&emsp;销</span>
<div class="promo-content-column">
<!-- 满减/满折/满赠 -->
<div class="promo-activity-entry" v-if="promotionMap['FULL_DISCOUNT']">
<span class="promo-badge">{{ getFullDiscountTag(promotionMap['FULL_DISCOUNT']) }}</span>
<span class="promo-desc">{{ formatFullDiscountDesc(promotionMap['FULL_DISCOUNT']) }}</span>
</div>
<!-- 拼团 -->
<div class="promo-activity-entry" v-if="promotionMap['PINTUAN']">
<span class="promo-badge">拼团</span>
<span class="promo-desc">
拼团价
<span class="promo-price-text">{{ $filters.unitPrice(promotionMap["PINTUAN"].price, "¥") }}</span>
,多人拼单更实惠
</span>
</div>
</div>
</div>
</div>
<!-- 选择颜色 --> <!-- 选择颜色 -->
<div <div
class="item-select item-spec" class="item-select item-spec"
@@ -364,6 +357,14 @@ const currentImg = computed(() => {
return item.type === "video" ? item.poster : item.url || item; return item.type === "video" ? item.poster : item.url || item;
}); });
/** 是否包含有效促销/优惠(优惠券、满减/满折/满赠、拼团) */
const hasPromotions = computed(() => {
const hasCoupon = Array.isArray(promotionMap.value?.COUPON) && promotionMap.value.COUPON.length > 0;
const hasFullDiscount = !!promotionMap.value?.FULL_DISCOUNT;
const hasPintuan = !!promotionMap.value?.PINTUAN;
return hasCoupon || hasFullDiscount || hasPintuan;
});
/** 卡密商品:禁加购,仅立即购买;库存读 quantity(M-02 起支持促销) */ /** 卡密商品:禁加购,仅立即购买;库存读 quantity(M-02 起支持促销) */
const isECouponGoods = computed(() => { const isECouponGoods = computed(() => {
@@ -533,6 +534,53 @@ function pointPay() {
startCheckout(E_COUPON_CART_POINTS, "loading"); startCheckout(E_COUPON_CART_POINTS, "loading");
} }
function getFullDiscountTag(fd) {
if (!fd) return "满优惠";
if (fd.activityType === "FULL_RATE") return "满折";
if (fd.activityType === "FULL_GIFT") return "满赠";
if (fd.activityType === "FULL_MINUS") return "满减";
if (fd.fullRateFlag) return "满折";
if (fd.giftFlag) return "满赠";
return "满减";
}
function formatFullDiscountDesc(fd) {
if (!fd) return "";
let rules = fd.ruleList;
if (!rules && fd.rulesJson) {
try {
rules = JSON.parse(fd.rulesJson);
} catch (e) {
rules = null;
}
}
if (Array.isArray(rules) && rules.length > 0) {
return rules
.map((rule) => {
if (fd.activityType === "FULL_RATE") {
return `满${rule.fullMoney}元打${rule.fullRate}折`;
}
if (fd.activityType === "FULL_GIFT") {
const gifts = Array.isArray(rule.giftItems)
? rule.giftItems
.map((g) => `${g.goodsName || "赠品"}x${g.num || 1}`)
.join("、")
: "赠品";
return `满${rule.fullMoney}元送${gifts}`;
}
return `满${rule.fullMoney}元减${rule.fullMinus}元`;
})
.join(";");
}
if (fd.fullMinusFlag && fd.fullMinus) {
return `满${fd.fullMoney}元,立减现金${fd.fullMinus}元`;
}
if (fd.fullRateFlag && fd.fullRate) {
return `满${fd.fullMoney}元,立享${fd.fullRate}折`;
}
return fd.title || "";
}
function startCheckout(action, loadingKey) { function startCheckout(action, loadingKey) {
const params = { const params = {
num: count.value, num: count.value,
@@ -906,6 +954,7 @@ onBeforeUnmount(() => {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center;
background: #fafafa; background: #fafafa;
border-radius: 4px; border-radius: 4px;
} }
@@ -948,15 +997,70 @@ onBeforeUnmount(() => {
margin-left: 5px; margin-left: 5px;
} }
.item-price-coupon-row { /* 促销与优惠独立展示模块 */
.goods-promotions-box {
margin-top: 12px;
padding: 12px 20px;
background: #fafafa;
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 12px;
}
.promo-item-row {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
margin: 8px 0px; line-height: 24px;
}
.item-price-title { .promo-title {
flex-shrink: 0; flex-shrink: 0;
line-height: 22px; width: 56px;
} margin-right: 12px;
color: #666;
font-size: 14px;
line-height: 24px;
text-align: left;
}
.promo-content-column {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.promo-activity-entry {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
line-height: 22px;
}
.promo-badge {
display: inline-block;
padding: 1px 6px;
font-size: 12px;
line-height: 18px;
color: $theme_color;
background: rgba(235, 69, 73, 0.08);
border: 1px solid $theme_color;
border-radius: 2px;
white-space: nowrap;
}
.promo-desc {
font-size: 13px;
color: #333;
line-height: 22px;
word-break: break-all;
}
.promo-price-text {
color: $theme_color;
font-weight: 600;
} }
.coupon-tags { .coupon-tags {

View File

@@ -67,7 +67,10 @@
/> />
</div> </div>
<div class="goods-info"> <div class="goods-info">
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div> <div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
{{ goods.name }}
</div>
<div class="goods-num">共{{ goods.num }}件</div> <div class="goods-num">共{{ goods.num }}件</div>
<div class="goods-extra"> <div class="goods-extra">
<el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag> <el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
@@ -151,7 +154,10 @@
/> />
</div> </div>
<div class="goods-info"> <div class="goods-info">
<div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">{{ goods.name }}</div> <div class="goods-name hover-color" @click="goodsDetail(goods.skuId, goods.goodsId)">
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
{{ goods.name }}
</div>
<div class="goods-num">共{{ goods.num }}件</div> <div class="goods-num">共{{ goods.num }}件</div>
<div class="goods-extra"> <div class="goods-extra">
<el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag> <el-tag v-if="refundPriceList(goods.isRefund) && !isUnpaid(order)" type="warning" size="small">{{ refundPriceList(goods.isRefund) }}</el-tag>
@@ -240,6 +246,10 @@ const cancelAvail = ref(false); // 取消订单modal控制
const cancelReason = ref([]); // 取消订单原因 const cancelReason = ref([]); // 取消订单原因
// 退款状态枚举 // 退款状态枚举
function isGiftItem(goods) {
if (!goods) return false;
return goods.promotionType === 'GIFT' || (Number(goods.flowPrice) === 0 && goods.promotionType === 'GIFT');
}
function refundPriceList(status) { function refundPriceList(status) {
switch (status) { switch (status) {
case 'ALL_REFUND': case 'ALL_REFUND':

View File

@@ -269,40 +269,54 @@
@click="goodsDetail(goods.skuId, goods.goodsId)" @click="goodsDetail(goods.skuId, goods.goodsId)"
class="hover-color goods-name" class="hover-color goods-name"
> >
<el-tag v-if="isGiftItem(goods)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
{{ goods.goodsName }} {{ goods.goodsName }}
</p> </p>
<p class="goods-sn">货号:{{ goods.id }}</p> <p class="goods-sn">货号:{{ goods.id }}</p>
</div> </div>
<div class="goods-price"> <div class="goods-price">
<span class="unit-price"> <template v-if="isGiftItem(goods)">
{{ $filters.unitPrice(goods.goodsPrice, "¥") }} × {{ goods.num }} <span class="unit-price gift-original-price">
</span> {{ $filters.unitPrice(goods.goodsPrice, "¥") }}
<span class="subtotal">{{ $filters.unitPrice((goods.goodsPrice * goods.num), "¥") }}</span> </span>
<span class="subtotal gift-subtotal">赠品 (¥0.00 × {{ goods.num }})</span>
</template>
<template v-else>
<span class="unit-price">
{{ $filters.unitPrice(goods.goodsPrice, "¥") }} × {{ goods.num }}
</span>
<span class="subtotal">{{ $filters.unitPrice((goods.goodsPrice * goods.num), "¥") }}</span>
</template>
</div> </div>
<div class="goods-actions"> <div class="goods-actions">
<button <template v-if="!isGiftItem(goods)">
v-if="goods.commentStatus == 'UNFINISHED'" <button
type="button" v-if="goods.commentStatus == 'UNFINISHED'"
class="comment-text" type="button"
@click="comment(order.order.sn, goodsIndex)" class="comment-text"
>评价</button> @click="comment(order.order.sn, goodsIndex)"
<el-button >评价</button>
v-if="goods.complainStatus == 'NO_APPLY'" <el-button
@click="complain(order.order.sn, goodsIndex)" v-if="goods.complainStatus == 'NO_APPLY'"
type="danger" @click="complain(order.order.sn, goodsIndex)"
size="small" type="danger"
>投诉</el-button size="small"
> >投诉</el-button
<el-button >
v-if=" <el-button
!isECouponOrder && v-if="
(goods.afterSaleStatus.includes('NOT_APPLIED') || !isECouponOrder &&
goods.afterSaleStatus.includes('PART_AFTER_SALE')) (goods.afterSaleStatus.includes('NOT_APPLIED') ||
" goods.afterSaleStatus.includes('PART_AFTER_SALE'))
@click="applyAfterSale(goods.sn)" "
size="small" @click="applyAfterSale(goods.sn)"
>申请售后</el-button size="small"
> >申请售后</el-button
>
</template>
<template v-else>
<span class="gift-action-tip">随单赠送,不支持售后</span>
</template>
</div> </div>
</div> </div>
</div> </div>
@@ -726,6 +740,11 @@ function goPay(sn) {
}); });
} }
function isGiftItem(goods) {
if (!goods) return false;
return goods.promotionType === 'GIFT' || (Number(goods.flowPrice) === 0 && goods.promotionType === 'GIFT');
}
function applyAfterSale(sn) { function applyAfterSale(sn) {
// 申请售后 // 申请售后
router.push({ name: "ApplyAfterSale", query: { sn: sn } }); router.push({ name: "ApplyAfterSale", query: { sn: sn } });
@@ -1178,4 +1197,16 @@ onMounted(() => {
} }
} }
} }
.gift-original-price {
text-decoration: line-through;
color: #999;
}
.gift-subtotal {
color: #e4393c;
font-weight: bold;
}
.gift-action-tip {
color: #999;
font-size: 12px;
}
</style> </style>

View File

@@ -147,6 +147,24 @@
<span>{{ goods.goodsSku?.quantity > 0 ? "有货" : "无货" }}</span> <span>{{ goods.goodsSku?.quantity > 0 ? "有货" : "无货" }}</span>
<span class="goods-price">{{ $filters.unitPrice(goods.subTotal, "¥") }}</span> <span class="goods-price">{{ $filters.unitPrice(goods.subTotal, "¥") }}</span>
</div> </div>
<!-- 随单实物赠品行 -->
<div
class="goods-item gift-item"
v-for="(gift, giftIndex) in getShopGifts(shop)"
:key="'gift-' + (gift.skuId || giftIndex)"
>
<span class="goods-main hover-color" @click="goGoodsDetail(gift.skuId, gift.goodsId)">
<img :src="gift.thumbnail || gift.image" alt="" />
<span class="goods-name">
<el-tag size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
{{ gift.goodsName || gift.name || '随单实物赠品' }}
</span>
</span>
<span class="goods-price gift-zero-price">¥0.00</span>
<span>{{ gift.num || 1 }}</span>
<span>有货</span>
<span class="goods-price gift-zero-price">¥0.00</span>
</div>
</div> </div>
<div class="order-mark"> <div class="order-mark">
<div v-if="isRemarkOpen(shop)" class="mark-field"> <div v-if="isRemarkOpen(shop)" class="mark-field">
@@ -876,6 +894,13 @@ function delAddress(item) {
onCancel: () => { }, onCancel: () => { },
}); });
} }
function getShopGifts(shop) {
if (!shop) return [];
if (Array.isArray(shop.giftItemList) && shop.giftItemList.length > 0) {
return shop.giftItemList;
}
return [];
}
function goGoodsDetail(skuId, goodsId) { function goGoodsDetail(skuId, goodsId) {
// 跳转商品详情 // 跳转商品详情
let routeUrl = router.resolve({ let routeUrl = router.resolve({
@@ -1800,4 +1825,8 @@ function useScope(type, storeName) {
.pay-gcc-notice-foot { .pay-gcc-notice-foot {
text-align: right; text-align: right;
} }
.gift-zero-price {
color: #e4393c !important;
font-weight: 500;
}
</style> </style>

View File

@@ -368,6 +368,7 @@
/> />
<div class="goods-info"> <div class="goods-info">
<div class="goods-name"> <div class="goods-name">
<el-tag v-if="isGiftItem(row)" size="small" type="danger" effect="plain" class="mr_5">赠品</el-tag>
<span>{{ row.goodsName }}</span> <span>{{ row.goodsName }}</span>
</div> </div>
<div v-if="row.specs" class="goods-specs"> <div v-if="row.specs" class="goods-specs">
@@ -391,7 +392,11 @@
<el-table-column label="单价" width="150" align="center"> <el-table-column label="单价" width="150" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span class="font-mono font-bold">¥ {{ Number(row.unitPrice || 0).toFixed(2) }}</span> <template v-if="isGiftItem(row)">
<div class="font-mono text-gray" style="text-decoration: line-through;">¥ {{ Number(row.unitPrice || 0).toFixed(2) }}</div>
<div class="text-danger font-bold">赠品 ¥ 0.00</div>
</template>
<span v-else class="font-mono font-bold">¥ {{ Number(row.unitPrice || 0).toFixed(2) }}</span>
</template> </template>
</el-table-column> </el-table-column>
@@ -1159,10 +1164,16 @@ function formatOperatorType(type) {
return map[type] || type || "系统"; return map[type] || type || "系统";
} }
function isGiftItem(row) {
if (!row) return false;
return row.promotionType === 'GIFT' || (Number(row.flowPrice) === 0 && row.promotionType === 'GIFT');
}
function getPromotionText(row) { function getPromotionText(row) {
let resultText = ""; let resultText = "";
if (row && row.promotionType) { if (row && row.promotionType) {
const type = row.promotionType.split(","); const type = row.promotionType.split(",");
if (type.indexOf("GIFT") !== -1) resultText += "满赠赠品 ";
if (type.indexOf("PINTUAN") !== -1) resultText += "拼团 "; if (type.indexOf("PINTUAN") !== -1) resultText += "拼团 ";
if (type.indexOf("SECKILL") !== -1) resultText += "秒杀 "; if (type.indexOf("SECKILL") !== -1) resultText += "秒杀 ";
if (type.indexOf("COUPON") !== -1) resultText += "优惠券 "; if (type.indexOf("COUPON") !== -1) resultText += "优惠券 ";

View File

@@ -191,32 +191,152 @@
</div> </div>
</div> </div>
</el-card> </el-card>
<el-card v-if="showDelivery && orderPackages.length" class="mt_10 order-goods-card"> <el-card v-if="showDelivery && orderPackages.length" class="mt_10 delivery-record-card">
<el-divider content-position="left">发货记录</el-divider> <div class="delivery-section-header">
<el-table :data="orderPackages" border style="width: 100%"> <div class="delivery-section-title-wrap">
<el-table-column label="发货方式" width="120"> <span class="delivery-title-bar"></span>
<template #default="{ row }">{{ getFulfillmentModeLabel(row.fulfillmentMode) }}</template> <span class="delivery-section-title">发货记录</span>
</el-table-column> <el-tag size="small" type="primary" effect="plain" round class="delivery-count-badge">
<el-table-column label="物流信息" min-width="220"> 共 {{ orderPackages.length }} 个包裹
<template #default="{ row }"> </el-tag>
<template v-if="isNoDeliveryPackage(row)"> </div>
<span>无需配送</span> </div>
<span v-if="row.deliveryRemark" class="delivery-remark-text">({{ row.deliveryRemark }})</span>
</template> <div class="delivery-package-list">
<template v-else> <div
{{ row.logisticsName || "-" }} / {{ row.logisticsNo || "-" }} v-for="(pkg, pkgIdx) in orderPackages"
</template> :key="pkg.packageNo || pkg.id || pkgIdx"
</template> class="delivery-package-item"
</el-table-column> >
<el-table-column prop="logisticsTime" label="发货时间" width="170" /> <!-- 包裹顶部信息栏 -->
<el-table-column label="商品明细" min-width="260"> <div class="pkg-bar">
<template #default="{ row }"> <div class="pkg-bar-left">
<div v-for="(item, idx) in row.orderPackageItemList || []" :key="idx" class="package-item-line"> <div class="pkg-index-tag">
{{ item.goodsName }} × {{ item.deliverNumber }} <el-icon class="pkg-icon"><Box /></el-icon>
<span>包裹 {{ orderPackages.length > 1 ? `#${pkgIdx + 1}` : '' }}</span>
</div>
<el-tag
size="small"
:type="getFulfillmentTagType(pkg.fulfillmentMode)"
effect="light"
class="pkg-mode-tag"
>
{{ getFulfillmentModeLabel(pkg.fulfillmentMode) }}
</el-tag>
<div v-if="!isNoDeliveryPackage(pkg)" class="pkg-express-group">
<span class="express-name">{{ pkg.logisticsName || "快递物流" }}</span>
<span class="express-divider">/</span>
<span class="express-no-label">运单号:</span>
<span class="express-no">{{ pkg.logisticsNo || "-" }}</span>
<el-tooltip content="复制运单号" placement="top" v-if="pkg.logisticsNo">
<span
class="copy-icon-btn"
@click="copyText(pkg.logisticsNo, '运单号')"
>
<el-icon><DocumentCopy /></el-icon>
</span>
</el-tooltip>
</div>
<div v-else class="pkg-no-express-group">
<span class="no-express-text">无需物流配送</span>
<span v-if="pkg.deliveryRemark" class="no-express-remark">(备注:{{ pkg.deliveryRemark }})</span>
</div>
</div> </div>
</template>
</el-table-column> <div class="pkg-bar-right">
</el-table> <div v-if="pkg.logisticsTime" class="pkg-time">
<el-icon class="clock-icon" style="margin-right: 4px;"><Clock /></el-icon>
<span>发货时间:{{ pkg.logisticsTime }}</span>
</div>
<el-button
v-if="!isNoDeliveryPackage(pkg) && pkg.logisticsNo"
type="primary"
link
size="small"
class="pkg-track-btn"
@click="checkLogistics"
>
<el-icon style="margin-right: 3px;"><Van /></el-icon>查看物流
</el-button>
</div>
</div>
<!-- 包裹内商品明细 -->
<div class="pkg-goods-container">
<div
v-for="(item, itemIdx) in pkg.orderPackageItemList || []"
:key="item.orderItemSn || item.id || itemIdx"
class="pkg-goods-row"
>
<!-- 商品缩略图 -->
<div class="pkg-goods-img-box">
<img
:src="getPackageItemThumb(item)"
class="pkg-goods-img"
alt="商品图片"
/>
</div>
<!-- 商品详情 -->
<div class="pkg-goods-detail">
<div class="pkg-goods-title" :title="item.goodsName">
{{ item.goodsName }}
</div>
<div v-if="getPackageItemSpecsText(item)" class="pkg-goods-specs">
{{ getPackageItemSpecsText(item) }}
</div>
</div>
<!-- 发货数量 -->
<div class="pkg-goods-num-col">
<span class="pkg-num-label">发货数量</span>
<span class="pkg-num-val">× {{ item.deliverNumber || 1 }}</span>
</div>
</div>
</div>
<!-- 物流信息与动态跟踪栏 -->
<div class="pkg-logistics-footer">
<template v-if="!isNoDeliveryPackage(pkg)">
<!-- 若已有物流轨迹节点 -->
<div v-if="hasPackageTraces(pkg)" class="pkg-trace-content">
<el-tag size="small" type="success" effect="light" class="trace-status-tag">最新轨迹</el-tag>
<span class="trace-time">{{ getLatestTrace(pkg).AcceptTime || getLatestTrace(pkg).acceptTime }}</span>
<span class="trace-desc" :title="getLatestTrace(pkg).AcceptStation || getLatestTrace(pkg).remark">
{{ getLatestTrace(pkg).AcceptStation || getLatestTrace(pkg).remark }}
</span>
<el-button type="primary" link size="small" class="trace-more-btn" @click="checkLogistics">
查看完整轨迹
</el-button>
</div>
<!-- 暂无第三方轨迹更新时 -->
<div v-else class="pkg-trace-content">
<el-tag size="small" type="info" effect="light" class="trace-status-tag">物流动态</el-tag>
<span class="trace-desc">
已生成 {{ pkg.logisticsName || "快递" }} 运单({{ pkg.logisticsNo }}),等待快递网点揽收更新
</span>
<el-button type="primary" link size="small" class="trace-more-btn" @click="checkLogistics">
查看物流
</el-button>
</div>
</template>
<!-- 无需配送 -->
<template v-else>
<div class="pkg-trace-content">
<el-tag size="small" type="warning" effect="light" class="trace-status-tag">配送说明</el-tag>
<span class="trace-desc">
{{ pkg.deliveryRemark ? `配送说明:${pkg.deliveryRemark}` : "无需物流配送,商家已完成线上/线下交付" }}
</span>
</div>
</template>
</div>
</div>
</div>
</el-card> </el-card>
<el-card class="mt_10 order-goods-card"> <el-card class="mt_10 order-goods-card">
<div v-loading="loading" class="order-fit-wrap"> <div v-loading="loading" class="order-fit-wrap">
@@ -246,6 +366,7 @@
<img alt="商品图片" :src="row.image" class="order-goods-img" /> <img alt="商品图片" :src="row.image" class="order-goods-img" />
<div class="order-goods-meta"> <div class="order-goods-meta">
<div class="div-zoom"> <div class="div-zoom">
<el-tag v-if="isGiftItem(row)" size="small" type="danger" effect="plain" style="margin-right: 5px;">赠品</el-tag>
<a class="link-text" @click="linkTo(row.goodsId, row.skuId)">{{ row.goodsName }}</a> <a class="link-text" @click="linkTo(row.goodsId, row.skuId)">{{ row.goodsName }}</a>
</div> </div>
<span v-for="(item, key) in parseOrderItemSpecs(row.specs)" :key="key"> <span v-for="(item, key) in parseOrderItemSpecs(row.specs)" :key="key">
@@ -497,7 +618,10 @@
<div class="part-delivery-goods"> <div class="part-delivery-goods">
<img :src="row.image" class="part-delivery-goods-img" alt="" /> <img :src="row.image" class="part-delivery-goods-img" alt="" />
<div class="part-delivery-goods-info"> <div class="part-delivery-goods-info">
<div class="part-delivery-goods-name">{{ row.goodsName }}</div> <div class="part-delivery-goods-name">
<el-tag v-if="isGiftItem(row)" size="small" type="danger" effect="plain" style="margin-right: 5px;">赠品</el-tag>
{{ row.goodsName }}
</div>
<div <div
v-for="(specValue, specKey) in parseOrderItemSpecs(row.specs)" v-for="(specValue, specKey) in parseOrderItemSpecs(row.specs)"
:key="specKey" :key="specKey"
@@ -546,7 +670,10 @@
<div class="part-delivery-goods"> <div class="part-delivery-goods">
<img :src="row.image" class="part-delivery-goods-img" alt="" /> <img :src="row.image" class="part-delivery-goods-img" alt="" />
<div class="part-delivery-goods-info"> <div class="part-delivery-goods-info">
<div class="part-delivery-goods-name">{{ row.goodsName }}</div> <div class="part-delivery-goods-name">
<el-tag v-if="isGiftItem(row)" size="small" type="danger" effect="plain" style="margin-right: 5px;">赠品</el-tag>
{{ row.goodsName }}
</div>
<div <div
v-for="(specValue, specKey) in parseOrderItemSpecs(row.specs)" v-for="(specValue, specKey) in parseOrderItemSpecs(row.specs)"
:key="specKey" :key="specKey"
@@ -849,6 +976,7 @@
<script setup> <script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue"; import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router"; import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
import { Box, DocumentCopy, Clock, Van } from "@element-plus/icons-vue";
import * as API_Order from "@/api/order"; import * as API_Order from "@/api/order";
import * as API_Logistics from "@/api/logistics"; import * as API_Logistics from "@/api/logistics";
import * as RegExp from "@/libs/RegExp.js"; import * as RegExp from "@/libs/RegExp.js";
@@ -1120,23 +1248,34 @@ onBeforeRouteLeave((to, from, next) => {
function lineCardKeyRows(line) { function lineCardKeyRows(line) {
return line.cardKeys || []; return line.cardKeys || [];
} }
function copySn() { function copyText(text, label = "内容") {
const text = orderInfo.value?.order?.sn || "";
if (!text) return; if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(String(text)).then(() => {
Message.success("订单号已复制"); Message.success(`${label}已复制`);
}).catch(() => {
fallbackCopyText(String(text), label);
}); });
} else { } else {
const ta = document.createElement("textarea"); fallbackCopyText(String(text), label);
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
Message.success("订单号已复制");
} }
} }
function fallbackCopyText(text, label = "内容") {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
Message.success(`${label}已复制`);
}
function copySn() {
copyText(orderInfo.value?.order?.sn, "订单号");
}
function copyCardKey(row) { function copyCardKey(row) {
const text = `卡号:${row.cardNo || ""}\n卡密:${row.cardSecret || ""}`; const text = `卡号:${row.cardNo || ""}\n卡密:${row.cardSecret || ""}`;
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -1153,10 +1292,15 @@ function copyCardKey(row) {
Message.success("已复制到剪贴板"); Message.success("已复制到剪贴板");
} }
} }
function isGiftItem(row) {
if (!row) return false;
return row.promotionType === 'GIFT' || (Number(row.flowPrice) === 0 && row.promotionType === 'GIFT');
}
function getPromotionText(row) { function getPromotionText(row) {
let resultText = ""; let resultText = "";
if (row && row.promotionType) { if (row && row.promotionType) {
const type = row.promotionType.split(","); const type = row.promotionType.split(",");
if (type.indexOf("GIFT") != -1) resultText += "满赠赠品 ";
if (type.indexOf("PINTUAN") != -1) resultText += "拼团 "; if (type.indexOf("PINTUAN") != -1) resultText += "拼团 ";
if (type.indexOf("SECKILL") != -1) resultText += "秒杀 "; if (type.indexOf("SECKILL") != -1) resultText += "秒杀 ";
if (type.indexOf("COUPON") != -1) resultText += "优惠券 "; if (type.indexOf("COUPON") != -1) resultText += "优惠券 ";
@@ -1219,6 +1363,40 @@ function isPackageItemMatch(orderItem, pkgItem) {
.map(String); .map(String);
return pkgKeys.some((key) => orderKeys.includes(key)); return pkgKeys.some((key) => orderKeys.includes(key));
} }
function getPackageItemThumb(item) {
if (item && item.thumbnail) {
return item.thumbnail;
}
const match = (data.value || []).find((row) => isPackageItemMatch(row, item));
return match?.image || match?.goodsImage || "";
}
function getPackageItemSpecsText(item) {
const match = (data.value || []).find((row) => isPackageItemMatch(row, item));
if (!match || !match.specs) return "";
try {
const specsObj = parseOrderItemSpecs(match.specs);
if (!specsObj || typeof specsObj !== "object") return "";
return Object.entries(specsObj)
.filter(([k]) => k !== "images")
.map(([k, v]) => `${k}: ${v}`)
.join(" | ");
} catch (e) {
return "";
}
}
function getFulfillmentTagType(mode) {
if (mode === "NO_DELIVERY") return "info";
if (mode === "SPLIT") return "warning";
return "primary";
}
function hasPackageTraces(pkg) {
return Boolean(pkg && pkg.traces && Array.isArray(pkg.traces.traces) && pkg.traces.traces.length > 0);
}
function getLatestTrace(pkg) {
if (!hasPackageTraces(pkg)) return {};
const list = pkg.traces.traces;
return list[0] || {};
}
function getRemainingDeliveryNum(item) { function getRemainingDeliveryNum(item) {
if (item != null && item.remainDeliverNumber != null && item.remainDeliverNumber !== "") { if (item != null && item.remainDeliverNumber != null && item.remainDeliverNumber !== "") {
const remain = Number(item.remainDeliverNumber); const remain = Number(item.remainDeliverNumber);
@@ -1968,6 +2146,324 @@ function editAddressSubmit () {
color: #606266; color: #606266;
} }
.delivery-record-card {
border-radius: 8px;
border: 1px solid #ebeef5;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.03);
:deep(.el-card__body) {
padding: 16px 20px 20px;
}
}
.delivery-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f2f5;
.delivery-section-title-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.delivery-title-bar {
width: 3px;
height: 15px;
background: #409eff;
border-radius: 2px;
}
.delivery-section-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
line-height: 1;
}
.delivery-count-badge {
margin-left: 4px;
font-size: 12px;
height: 22px;
line-height: 20px;
}
}
.delivery-package-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.delivery-package-item {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #ffffff;
overflow: hidden;
transition: all 0.2s ease;
&:hover {
border-color: #cbd5e1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.pkg-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
padding: 10px 16px;
background: #f8fafc;
border-bottom: 1px solid #e5e7eb;
.pkg-bar-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.pkg-index-tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 13px;
font-weight: 600;
color: #1e293b;
.pkg-icon {
font-size: 15px;
color: #3b82f6;
}
}
.pkg-mode-tag {
font-size: 12px;
height: 22px;
line-height: 20px;
padding: 0 8px;
}
.pkg-express-group {
display: inline-flex;
align-items: center;
font-size: 13px;
background: #f1f5f9;
padding: 3px 10px;
border-radius: 4px;
border: 1px solid #e2e8f0;
.express-name {
font-weight: 500;
color: #334155;
}
.express-divider {
margin: 0 6px;
color: #94a3b8;
}
.express-no-label {
color: #64748b;
}
.express-no {
font-family: Consolas, Monaco, "Courier New", monospace;
font-weight: 600;
color: #0284c7;
letter-spacing: 0.5px;
}
.copy-icon-btn {
margin-left: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
color: #3b82f6;
transition: transform 0.15s ease, color 0.15s ease;
&:hover {
color: #2563eb;
transform: scale(1.15);
}
}
}
.pkg-no-express-group {
display: inline-flex;
align-items: center;
font-size: 13px;
color: #64748b;
.no-express-text {
font-weight: 500;
color: #475569;
}
.no-express-remark {
color: #94a3b8;
margin-left: 4px;
}
}
.pkg-bar-right {
display: flex;
align-items: center;
gap: 16px;
font-size: 12px;
.pkg-time {
display: inline-flex;
align-items: center;
color: #64748b;
.clock-icon {
font-size: 13px;
}
}
.pkg-track-btn {
font-size: 12px;
display: inline-flex;
align-items: center;
padding: 0;
}
}
}
.pkg-goods-container {
padding: 6px 16px;
background: #ffffff;
.pkg-goods-row {
display: flex;
align-items: center;
padding: 10px 0;
border-bottom: 1px dashed #f1f5f9;
&:last-child {
border-bottom: none;
}
.pkg-goods-img-box {
width: 46px;
height: 46px;
flex-shrink: 0;
border-radius: 6px;
border: 1px solid #e2e8f0;
overflow: hidden;
background: #f8fafc;
display: flex;
align-items: center;
justify-content: center;
.pkg-goods-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.pkg-goods-detail {
flex: 1;
min-width: 0;
margin-left: 12px;
margin-right: 16px;
.pkg-goods-title {
font-size: 13px;
font-weight: 500;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
.pkg-goods-specs {
margin-top: 3px;
font-size: 12px;
color: #64748b;
background: #f8fafc;
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
border: 1px solid #edf2f7;
line-height: 1.4;
}
}
.pkg-goods-num-col {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
.pkg-num-label {
font-size: 11px;
color: #94a3b8;
}
.pkg-num-val {
font-size: 14px;
font-weight: 600;
color: #0f172a;
letter-spacing: 0.5px;
}
}
}
}
.pkg-logistics-footer {
padding: 9px 16px;
background: #f8fafc;
border-top: 1px solid #f1f5f9;
.pkg-trace-content {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
font-size: 12px;
line-height: 1.5;
.trace-status-tag {
font-size: 11px;
height: 20px;
line-height: 18px;
padding: 0 6px;
}
.trace-time {
color: #64748b;
font-family: Consolas, Monaco, monospace;
}
.trace-desc {
color: #334155;
font-weight: 500;
flex: 1;
min-width: 160px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.trace-more-btn {
font-size: 12px;
padding: 0;
margin-left: auto;
}
}
}
}
.package-item-line { .package-item-line {
line-height: 1.8; line-height: 1.8;
} }