feat(buyer): 添加电子卡券(E_COUPON)支持

- 新增 E_COUPON 商品类型,支持电子卡券的展示与购买逻辑
- 优化商品详情页,调整促销、库存和购买按钮逻辑以适应电子卡券
- 增加卡密信息展示与管理功能,支持商家端卡池管理
- 更新相关页面以处理电子卡券的特殊逻辑,如免地址、隐藏优惠券等
This commit is contained in:
田香琪
2026-07-31 13:44:56 +08:00
parent 36878e6f7f
commit 1ae7ab3036
19 changed files with 1269 additions and 85 deletions

View File

@@ -44,7 +44,14 @@
<div class="item-detail-title-row">
<span class="item-detail-name">{{ skuDetail.goodsName }}</span>
<el-tag
v-if="skuDetail.goodsType !== 'VIRTUAL_GOODS'"
v-if="isECouponGoods"
class="goods-type-tag"
size="small"
>
电子卡券
</el-tag>
<el-tag
v-else-if="skuDetail.goodsType !== 'VIRTUAL_GOODS'"
class="goods-type-tag"
size="small"
>
@@ -64,7 +71,7 @@
</div>
<!-- 限时秒杀 -->
<Promotion
v-if="promotionMap['SECKILL']"
v-if="!isECouponGoods && promotionMap['SECKILL']"
:time="promotionMap['SECKILL'].endTime"
></Promotion>
<!-- 商品详细 价格优惠券促销 -->
@@ -73,7 +80,7 @@
<!-- 秒杀价格 -->
<div
class="item-price-row"
v-if="skuDetail.promotionPrice && promotionMap['SECKILL']"
v-if="skuDetail.promotionPrice && !isECouponGoods && promotionMap['SECKILL']"
>
<p>
<span class="item-price-title" v-if="promotionMap['SECKILL']"
@@ -85,7 +92,7 @@
<!-- 商品原价 -->
<div class="item-price-row" v-else>
<!-- 批发价格 -->
<div v-if="wholesaleNum && wholesaleNum.length">
<div v-if="!isECouponGoods && wholesaleNum && wholesaleNum.length">
<div class="flex">
<div class="item-price-title">
&nbsp;&nbsp;&nbsp;&nbsp;
@@ -119,7 +126,7 @@
</div>
</div>
<!-- 优惠券展示 -->
<div class="item-price-coupon-row" v-if="promotionMap['COUPON'].length">
<div class="item-price-coupon-row" v-if="!isECouponGoods && promotionMap['COUPON'].length">
<div class="Ellipsis">
<span class="item-price-title"> </span>
<span>
@@ -159,7 +166,7 @@
</div>
</div>
<!-- 满减展示 -->
<div class="item-price-row" v-if="promotionMap['FULL_DISCOUNT']">
<div class="item-price-row" v-if="!isECouponGoods && promotionMap['FULL_DISCOUNT']">
<p>
<span class="item-price-title"
>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
@@ -238,14 +245,16 @@
:precision="0"
@blur="changeCount"
></el-input-number>
<span class="inventory">&nbsp;&nbsp;库存{{ skuDetail.quantity }}</span>
<span class="inventory">&nbsp;&nbsp;库存{{ displayStock }}</span>
</div>
</div>
</div>
<div
class="item-select"
v-if="
skuDetail.goodsType !== 'VIRTUAL_GOODS' && skuDetail.weight !== 0
!isECouponGoods &&
skuDetail.goodsType !== 'VIRTUAL_GOODS' &&
skuDetail.weight !== 0
"
>
<div class="item-select-title">
@@ -279,16 +288,16 @@
<div class="add-buy-car">
<el-button
class="goods-action-btn"
v-if="skuDetail.goodsType !== 'VIRTUAL_GOODS'"
v-if="!isECouponGoods && skuDetail.goodsType !== 'VIRTUAL_GOODS'"
:loading="loading"
:disabled="skuDetail.quantity === 0"
:disabled="isOutOfStock"
@click="addShoppingCartBtn"
>加入购物车</el-button
>
<el-button
class="goods-action-btn"
:loading="loading1"
:disabled="skuDetail.quantity === 0"
:disabled="isOutOfStock"
@click="buyNow"
>立即购买</el-button
>
@@ -320,6 +329,12 @@ import {
} from "@/api/member.js";
import { addCartGoods } from "@/api/cart.js";
import playIcon from "@/assets/iconfont/play.svg";
/** 卡密商品E_COUPON判定与库存工具见 @/constants/goodsType */
import {
isECoupon,
getECouponStock,
getECouponMaxBuyNum,
} from "@/constants/goodsType";
export default {
name: "ShowGoods",
@@ -397,11 +412,27 @@ export default {
})
: [];
},
/** 卡密商品:禁加购/禁促销,仅立即购买,库存用 poolStockFR-B-01 / P-06 */
isECouponGoods() {
return isECoupon(this.skuDetail.goodsType);
},
displayStock() {
if (this.isECouponGoods) {
return getECouponStock(this.skuDetail);
}
return Number(this.skuDetail.quantity) || 0;
},
quantityMax() {
if (this.isECouponGoods) {
return getECouponMaxBuyNum(this.skuDetail);
}
const qty = Number(this.skuDetail.quantity) || 0;
return qty > 0 ? qty : 1;
},
isOutOfStock() {
if (this.isECouponGoods) {
return getECouponStock(this.skuDetail) <= 0;
}
return (Number(this.skuDetail.quantity) || 0) <= 0;
},
},
@@ -425,17 +456,20 @@ export default {
syncCountWithQuantity() {
const qty = Number(this.skuDetail.quantity) || 0;
const qty = this.isECouponGoods
? getECouponStock(this.skuDetail)
: Number(this.skuDetail.quantity) || 0;
if (qty <= 0) {
this.count = 1;
return;
}
if (this.wholesaleList && this.wholesaleList.length > 0) {
if (!this.isECouponGoods && this.wholesaleList && this.wholesaleList.length > 0) {
this.count = Math.min(Math.max(this.wholesaleList[0].num, 1), qty);
return;
}
if (this.count > qty) {
this.count = qty;
const max = this.isECouponGoods ? getECouponMaxBuyNum(this.skuDetail) : qty;
if (this.count > max) {
this.count = max;
} else if (this.count < 1) {
this.count = 1;
}
@@ -471,6 +505,11 @@ export default {
},
addShoppingCartBtn() {
// FR-B-01E_COUPON 禁止加购物车(后端亦返回 CARD_KEY_E_COUPON_CART_FORBIDDEN
if (this.isECouponGoods) {
Message.warning("电子卡券请使用立即购买");
return;
}
// 添加购物车
const params = {
num: this.count,
@@ -506,10 +545,11 @@ export default {
skuId: this.skuDetail.id,
cartType: "BUY_NOW",
};
// 虚拟商品购买
// 虚拟商品cartType=VIRTUAL核销码流程
if (this.skuDetail.goodsType === "VIRTUAL_GOODS") {
params.cartType = "VIRTUAL";
}
// E_COUPON 卡密商品cartType 须保持 BUY_NOW勿用 VIRTUALFR-B-01 / EC-24
this.loading1 = true;
addCartGoods(params)
.then((res) => {

View File

@@ -0,0 +1,39 @@
/**
* 卡密商品E_COUPON / 电子卡券)— 买家端公共常量与工具
*
* 与 VIRTUAL_GOODS核销型虚拟区分E_COUPON 仅立即购买BUY_NOW
* 支付后自动发卡,订单终态 COMPLETED可售库存优先读 poolStock。
* 需求lilishop/docs/requirements/card-key-goods-v4.md
*
* @author Mike
* @date 2026-07-31
*/
export const E_COUPON_GOODS_TYPE = "E_COUPON";
export const E_COUPON_ORDER_TYPE = "E_COUPON";
/** 单次购买数量上限O-09 待拍板默认值min(poolStock, 999) */
export const E_COUPON_MAX_BUY_NUM = 999;
export function isECoupon(goodsType) {
return goodsType === E_COUPON_GOODS_TYPE;
}
export function isECouponOrder(orderType) {
return orderType === E_COUPON_ORDER_TYPE;
}
/** 可售库存:优先 poolStock */
export function getECouponStock(sku) {
if (!sku) return 0;
const pool = sku.poolStock;
if (pool !== undefined && pool !== null) {
return Number(pool) || 0;
}
return Number(sku.quantity) || 0;
}
export function getECouponMaxBuyNum(sku) {
const stock = getECouponStock(sku);
if (stock <= 0) return 1;
return Math.min(stock, E_COUPON_MAX_BUY_NUM);
}

View File

@@ -80,9 +80,16 @@
>
自营
</el-tag>
<!-- E_COUPON 卡密商品类型标识 -->
<el-tag
class="goods-show-tag goods-show-tag-physical goods-show-tag-ecoupon"
v-if="item.goodsType === 'E_COUPON'"
>
电子卡券
</el-tag>
<el-tag
class="goods-show-tag goods-show-tag-physical"
v-if="item.goodsType === 'VIRTUAL_GOODS'"
v-else-if="item.goodsType === 'VIRTUAL_GOODS'"
>
虚拟
</el-tag>
@@ -281,13 +288,24 @@ export default {
}
.goods-show-tag {
height: 18px;
width: 32px;
line-height: 14px;
white-space: nowrap;
text-align: center;
display: inline-flex;
align-items: center;
padding: 0 3px;
justify-content: center;
height: 18px;
min-width: 32px;
width: auto;
line-height: 1;
white-space: nowrap;
padding: 0 4px;
box-sizing: border-box;
:deep(.el-tag__content) {
line-height: 1;
}
}
.goods-show-tag-ecoupon {
padding: 0 5px;
}
.goods-show-tag-self {

View File

@@ -72,8 +72,8 @@
<el-button @click="handleCancelOrder(order.sn)" type="danger" v-if="order.allowOperationVO.cancel" size="small">取消订单</el-button>
<el-button @click="goPay(order.sn)" size="small" type="success" v-if="order.allowOperationVO.pay">去支付</el-button>
<el-button @click="received(order.sn)" size="small" type="primary" v-if="order.allowOperationVO.rog">确认收货</el-button>
<!-- 售后 -->
<el-button v-if="order.groupAfterSaleStatus && (order.groupAfterSaleStatus.includes('NOT_APPLIED')|| order.groupAfterSaleStatus.includes('PART_AFTER_SALE'))"
<!-- 售后E_COUPON 卡密已交付不支持售后 FR-B-04 -->
<el-button v-if="order.orderType !== 'E_COUPON' && order.groupAfterSaleStatus && (order.groupAfterSaleStatus.includes('NOT_APPLIED')|| order.groupAfterSaleStatus.includes('PART_AFTER_SALE'))"
@click="applyAfterSale(order.orderItems)" size="small">申请售后</el-button>
</div>
</div>
@@ -141,8 +141,8 @@
<el-button @click="handleCancelOrder(order.sn)" type="danger" v-if="order.allowOperationVO.cancel" size="small">取消订单</el-button>
<el-button @click="goPay(order.sn)" size="small" type="success" v-if="order.allowOperationVO.pay">去支付</el-button>
<el-button @click="received(order.sn)" size="small" type="primary" v-if="order.allowOperationVO.rog">确认收货</el-button>
<!-- 售后 -->
<el-button v-if="order.groupAfterSaleStatus && (order.groupAfterSaleStatus.includes('NOT_APPLIED')|| order.groupAfterSaleStatus.includes('PART_AFTER_SALE'))"
<!-- 售后E_COUPON 不支持买家售后 FR-B-04 -->
<el-button v-if="order.orderType !== 'E_COUPON' && order.groupAfterSaleStatus && (order.groupAfterSaleStatus.includes('NOT_APPLIED')|| order.groupAfterSaleStatus.includes('PART_AFTER_SALE'))"
@click="applyAfterSale(order.orderItems)" size="small">申请售后</el-button>
</div>
</div>

View File

@@ -30,7 +30,7 @@
>取消订单</el-button>
<el-button v-if="order.allowOperationVO.showLogistics || orderPackage.length > 0 || logistics" type="info" @click="logisticsList()" size="small">查看物流</el-button>
</el-card>
<p class="verificationCode" v-if="order.order.verificationCode">
<p class="verificationCode" v-if="order.order.verificationCode && !isECouponOrder">
核验码:<span>{{ order.order.verificationCode }}</span>
</p>
<div class="order-card">
@@ -51,7 +51,7 @@
></el-step>
</el-steps>
</div>
<div class="order-card" v-if="order.order.deliveryMethod === 'LOGISTICS' && order.order.orderType !== 'VIRTUAL'">
<div class="order-card" v-if="order.order.deliveryMethod === 'LOGISTICS' && !isNonPhysicalOrder">
<h3>收货人信息</h3>
<p>收货人:{{ order.order.consigneeName }}</p>
<p>手机号码:{{ $filters.secrecyMobile( order.order.consigneeMobile ) }}</p>
@@ -70,7 +70,7 @@
<p>支付方式:{{ order.paymentMethodValue }}</p>
<p>付款状态:{{ order.payStatusValue }}</p>
</div>
<div class="order-card" v-if="!order.order.verificationCode && order.order.orderType !== 'VIRTUAL'">
<div class="order-card" v-if="!order.order.verificationCode && !isNonPhysicalOrder">
<h3>配送信息</h3>
<p>配送方式:{{ order.deliveryMethodValue }}</p>
<p v-if="order.order.deliveryMethod === 'LOGISTICS'">配送状态:{{ order.deliverStatusValue }}</p>
@@ -132,6 +132,37 @@
</template>
<div v-else style="color: #999; margin-left: 5px">未开发票</div>
</div>
<!-- 电子卡券卡密信息PC 内嵌) -->
<div class="order-card ecoupon-card-keys" v-if="isECouponOrder">
<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>
<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>
</div>
<!-- 订单商品 -->
<div class="goods">
<div class="shop-name">
@@ -144,8 +175,8 @@
<th width="15%">货号</th>
<th width="10%">单价</th>
<th width="5%">数量</th>
<th width="10%">退款状态</th>
<th width="10%">实际退款金额</th>
<th width="10%" v-if="!isECouponOrder">退款状态</th>
<th width="10%" v-if="!isECouponOrder">实际退款金额</th>
<th width="10%">小计</th>
<th width="10%">操作</th>
</tr>
@@ -170,8 +201,8 @@
<td>{{ goods.id }}</td>
<td>{{ $filters.unitPrice(goods.goodsPrice, "¥") }}</td>
<td>{{ goods.num }}</td>
<td>{{refundPriceList(goods.isRefund)}}</td>
<td>{{ $filters.unitPrice(goods.refundPrice, "¥") }}</td>
<td v-if="!isECouponOrder">{{refundPriceList(goods.isRefund)}}</td>
<td v-if="!isECouponOrder">{{ $filters.unitPrice(goods.refundPrice, "¥") }}</td>
<td>{{ $filters.unitPrice((goods.goodsPrice * goods.num), "¥") }}</td>
<td class="order-item-actions">
<el-button
@@ -192,8 +223,9 @@
>
<el-button
v-if="
goods.afterSaleStatus.includes('NOT_APPLIED') ||
goods.afterSaleStatus.includes('PART_AFTER_SALE')
!isECouponOrder &&
(goods.afterSaleStatus.includes('NOT_APPLIED') ||
goods.afterSaleStatus.includes('PART_AFTER_SALE'))
"
@click="applyAfterSale(goods.sn)"
type="default"
@@ -213,7 +245,7 @@
<div>
<span>商品总价:</span><span>{{ $filters.unitPrice(order.order.goodsPrice, "¥") }}</span><br />
</div>
<div v-if="order.order.orderType !== 'VIRTUAL'">
<div v-if="!isECouponOrder">
<span>运费:</span><span>+{{ $filters.unitPrice(order.order.freightPrice, "¥") }}</span><br />
</div>
<div v-if="order.order.priceDetailDTO.couponPrice">
@@ -319,6 +351,19 @@
<el-button @click="logisticsModal = false">取消</el-button>
</div></template>
</el-dialog>
<!-- 移动端查看卡密 -->
<el-dialog v-model="cardKeyDialogVisible" title="卡密信息" width="92%" class="card-key-mobile-dialog">
<div v-for="(row, idx) in ecouponCardKeyRows" :key="idx" class="card-key-mobile-item">
<p><strong>卡号:</strong>{{ row.cardNo }}</p>
<p><strong>卡密:</strong>{{ row.cardSecret }}</p>
<el-button size="small" link type="primary" @click="copyCardKey(row)">复制</el-button>
</div>
<template #footer>
<el-button @click="copyAllCardKeys">复制全部</el-button>
<el-button type="primary" @click="cardKeyDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</UserCenterLayout>
</div>
@@ -333,6 +378,12 @@ import {
getPackage
} from "@/api/order.js";
import { afterSaleReason, receiptDetail } from "@/api/member";
import { isECouponOrder as checkECouponOrder } from "@/constants/goodsType";
/**
* 订单详情E_COUPON 展示 orderItems[].cardKeys屏蔽售后/物流FR-B-03 / FR-B-04
* 移动端已完成订单用 Dialog 查看卡密(原型 P-09 / D-01
*/
export default {
name: "order-detail",
data() {
@@ -340,6 +391,7 @@ export default {
order: {}, // 订单详情数据
progressList: [], // 订单流程
logistics: "", // 物流数据
cardKeyDialogVisible: false,
cancelParams: {
// 取消售后参数
orderSn: "",
@@ -352,7 +404,57 @@ export default {
logisticsModal: false,
};
},
computed: {
/** 电子卡券订单:无物流/核验码,卡密数据来自 API-B-01 orderItems[].cardKeys */
isECouponOrder() {
return checkECouponOrder(this.order?.order?.orderType);
},
isVirtualOrder() {
return this.order?.order?.orderType === "VIRTUAL";
},
isNonPhysicalOrder() {
return this.isVirtualOrder || this.isECouponOrder;
},
ecouponCardKeyDelivered() {
return (this.order.orderItems || []).some((item) => item.cardKeyDelivered);
},
ecouponCardKeyRows() {
const rows = [];
(this.order.orderItems || []).forEach((item) => {
(item.cardKeys || []).forEach((ck) => {
rows.push({ ...ck });
});
});
return rows;
},
},
methods: {
copyCardKey(row) {
const text = `卡号:${row.cardNo || ""}\n卡密${row.cardSecret || ""}`;
this.copyText(text);
},
copyAllCardKeys() {
const text = this.ecouponCardKeyRows
.map((row, i) => `${i + 1}. 卡号:${row.cardNo || ""} 卡密:${row.cardSecret || ""}`)
.join("\n");
this.copyText(text || "");
},
copyText(text) {
if (!text) return;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
Message.success("已复制到剪贴板");
});
} else {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
Message.success("已复制到剪贴板");
}
},
isVatSpecialReceipt (receipt) {
if (!receipt) return false;
const rt = receipt.receiptType != null ? String(receipt.receiptType).trim() : "";
@@ -660,6 +762,32 @@ table {
color: $theme_color;
}
}
.ecoupon-card-keys-mobile {
display: none;
}
.ecoupon-card-keys-pc {
display: block;
}
.card-key-mobile-item {
padding: 12px 0;
border-bottom: 1px solid #eee;
p {
margin: 4px 0;
word-break: break-all;
}
}
@media (max-width: 768px) {
.ecoupon-card-keys-mobile {
display: block;
}
.ecoupon-card-keys-pc {
display: none;
}
}
/** 订单进度条 */
.progress {
margin: 15px 0;

View File

@@ -18,7 +18,8 @@
<el-divider />
<div class="content width_1200_auto">
<!-- 收货地址 -->
<div class="address" v-if="selectedDeliverMethod === 'LOGISTICS' && goodsType !== 'VIRTUAL_GOODS'">
<!-- 收货地址E_COUPON / VIRTUAL 免地址 isVirtualLikeCheckout -->
<div class="address" v-if="selectedDeliverMethod === 'LOGISTICS' && !isVirtualLikeCheckout">
<div class="card-head">
<span>收货人信息</span>
<span @click="goAddressManage">管理收货人地址</span>
@@ -94,7 +95,7 @@
</div>
<div>
</div>
<div class="goods-content" v-if="goodsType !== 'VIRTUAL_GOODS'">
<div class="goods-content" v-if="!isVirtualLikeCheckout">
<div class="card-head mt_20 mb_20">
<span>配送方式</span>
</div>
@@ -166,8 +167,8 @@
<span @click="editInvoice">编辑</span>
</div>
</div>
<!-- 优惠券 -->
<div class="invoice">
<!-- 优惠券E_COUPON 不参与促销 M-01 -->
<div class="invoice" v-if="!isECouponCheckout">
<div class="card-head mt_20 mb_20">
<span class="relative">优惠券</span>
</div>
@@ -203,7 +204,7 @@
</ul>
</div>
<!-- 礼品卡 -->
<div class="invoice pay-gcc-module">
<div class="invoice pay-gcc-module" v-if="!isECouponCheckout">
<div class="pay-gcc-head">
<div class="pay-gcc-title">
使用礼品卡
@@ -328,6 +329,7 @@ import {
} from "@/api/cart";
import { getStoreAddress } from "@/api/shopentry.js"
import { canUseCouponList } from "@/api/member.js";
import { isECoupon } from "@/constants/goodsType";
export default {
name: "Pay",
@@ -377,6 +379,14 @@ export default {
);
return Number.isFinite(n) ? n : 0;
},
/** 电子卡券结算:免地址、隐藏优惠券/礼品卡/发票M-01 / O-11 */
isECouponCheckout() {
return isECoupon(this.goodsType);
},
/** 虚拟型结算(核销虚拟 + 电子卡券):均不需要收货地址 */
isVirtualLikeCheckout() {
return this.goodsType === "VIRTUAL_GOODS" || this.isECouponCheckout;
},
},
data() {
return {
@@ -854,10 +864,15 @@ export default {
query: { orderType: "TRADE", sn: res.result.sn },
});
}
} else if (res.message) {
Message.warning(res.message);
}
})
.catch(() => {
.catch((err) => {
Spin.hide();
if (err?.message && !err?.data?.message) {
Message.error(err.message);
}
});
},
// 优惠券可用范围

View File

@@ -102,14 +102,21 @@ export default {
title: '支付确认',
content: '确认使用余额支付吗?',
onOk: () => {
pay(params).then(res => {
return pay(params)
.then((res) => {
if (res.success) {
Message.success(res.message)
Message.success(res.message || '支付成功');
this.$router.push('/payDone');
} else {
Message.warning(res.message)
Message.warning(res.message || '支付失败');
}
})
.catch((err) => {
// 支付失败兜底:业务错误(如 CARD_KEY_STORE_SELL_FORBIDDEN通常已由 axios 拦截器提示
if (err?.message && !err?.data?.message) {
Message.error(err.message);
}
});
}
});
} else {

View File

@@ -0,0 +1,18 @@
/**
* 卡密商品E_COUPON— 平台端订单展示用常量
*
* 平台不管理卡池明文S-04本模块仅用于订单详情卡密状态文案。
*
* @author Mike
* @date 2026-07-31
*/
/** 卡密状态文案(与后端 CardKeyStatusEnum 一致) */
export const CARD_KEY_STATUS_TEXT = {
UNUSED: "未使用",
ALLOCATED: "已分配",
VOIDED: "已作废",
};
export function formatCardKeyStatus(status) {
return CARD_KEY_STATUS_TEXT[status] || status || "—";
}

View File

@@ -53,6 +53,8 @@
>
<el-option label="实物商品" value="PHYSICAL_GOODS" />
<el-option label="虚拟商品" value="VIRTUAL_GOODS" />
<!-- E_COUPON卡密商品平台无卡池管理权限S-04 -->
<el-option label="电子卡券" value="E_COUPON" />
</el-select>
</el-form-item>
<el-form-item label="商品分组" prop="groupId">
@@ -385,7 +387,8 @@ export default {
goodsTypeText(v) {
if (v === "PHYSICAL_GOODS") return "实物商品";
if (v === "VIRTUAL_GOODS") return "虚拟商品";
return "电子卡券";
if (v === "E_COUPON") return "电子卡券"; // 卡密商品
return v || "—";
},
marketEnableText(v) {
if (v === "DOWN") return "下架";

View File

@@ -8,7 +8,7 @@
<el-button v-if="allowOperation.cancel" plain type="warning" @click="orderCancel">订单取消</el-button>
<el-button v-if="orderInfo.order.orderStatus === 'UNPAID'" type="primary" @click="confirmPrice">收款</el-button>
<el-button plain @click="orderLog">订单日志</el-button>
<el-button v-if="$route.query.orderType != 'VIRTUAL'" plain type="primary" style="float:right;" @click="printOrder">打印发货单</el-button>
<el-button v-if="!isNonPhysicalOrder" plain type="primary" style="float:right;" @click="printOrder">打印发货单</el-button>
</div>
</el-card>
<el-card class="mt_10 clearfix">
@@ -90,7 +90,7 @@
</div>
</div>
<div style="width: 36%; float: left">
<div class="div-item">
<div class="div-item" v-if="!isECouponOrder && orderInfo.order.deliveryMethod != 'SELF_PICK_UP'">
<div class="div-item-left">收货信息</div>
<div class="div-item-right">
{{ orderInfo.order.consigneeName }}
@@ -165,7 +165,7 @@
</div>
</div> -->
<div class="div-item" v-if="$route.query.orderType != 'VIRTUAL'">
<div class="div-item" v-if="!isNonPhysicalOrder">
<div class="div-item-left">配送方式</div>
<div class="div-item-right">
{{ orderInfo.deliveryMethodValue }}
@@ -217,6 +217,42 @@
</template>
</el-table-column>
</el-table>
<!-- E_COUPON平台订单详情只读展示卡密无卡池代管S-04 -->
<div v-if="isECouponOrder" class="ecoupon-card-keys mt_10">
<h4>卡密信息</h4>
<el-alert
v-if="!ecouponCardKeyDelivered"
type="info"
show-icon
:closable="false"
title="卡密尚未发放"
class="mb_10"
/>
<el-table
v-else-if="ecouponCardKeyRows.length"
border
:data="ecouponCardKeyRows"
style="width: 100%"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="goodsName" label="商品" min-width="140" 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="90" align="center">
<template #default="{ row }">
<span v-if="row">{{ formatCardKeyStatus(row.status || 'ALLOCATED') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" 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>
<div class="goods-total">
<ul>
<li>
@@ -280,7 +316,7 @@
<span class="label" v-if="typeList.length == 1 && index == 0" style="font-size:10px !important;"><a @click="gotoHomes" style="display: inline-block;border-top: 1px dashed;border-bottom: 1px dashed;color:black;width:80px;">{{item.promotionName}}</a><span class="op-split">|</span>
<span class="txt" v-if="typeList.length == 1 && index == 0" style="border-top: 1px dashed;border-bottom: 1px dashed;font-size:10px !important;">¥{{ unitPrice(item.discountPrice) }}</span>
</li> -->
<li>
<li v-if="!isECouponOrder">
<span class="label">运费:</span>
<span class="txt">{{
unitPrice(orderInfo.order.freightPrice, "¥")
@@ -440,6 +476,7 @@ import vueQr from "vue-qr";
import { printElement } from "@/utils/print";
import { ElMessage, ElMessageBox } from "element-plus";
import { unitPrice, clientTypeWay } from "@/utils/filters";
import { formatCardKeyStatus } from "@/constants/cardKey";
export default {
name: "orderList",
components: {
@@ -447,6 +484,35 @@ export default {
multipleMap,
"vue-qr": vueQr,
},
computed: {
/** 电子卡券订单:无物流区,卡密来自 orderItems[].cardKeys */
isECouponOrder() {
const t = this.orderInfo?.order?.orderType;
return t === "E_COUPON" || this.$route.query.orderType === "E_COUPON";
},
isVirtualOrder() {
const t = this.orderInfo?.order?.orderType;
return t === "VIRTUAL" || this.$route.query.orderType === "VIRTUAL";
},
isNonPhysicalOrder() {
return this.isVirtualOrder || this.isECouponOrder;
},
ecouponCardKeyDelivered() {
return (this.data || []).some((item) => item.cardKeyDelivered);
},
ecouponCardKeyRows() {
const rows = [];
(this.data || []).forEach((item) => {
(item.cardKeys || []).forEach((ck) => {
rows.push({
...ck,
goodsName: item.goodsName,
});
});
});
return rows;
},
},
data () {
return {
typeList: [],
@@ -546,6 +612,23 @@ export default {
methods: {
unitPrice,
clientTypeWay,
formatCardKeyStatus,
copyCardKey(row) {
const text = `卡号:${row.cardNo || ""}\n卡密${row.cardSecret || ""}`;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
ElMessage.success("已复制到剪贴板");
});
} else {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
ElMessage.success("已复制到剪贴板");
}
},
getPromotionText(row) {
let resultText = "";
if (row && row.promotionType) {

65
seller/src/api/cardKey.js Normal file
View File

@@ -0,0 +1,65 @@
/**
* 卡密商品E_COUPON— 商家端卡池 HTTP 封装
*
* Base Path/store/goods/card-keyaxios 已带 /store 前缀)
* 对应 card-key-goods-api.md API-S-01a ~ S-07导出为同步文件流S-08
*
* @author Mike
* @date 2026-07-31
*/
import {
getRequest,
postRequestWithNoForm,
putRequest,
uploadFileRequest,
} from "@/libs/axios";
import { downloadBlob } from "@/utils/downloadBlob";
/** API-S-01a 下载卡密导入模板(同步文件流) */
export const downloadImportTemplateBlob = () => {
return getRequest("/goods/card-key/import/template", {}, "blob");
};
export const downloadImportTemplate = async () => {
const blob = await downloadImportTemplateBlob();
downloadBlob(blob, "card-key-import-template.xlsx");
};
/** API-S-01 批量导入卡密 */
export const importCardKey = (skuId, file) => {
const formData = new FormData();
formData.append("skuId", skuId);
formData.append("file", file);
return uploadFileRequest("/goods/card-key/import", formData);
};
/** API-S-02 单条新增卡密(后端 @RequestBody须 application/json */
export const addCardKey = (data) => {
return postRequestWithNoForm("/goods/card-key/add", data);
};
/** API-S-03 卡池分页列表 */
export const getCardKeyList = (params) => {
return getRequest("/goods/card-key/list", params);
};
/** API-S-04 作废卡密 */
export const voidCardKey = (id) => {
return putRequest(`/goods/card-key/void/${id}`);
};
/** API-S-05 卡池状态统计 */
export const getCardKeyStats = (skuId) => {
return getRequest(`/goods/card-key/stats/${skuId}`);
};
/** API-S-07 卡池导出(同步文件流) */
export const exportCardKeyBlob = (params) => {
return getRequest("/goods/card-key/export", params, "blob");
};
export const exportCardKey = async (params, skuId) => {
const blob = await exportCardKeyBlob(params);
const ts = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
downloadBlob(blob, `card-key-${skuId || "export"}-${ts}.xlsx`);
};

View File

@@ -0,0 +1,41 @@
/**
* 卡密商品E_COUPON— 商家端常量
*
* 卡池状态 UNUSED / ALLOCATED / VOIDEDTab 与列表筛选、标签色映射。
* 需求card-key-goods-api.md §2.1~2.3
*
* @author Mike
* @date 2026-07-31
*/
/** 卡密状态枚举(与后端 CardKeyStatusEnum 一致) */
export const CARD_KEY_STATUS = {
UNUSED: "UNUSED",
ALLOCATED: "ALLOCATED",
VOIDED: "VOIDED",
};
export const CARD_KEY_STATUS_TEXT = {
UNUSED: "未使用",
ALLOCATED: "已分配",
VOIDED: "已作废",
};
export const CARD_KEY_STATUS_TAG = {
UNUSED: "success",
ALLOCATED: "info",
VOIDED: "danger",
};
/** Tab全部 + 各状态 */
export const CARD_KEY_STATUS_TABS = [
{ key: "", label: "全部" },
{ key: CARD_KEY_STATUS.UNUSED, label: "未使用" },
{ key: CARD_KEY_STATUS.ALLOCATED, label: "已分配" },
{ key: CARD_KEY_STATUS.VOIDED, label: "已作废" },
];
export const E_COUPON_GOODS_TYPE = "E_COUPON";
export function formatCardKeyStatus(status) {
return CARD_KEY_STATUS_TEXT[status] || status || "—";
}

View File

@@ -64,6 +64,13 @@ export const otherRouter = {
name: "goods-draft-operation-edit",
component: () => import("@/views/goods/goods-seller/goodsOperation.vue")
},
/** 卡密商品E_COUPON卡池管理query: skuId必填, goodsId, goodsName */
{
path: "card-key-pool",
title: "卡池管理",
name: "card-key-pool",
component: () => import("@/views/goods/card-key/cardKeyPool.vue")
},
{
path: "add-coupon",
title: "店铺优惠券",

View File

@@ -0,0 +1,485 @@
<!-- 卡密商品 · 卡池管理原型 P-04API-S-01~S-07
@author Mike
@date 2026-07-31
-->
<template>
<div class="search card-key-pool">
<el-card>
<div v-if="goodsName" class="pool-header">
<el-button @click="goBack">返回</el-button>
<span class="pool-meta">
<strong>商品</strong>{{ goodsName }}
</span>
</div>
<el-alert
v-if="!skuId"
type="warning"
show-icon
:closable="false"
class="mb_10"
>
缺少 SKU 参数请从商品列表卡池管理进入
</el-alert>
<el-form
v-else
ref="searchFormRef"
:model="searchForm"
inline
label-width="70px"
class="search-form"
@keyup.enter="handleSearch"
>
<el-form-item label="卡号" prop="cardNo">
<el-input
v-model="searchForm.cardNo"
placeholder="卡号模糊搜索"
clearable
style="width: 240px"
/>
</el-form-item>
<el-form-item label="导入时间" prop="importRange">
<el-date-picker
v-model="importRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card v-if="skuId">
<div class="goods-tab">
<el-tabs v-model="statusTab" @tab-click="onStatusTabClick">
<el-tab-pane
v-for="tab in statusTabsWithCount"
:key="tab.value"
:label="tab.title"
:name="tab.value"
/>
</el-tabs>
</div>
<el-alert
v-if="poolLocked"
type="warning"
show-icon
:closable="false"
class="mb_10"
title="当前商品不可管理卡池(审核拒绝、商品已删除或店铺已关店)"
/>
<div class="operation" style="margin: 10px 0">
<el-button type="primary" :disabled="poolLocked" @click="importModal = true">
批量导入
</el-button>
<el-button :disabled="poolLocked" @click="openAddDialog">单条新增</el-button>
<el-button :loading="exportLoading" :disabled="poolLocked" @click="handleExport">
导出
</el-button>
</div>
<el-table v-loading="loading" :data="data" class="mt_10" style="width: 100%">
<el-table-column label="序号" width="60" align="center">
<template #default="{ $index }">
{{ (searchForm.pageNumber - 1) * searchForm.pageSize + $index + 1 }}
</template>
</el-table-column>
<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 label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row" :type="statusTag(row.status)" size="small">
{{ formatStatus(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="导入时间" width="170" />
<el-table-column prop="allocatedTime" label="发卡时间" width="170" />
<el-table-column prop="orderSn" label="订单号" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-button
v-if="row && row.status === 'UNUSED'"
link
type="danger"
:disabled="poolLocked"
@click="handleVoid(row)"
>
作废
</el-button>
<span v-else></span>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
size="small"
@current-change="getList"
@size-change="onPageSizeChange"
/>
</div>
</el-card>
<!-- 批量导入 -->
<el-dialog v-model="importModal" title="批量导入卡密" width="520px" :close-on-click-modal="false">
<p class="import-tip">
Excel 模板 1 行表头 2 行起为数据 A 卡号 B 卡密单次最多 10,000
</p>
<el-button
type="primary"
link
class="mb_10"
:loading="templateLoading"
@click="handleDownloadTemplate"
>
下载导入模板
</el-button>
<el-upload drag :show-file-list="false" accept=".xlsx" :before-upload="handleImportUpload">
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
</el-upload>
<template #footer>
<el-button @click="importModal = false">关闭</el-button>
</template>
</el-dialog>
<!-- 导入结果 -->
<el-dialog v-model="importResultVisible" title="导入结果" width="560px">
<p>成功 {{ importResult.successCount || 0 }} 失败 {{ importResult.failCount || 0 }} </p>
<el-table
v-if="importResult.failRows && importResult.failRows.length"
:data="importResult.failRows"
max-height="320"
border
>
<el-table-column prop="row" label="行号" width="80" />
<el-table-column prop="cardNo" label="卡号" min-width="120" />
<el-table-column prop="reason" label="失败原因" min-width="200" />
</el-table>
<template #footer>
<el-button type="primary" @click="importResultVisible = false">确定</el-button>
</template>
</el-dialog>
<!-- 单条新增 -->
<el-dialog v-model="addVisible" title="单条新增卡密" width="480px" :close-on-click-modal="false">
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="80px">
<el-form-item label="卡号" prop="cardNo">
<el-input v-model="addForm.cardNo" placeholder="请输入卡号" clearable />
</el-form-item>
<el-form-item label="卡密" prop="cardSecret">
<el-input v-model="addForm.cardSecret" placeholder="请输入卡密" clearable />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addVisible = false">取消</el-button>
<el-button type="primary" :loading="addLoading" @click="submitAdd">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script>
import {
importCardKey,
addCardKey,
getCardKeyList,
voidCardKey,
getCardKeyStats,
exportCardKey,
downloadImportTemplate,
} from "@/api/cardKey";
import {
formatCardKeyStatus,
CARD_KEY_STATUS_TAG,
} from "@/constants/cardKey";
/**
* 商家卡池管理页:导入/新增/作废/导出,列表展示明文卡密(仅商家授权上下文)。
* 入口:商品列表「卡池管理」,路由 query 须带 skuId审核拒绝/删 SKU/关店时 poolLocked。
*
* @author Mike
* @date 2026-07-31
*/
export default {
name: "cardKeyPool",
data() {
return {
skuId: "",
goodsId: "",
goodsName: "",
statusTab: "ALL",
stats: null,
poolLocked: false,
loading: false,
exportLoading: false,
templateLoading: false,
data: [],
total: 0,
importRange: [],
searchForm: {
cardNo: "",
pageNumber: 1,
pageSize: 20,
},
importModal: false,
importResultVisible: false,
importResult: {},
addVisible: false,
addLoading: false,
addForm: {
cardNo: "",
cardSecret: "",
},
addRules: {
cardNo: [{ required: true, message: "卡号不能为空", trigger: "blur" }],
cardSecret: [{ required: true, message: "卡密不能为空", trigger: "blur" }],
},
};
},
computed: {
statusTabsWithCount() {
const s = this.stats || {};
const withCount = (label, count) =>
count != null && this.stats ? `${label}(${count})` : label;
const total =
(s.unusedCount || 0) + (s.allocatedCount || 0) + (s.voidedCount || 0);
return [
{ title: withCount("全部", total), value: "ALL" },
{ title: withCount("未使用", s.unusedCount || 0), value: "UNUSED" },
{ title: withCount("已分配", s.allocatedCount || 0), value: "ALLOCATED" },
{ title: withCount("已作废", s.voidedCount || 0), value: "VOIDED" },
];
},
},
methods: {
formatStatus: formatCardKeyStatus,
statusTag(status) {
return CARD_KEY_STATUS_TAG[status] || "info";
},
goBack() {
this.$router.back();
},
initFromRoute() {
const q = this.$route.query;
this.skuId = q.skuId || "";
this.goodsId = q.goodsId || "";
this.goodsName = q.goodsName || "";
},
onStatusTabClick(tab) {
this.statusTab = tab.paneName;
this.searchForm.pageNumber = 1;
this.getList();
},
buildListParams() {
const params = {
skuId: this.skuId,
pageNumber: this.searchForm.pageNumber,
pageSize: this.searchForm.pageSize,
};
if (this.goodsId) params.goodsId = this.goodsId;
if (this.searchForm.cardNo) params.cardNo = this.searchForm.cardNo;
if (this.statusTab && this.statusTab !== "ALL") {
params.status = this.statusTab;
}
if (this.importRange && this.importRange.length === 2) {
params.createTimeStart = this.importRange[0];
params.createTimeEnd = this.importRange[1];
}
return params;
},
loadStats() {
if (!this.skuId) return;
getCardKeyStats(this.skuId).then((res) => {
if (res.success) {
this.stats = res.result;
}
});
},
getList() {
if (!this.skuId) return;
this.loading = true;
getCardKeyList(this.buildListParams())
.then((res) => {
this.loading = false;
if (res.success) {
this.data = res.result.records || [];
this.total = res.result.total || 0;
this.poolLocked = false;
} else {
this.checkPoolLocked(res);
}
})
.catch(() => {
this.loading = false;
});
},
/** 审核拒绝 / SKU 删除 / 关店时禁用卡池操作S-06 / S-07 / EC-21~23 */
checkPoolLocked(res) {
const lockedCodes = [
"CARD_KEY_GOODS_AUTH_REFUSE",
"CARD_KEY_SKU_DELETED",
"CARD_KEY_STORE_CLOSED",
];
if (res.code && lockedCodes.includes(String(res.code))) {
this.poolLocked = true;
}
},
handleSearch() {
this.searchForm.pageNumber = 1;
this.getList();
},
handleReset() {
this.searchForm.cardNo = "";
this.importRange = [];
this.searchForm.pageNumber = 1;
this.getList();
},
onPageSizeChange() {
this.searchForm.pageNumber = 1;
this.getList();
},
handleImportUpload(file) {
if (!/\.xlsx$/i.test(file.name)) {
this.$Message.error("请上传 .xlsx 文件");
return false;
}
importCardKey(this.skuId, file).then((res) => {
if (res.success) {
this.importModal = false;
this.importResult = res.result || {};
this.importResultVisible = true;
this.loadStats();
this.getList();
} else {
this.checkPoolLocked(res);
}
});
return false;
},
handleDownloadTemplate() {
this.templateLoading = true;
downloadImportTemplate()
.then(() => {
this.$Message.success("模板下载成功");
})
.catch(() => {})
.finally(() => {
this.templateLoading = false;
});
},
openAddDialog() {
this.addForm = { cardNo: "", cardSecret: "" };
this.addVisible = true;
},
submitAdd() {
this.$refs.addFormRef.validate((valid) => {
if (!valid) return;
this.addLoading = true;
addCardKey({
skuId: this.skuId,
cardNo: this.addForm.cardNo.trim(),
cardSecret: this.addForm.cardSecret.trim(),
})
.then((res) => {
this.addLoading = false;
if (res.success) {
this.$Message.success("新增成功");
this.addVisible = false;
this.loadStats();
this.getList();
} else {
this.checkPoolLocked(res);
}
})
.catch(() => {
this.addLoading = false;
});
});
},
handleVoid(row) {
this.$Modal.confirm({
title: "确认作废",
content: `确定作废卡号「${row.cardNo}」?作废后不可恢复。`,
onOk: () => {
voidCardKey(row.id).then((res) => {
if (res.success) {
this.$Message.success("作废成功");
this.loadStats();
this.getList();
}
});
},
});
},
handleExport() {
this.exportLoading = true;
const params = { ...this.buildListParams() };
delete params.pageNumber;
delete params.pageSize;
exportCardKey(params, this.skuId)
.then(() => {
this.$Message.success("导出成功");
})
.catch(() => {})
.finally(() => {
this.exportLoading = false;
});
},
init() {
this.initFromRoute();
if (this.skuId) {
this.loadStats();
this.getList();
}
},
},
mounted() {
this.init();
},
};
</script>
<style lang="scss" scoped>
@import "@/styles/table-common.scss";
.pool-header {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.pool-meta {
color: #606266;
font-size: 14px;
}
.goods-tab {
:deep(.el-tabs__item) {
font-size: 14px;
}
}
.import-tip {
margin: 0 0 12px;
font-size: 13px;
color: #909399;
line-height: 1.5;
}
</style>

View File

@@ -53,6 +53,8 @@
>
<el-option label="实物商品" value="PHYSICAL_GOODS" />
<el-option label="虚拟商品" value="VIRTUAL_GOODS" />
<!-- E_COUPON卡密商品可跳转卡池管理 -->
<el-option label="电子卡券" value="E_COUPON" />
</el-select>
</el-form-item>
<el-form-item>
@@ -144,7 +146,7 @@
</template>
</el-table-column>
<el-table-column prop="storeName" label="店铺名称" width="200" show-overflow-tooltip />
<el-table-column label="操作" width="200" align="center" fixed="right">
<el-table-column label="操作" width="260" align="center" fixed="right">
<template #default="{ row }">
<template v-if="row.marketEnable === 'DOWN'">
<a class="link-text" @click="upper(row)">上架</a>
@@ -156,6 +158,11 @@
<span class="op-split">|</span>
<a class="link-text" @click="editGoods(row)">编辑</a>
</template>
<!-- E_COUPON 专属卡池管理入口原型 P-03 -->
<template v-if="row.goodsType === 'E_COUPON'">
<span class="op-split">|</span>
<a class="link-text" @click="goCardKeyPool(row)">卡池管理</a>
</template>
</template>
</el-table-column>
</el-table>
@@ -192,6 +199,7 @@
import {
getGoodsListData,
getGoodsNumerData,
getQueryGoodsIdGoodsList,
upGoods,
lowGoods,
} from "@/api/goods";
@@ -248,7 +256,34 @@ export default {
goodsTypeText(v) {
if (v === "PHYSICAL_GOODS") return "实物商品";
if (v === "VIRTUAL_GOODS") return "虚拟商品";
return "电子卡券";
if (v === "E_COUPON") return "电子卡券"; // 卡密商品
return v || "—";
},
/** 跳转卡池管理;单 SKU 商品默认取第一个 SKUcard-key-pool 需 skuId */
async goCardKeyPool(row) {
let skuId = row.skuId;
if (!skuId) {
try {
const res = await getQueryGoodsIdGoodsList(row.id);
if (res.success && res.result?.length) {
skuId = res.result[0].id;
} else {
this.$message.warning("该商品暂无 SKU请先完善商品规格");
return;
}
} catch {
this.$message.error("获取商品规格失败");
return;
}
}
this.$router.push({
path: "/card-key-pool",
query: {
skuId,
goodsId: row.id,
goodsName: row.goodsName,
},
});
},
marketEnableText(v) {
if (v === "DOWN") return "下架";

View File

@@ -84,6 +84,7 @@ export default {
data() {
return {
selectGoodsType: false, // 展示选择商品分类modal
/** 商品类型选项E_COUPON 为卡密商品(与 VIRTUAL_GOODS 核销型区分,见 FR-S-01 */
goodsTypeWay: [
{
title: "实物商品",
@@ -99,6 +100,13 @@ export default {
type: "VIRTUAL_GOODS",
check: false,
},
{
title: "电子卡券",
img: goodsType2Img,
desc: "卡密自动发卡,无需物流",
type: "E_COUPON", // goodsType库存由卡池同步非手动填写
check: false,
},
],
// 商品分类选择数组
category: [
@@ -230,4 +238,12 @@ export default {
gap: 12px;
width: 100%;
}
.content-goods-publish {
.goods-category li.activeClass {
background-color: #409eff;
border-color: #409eff;
color: #fff;
}
}
</style>

View File

@@ -44,7 +44,7 @@
</el-form-item>
<el-form-item class="form-item-view-el" label="销售模式" prop="salesModel">
<el-radio-group
v-if="baseInfoForm.goodsType != 'VIRTUAL_GOODS'"
v-if="!isVirtualLikeGoods"
v-model="baseInfoForm.salesModel"
@change="handleSalesModeChange"
>
@@ -52,7 +52,7 @@
<el-radio-button value="WHOLESALE">批发型</el-radio-button>
</el-radio-group>
<el-radio-group v-else v-model="baseInfoForm.salesModel">
<el-radio-button value="RETAIL">虚拟型</el-radio-button>
<el-radio-button value="RETAIL">{{ isECouponGoods ? "电子卡券" : "虚拟型" }}</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="baseInfoForm.salesModel == 'WHOLESALE'" class="form-item-view-el" label="销售规则"
@@ -336,7 +336,7 @@
@change="updateSkuTable(row, 'sn', $index)"
/>
<el-input
v-else-if="col.slot === 'weight' && baseInfoForm.goodsType !== 'VIRTUAL_GOODS'"
v-else-if="col.slot === 'weight' && needsLogistics && baseInfoForm.salesModel !== 'WHOLESALE'"
v-model="row.weight"
clearable
placeholder="请输入重量"
@@ -344,6 +344,14 @@
>
<template #append>kg</template>
</el-input>
<el-input
v-else-if="col.slot === 'quantity' && isECouponGoods"
:model-value="resolvePoolStockDisplay(row)"
disabled
placeholder="卡池可用数"
>
<template #append>{{ baseInfoForm.goodsUnit || "" }}</template>
</el-input>
<el-input
v-else-if="col.slot === 'quantity'"
v-model="row.quantity"
@@ -444,7 +452,7 @@
</div>
</el-form-item>
</div>
<div v-if="baseInfoForm.goodsType != 'VIRTUAL_GOODS'">
<div v-if="needsLogistics">
<h4>商品物流信息</h4>
<div class="form-item-view">
<el-form-item class="form-item-view-el" label="物流模板" prop="templateId">
@@ -513,7 +521,7 @@
@selected="(list) => { selectedImage = list }"
/>
<template #footer>
<el-button @click="picModelFlag = false">取消</el-button>
<el-button @click="picModelFlag = false; selectedImage = []">取消</el-button>
<el-button type="primary" @click="confirmUrls">确定</el-button>
</template>
</el-dialog>
@@ -552,6 +560,22 @@ export default {
type: Object,
},
},
computed: {
/** 卡密商品:库存只读展示 poolStock提交时 quantity 传 0FR-S-01 / P-02 */
isECouponGoods() {
return this.baseInfoForm.goodsType === "E_COUPON";
},
isVirtualGoods() {
return this.baseInfoForm.goodsType === "VIRTUAL_GOODS";
},
isVirtualLikeGoods() {
return this.isVirtualGoods || this.isECouponGoods;
},
/** 仅实物需要运费模板E_COUPON 强制 templateId=0§6.1 */
needsLogistics() {
return this.baseInfoForm.goodsType === "PHYSICAL_GOODS";
},
},
data() {
// 表单验证项,商品价格
const checkPrice = (rule, value, callback) => {
@@ -759,11 +783,32 @@ export default {
}
},
methods: {
/** E_COUPON 库存列只读展示,无卡密时显示 0 */
resolvePoolStockDisplay(row) {
if (row.poolStock != null && row.poolStock !== "") {
return row.poolStock;
}
if (row.quantity != null && row.quantity !== "") {
return row.quantity;
}
return 0;
},
/** E_COUPON 提交固定传 0真实库存由卡池同步 */
resolveSubmitQuantity(sku) {
if (this.isECouponGoods) {
return 0;
}
return sku.quantity;
},
defaultSkuQuantity() {
return this.isECouponGoods ? 0 : "";
},
draggableItemKey(item) {
return item;
},
// 选择图片modal
handleCLickImg(val, index) {
this.selectedImage = [];
this.picModelFlag = true;
this.selectedFormBtnName = val;
this.$nextTick(() => {
@@ -793,22 +838,60 @@ export default {
desc: "视频大小不能超过10MB",
});
},
parseOssSelectionUrl(item) {
if (!item) return "";
if (typeof item === "string") {
const index = item.indexOf(",");
return index >= 0 ? item.slice(index + 1) : item;
}
return item.url || "";
},
// 图片选择后回调
callbackSelected(val) {
this.picModelFlag = false;
if (val && this.selectedFormBtnName == 'selectedSkuImages') {
this.selectedSku.images.push(val);
if (!val?.url) {
return;
}
if (this.selectedFormBtnName === "selectedSkuImages") {
if (!this.selectedSku.images) {
this.selectedSku.images = [];
}
this.selectedSku.images.push(val.url);
} else {
this.baseInfoForm[this.selectedFormBtnName].push(val.url);
}
},
confirmUrls() {
if (this.selectedImage && this.selectedFormBtnName == 'selectedSkuImages') {
this.selectedSku.images = [...this.selectedSku.images, ...this.selectedImage];
} else {
this.baseInfoForm[this.selectedFormBtnName] = [...this.baseInfoForm[this.selectedFormBtnName], ...this.selectedImage];
const urls = (this.selectedImage || [])
.map((item) => this.parseOssSelectionUrl(item))
.filter(Boolean);
if (!urls.length) {
this.$Message.warning("请选择图片");
return;
}
if (this.selectedFormBtnName === "selectedSkuImages") {
if (!this.selectedSku.images) {
this.selectedSku.images = [];
}
urls.forEach((url) => {
if (this.selectedSku.images.length < 5) {
this.selectedSku.images.push(url);
}
});
} else if (this.selectedFormBtnName === "goodsGalleryFiles") {
urls.forEach((url) => {
if (this.baseInfoForm.goodsGalleryFiles.length < 5) {
this.baseInfoForm.goodsGalleryFiles.push(url);
}
});
} else if (this.selectedFormBtnName) {
const target = this.baseInfoForm[this.selectedFormBtnName];
if (Array.isArray(target)) {
urls.forEach((url) => target.push(url));
}
}
this.selectedImage = [];
this.picModelFlag = false;
},
// 局部刷新
refresh(v) {
@@ -1255,6 +1338,7 @@ export default {
price: e.price,
// cost: e.cost,
quantity: e.quantity,
poolStock: e.poolStock,
// alertQuantity: e.alertQuantity,
weight: e.weight,
};
@@ -1724,14 +1808,20 @@ export default {
...combination,
id: existingCombination.id || "",
sn: existingCombination.sn || "",
quantity: existingCombination.quantity || "",
quantity: this.isECouponGoods
? this.resolvePoolStockDisplay(existingCombination)
: (existingCombination.quantity || ""),
poolStock: existingCombination.poolStock,
cost: existingCombination.cost || "",
price: existingCombination.price || "",
weight: existingCombination.weight || ""
};
} else {
// 新组合使用默认值
return combination;
return {
...combination,
quantity: this.defaultSkuQuantity(),
};
}
});
this.baseInfoForm.regeneratorSkuFlag = true;
@@ -1779,14 +1869,20 @@ export default {
...combination,
id: existingCombination.id || "",
sn: existingCombination.sn || "",
quantity: existingCombination.quantity || "",
quantity: this.isECouponGoods
? this.resolvePoolStockDisplay(existingCombination)
: (existingCombination.quantity || ""),
poolStock: existingCombination.poolStock,
cost: existingCombination.cost || "",
price: existingCombination.price || "",
weight: existingCombination.weight || ""
};
} else {
// 新组合使用默认值
return combination;
return {
...combination,
quantity: this.defaultSkuQuantity(),
};
}
});
@@ -1833,7 +1929,7 @@ export default {
// 有重量的情况
if (
this.baseInfoForm.goodsType !== "VIRTUAL_GOODS" &&
this.needsLogistics &&
this.baseInfoForm.salesModel !== "WHOLESALE"
) {
pushData.push({
@@ -1843,7 +1939,7 @@ export default {
}
pushData.push(
{
title: "库存",
title: this.isECouponGoods ? "卡池可用数" : "库存",
slot: "quantity",
},
// {
@@ -1878,7 +1974,10 @@ export default {
...combination,
id: existingCombination.id || "",
sn: existingCombination.sn || "",
quantity: existingCombination.quantity || "",
quantity: this.isECouponGoods
? this.resolvePoolStockDisplay(existingCombination)
: (existingCombination.quantity || ""),
poolStock: existingCombination.poolStock,
cost: existingCombination.cost || "",
price: existingCombination.price || (this.baseInfoForm.salesModel === 'WHOLESALE' && this.wholesaleData.length > 0 ? this.wholesaleData[0].price : ""),
weight: existingCombination.weight || ""
@@ -1889,7 +1988,7 @@ export default {
...combination,
id: "",
sn: "",
quantity: "",
quantity: this.defaultSkuQuantity(),
cost: "",
price: this.baseInfoForm.salesModel === 'WHOLESALE' && this.wholesaleData.length > 0 ? this.wholesaleData[0].price : "",
weight: ""
@@ -2153,6 +2252,10 @@ export default {
return;
}
if (submit.templateId === "") submit.templateId = 0;
if (this.isECouponGoods) {
submit.templateId = 0;
submit.salesModel = "RETAIL";
}
let flag = false;
let paramValue = "";
@@ -2175,7 +2278,7 @@ export default {
let skuCopy = {
cost: 1,
price: sku.price,
quantity: sku.quantity,
quantity: this.resolveSubmitQuantity(sku),
// alertQuantity: sku.alertQuantity,
sn: sku.sn,
images: [],

View File

@@ -27,7 +27,7 @@
@click="toPrint"
>打印电子面单</el-button>
<el-button
v-if="$route.query.orderType != 'VIRTUAL'"
v-if="!isNonPhysicalOrder"
type="primary"
plain
style="float: right"
@@ -122,7 +122,7 @@
</div>
</div>
<div style="width: 36%; float: left">
<div class="div-item" v-if="orderInfo.order.deliveryMethod != 'SELF_PICK_UP'">
<div class="div-item" v-if="!isECouponOrder && orderInfo.order.deliveryMethod != 'SELF_PICK_UP'">
<div class="div-item-left">收货信息</div>
<div class="div-item-right">
{{ orderInfo.order.consigneeName }}
@@ -204,7 +204,7 @@
</div>
</div> -->
<div class="div-item" v-if="$route.query.orderType != 'VIRTUAL'">
<div class="div-item" v-if="!isNonPhysicalOrder">
<div class="div-item-left">配送方式</div>
<div class="div-item-right">
{{ orderInfo.deliveryMethodValue }}
@@ -256,6 +256,42 @@
</template>
</el-table-column>
</el-table>
<!-- E_COUPON卡密来自订单详情 orderItems[].cardKeysS-09无独立 API-S-06 -->
<div v-if="isECouponOrder" class="ecoupon-card-keys mt_10">
<h4>卡密信息</h4>
<el-alert
v-if="!ecouponCardKeyDelivered"
type="info"
show-icon
:closable="false"
title="卡密尚未发放"
class="mb_10"
/>
<el-table
v-else-if="ecouponCardKeyRows.length"
border
:data="ecouponCardKeyRows"
style="width: 100%"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="goodsName" label="商品" min-width="140" 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="90" align="center">
<template #default="{ row }">
<span v-if="row">{{ formatCardKeyStatus(row.status || 'ALLOCATED') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" 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>
<div class="goods-total">
<ul>
<li>
@@ -319,7 +355,7 @@
<span class="label" v-if="typeList.length == 1 && index == 0" style="font-size:10px !important;"><a @click="gotoHomes" style="display: inline-block;border-top: 1px dashed;border-bottom: 1px dashed;color:black;width:80px;">{{item.promotionName}}</a><span class="op-split">|</span>
<span class="txt" v-if="typeList.length == 1 && index == 0" style="border-top: 1px dashed;border-bottom: 1px dashed;font-size:10px !important;">¥{{ $filters.unitPrice(item.discountPrice) }}</span>
</li> -->
<li>
<li v-if="!isECouponOrder">
<span class="label">运费:</span>
<span class="txt">{{
$filters.unitPrice(orderInfo.order.freightPrice, "¥")
@@ -694,6 +730,7 @@ import * as RegExp from "@/libs/RegExp.js";
import multipleMap from "@/views/my-components/map/multiple-map";
import vueQr from "vue-qr";
import { printElement } from "@/utils/print";
import { formatCardKeyStatus } from "@/constants/cardKey";
export default {
name: "orderDetail",
components: {
@@ -814,11 +851,38 @@ export default {
};
},
computed: {
/** 电子卡券订单:隐藏物流/发货/售后,展示 orderItems[].cardKeysFR-S-04 / FR-S-05 */
isECouponOrder() {
const t = this.orderInfo?.order?.orderType;
return t === "E_COUPON" || this.$route.query.orderType === "E_COUPON";
},
isVirtualOrder() {
const t = this.orderInfo?.order?.orderType;
return t === "VIRTUAL" || this.$route.query.orderType === "VIRTUAL";
},
isNonPhysicalOrder() {
return this.isVirtualOrder || this.isECouponOrder;
},
ecouponCardKeyDelivered() {
return (this.data || []).some((item) => item.cardKeyDelivered);
},
ecouponCardKeyRows() {
const rows = [];
(this.data || []).forEach((item) => {
(item.cardKeys || []).forEach((ck) => {
rows.push({
...ck,
goodsName: item.goodsName,
});
});
});
return rows;
},
canPartDelivery() {
if (!this.allowOperation.ship) return false;
const status = this.orderInfo.order && this.orderInfo.order.orderStatus;
return (
this.$route.query.orderType !== "VIRTUAL" &&
!this.isNonPhysicalOrder &&
["UNDELIVERED", "PARTS_DELIVERED"].includes(status) &&
this.deliverableOrderItems.length > 0
);
@@ -845,6 +909,23 @@ export default {
},
},
methods: {
formatCardKeyStatus,
copyCardKey(row) {
const text = `卡号:${row.cardNo || ""}\n卡密${row.cardSecret || ""}`;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
this.$Message.success("已复制到剪贴板");
});
} else {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
this.$Message.success("已复制到剪贴板");
}
},
getPromotionText(row) {
let resultText = "";
if (row && row.promotionType) {

View File

@@ -196,7 +196,7 @@ export default {
receiptModalMode: "detail",
uploadFileUrl: uploadFile,
accessToken: {},
receiptUploadData: { directoryPath: "receipt" },
receiptUploadData: { directoryPath: "default" }, // OSS 上传目录(与后端存储配置一致)
currentReceipt: {},
selectedReceiptRow: null,
searchForm: {