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

@@ -191,32 +191,152 @@
</div>
</div>
</el-card>
<el-card v-if="showDelivery && orderPackages.length" class="mt_10 order-goods-card">
<el-divider content-position="left">发货记录</el-divider>
<el-table :data="orderPackages" border style="width: 100%">
<el-table-column label="发货方式" width="120">
<template #default="{ row }">{{ getFulfillmentModeLabel(row.fulfillmentMode) }}</template>
</el-table-column>
<el-table-column label="物流信息" min-width="220">
<template #default="{ row }">
<template v-if="isNoDeliveryPackage(row)">
<span>无需配送</span>
<span v-if="row.deliveryRemark" class="delivery-remark-text">({{ row.deliveryRemark }})</span>
</template>
<template v-else>
{{ row.logisticsName || "-" }} / {{ row.logisticsNo || "-" }}
</template>
</template>
</el-table-column>
<el-table-column prop="logisticsTime" label="发货时间" width="170" />
<el-table-column label="商品明细" min-width="260">
<template #default="{ row }">
<div v-for="(item, idx) in row.orderPackageItemList || []" :key="idx" class="package-item-line">
{{ item.goodsName }} × {{ item.deliverNumber }}
<el-card v-if="showDelivery && orderPackages.length" class="mt_10 delivery-record-card">
<div class="delivery-section-header">
<div class="delivery-section-title-wrap">
<span class="delivery-title-bar"></span>
<span class="delivery-section-title">发货记录</span>
<el-tag size="small" type="primary" effect="plain" round class="delivery-count-badge">
共 {{ orderPackages.length }} 个包裹
</el-tag>
</div>
</div>
<div class="delivery-package-list">
<div
v-for="(pkg, pkgIdx) in orderPackages"
:key="pkg.packageNo || pkg.id || pkgIdx"
class="delivery-package-item"
>
<!-- 包裹顶部信息栏 -->
<div class="pkg-bar">
<div class="pkg-bar-left">
<div class="pkg-index-tag">
<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>
</template>
</el-table-column>
</el-table>
<div class="pkg-bar-right">
<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 class="mt_10 order-goods-card">
<div v-loading="loading" class="order-fit-wrap">
@@ -246,6 +366,7 @@
<img alt="商品图片" :src="row.image" class="order-goods-img" />
<div class="order-goods-meta">
<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>
</div>
<span v-for="(item, key) in parseOrderItemSpecs(row.specs)" :key="key">
@@ -497,7 +618,10 @@
<div class="part-delivery-goods">
<img :src="row.image" class="part-delivery-goods-img" alt="" />
<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
v-for="(specValue, specKey) in parseOrderItemSpecs(row.specs)"
:key="specKey"
@@ -546,7 +670,10 @@
<div class="part-delivery-goods">
<img :src="row.image" class="part-delivery-goods-img" alt="" />
<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
v-for="(specValue, specKey) in parseOrderItemSpecs(row.specs)"
:key="specKey"
@@ -849,6 +976,7 @@
<script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
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_Logistics from "@/api/logistics";
import * as RegExp from "@/libs/RegExp.js";
@@ -1120,23 +1248,34 @@ onBeforeRouteLeave((to, from, next) => {
function lineCardKeyRows(line) {
return line.cardKeys || [];
}
function copySn() {
const text = orderInfo.value?.order?.sn || "";
function copyText(text, label = "内容") {
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
Message.success("订单号已复制");
navigator.clipboard.writeText(String(text)).then(() => {
Message.success(`${label}已复制`);
}).catch(() => {
fallbackCopyText(String(text), label);
});
} else {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
Message.success("订单号已复制");
fallbackCopyText(String(text), label);
}
}
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) {
const text = `卡号:${row.cardNo || ""}\n卡密:${row.cardSecret || ""}`;
if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -1153,10 +1292,15 @@ function copyCardKey(row) {
Message.success("已复制到剪贴板");
}
}
function isGiftItem(row) {
if (!row) return false;
return row.promotionType === 'GIFT' || (Number(row.flowPrice) === 0 && row.promotionType === 'GIFT');
}
function getPromotionText(row) {
let resultText = "";
if (row && row.promotionType) {
const type = row.promotionType.split(",");
if (type.indexOf("GIFT") != -1) resultText += "满赠赠品 ";
if (type.indexOf("PINTUAN") != -1) resultText += "拼团 ";
if (type.indexOf("SECKILL") != -1) resultText += "秒杀 ";
if (type.indexOf("COUPON") != -1) resultText += "优惠券 ";
@@ -1219,6 +1363,40 @@ function isPackageItemMatch(orderItem, pkgItem) {
.map(String);
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) {
if (item != null && item.remainDeliverNumber != null && item.remainDeliverNumber !== "") {
const remain = Number(item.remainDeliverNumber);
@@ -1968,6 +2146,324 @@ function editAddressSubmit () {
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 {
line-height: 1.8;
}