Merge branch 'master' into salesman

This commit is contained in:
pikachu1995@126.com
2026-08-11 15:34:46 +08:00
46 changed files with 2688 additions and 678 deletions

View File

@@ -65,7 +65,7 @@ export function addCartGoods (params) {
/**
* 创建交易
* @param client 客户端H5/移动端 PC/PC端,WECHAT_MP/小程序端,APP/移动应用端
* @param way 购物车购买CART/立即购买BUY_NOW / 积分购买POINT
* @param way 购物车CART / 立即购买BUY_NOW / 积分POINTS / 拼团PINTUAN / 砍价KANJIA
* @param remark 备注 非必填
*/
export function createTrade (data) {
@@ -81,7 +81,7 @@ export function createTrade (data) {
/**
* 选择优惠券
* @param memberCouponId 优惠券id
* @param way 购物车购买CART/立即购买BUY_NOW/ 积分购买POINT
* @param way 购物车CART / 立即购买BUY_NOW / 积分POINTS
* @param used 使用true 弃用 false
*/
export function selectCoupon (params) {
@@ -95,7 +95,7 @@ export function selectCoupon (params) {
/**
* 选择礼品卡
* @param way 购物车购买CART/立即购买BUY_NOW/拼团购买PINTUAN / 积分购买POINT
* @param way 购物车CART / 立即购买BUY_NOW / 拼团PINTUAN / 积分POINTS / 砍价KANJIA
* @param credentialId 礼品卡凭证 idused=false 且不传则清空全部
* @param used 使用 true / 弃用 false
*/

View File

@@ -33,12 +33,13 @@ export function goodsDetail (goodsId) {
});
}
// id获取商品Sku详情
// id获取商品Sku详情(支持未登录浏览,登录后可选携带 token
export function goodsSkuDetail (params) {
return request({
url: `/buyer/goods/goods/sku/${params.goodsId}/${params.skuId}`,
method: Method.GET,
needToken: true,
needToken: false,
optionalToken: true,
params
});
}

View File

@@ -24,12 +24,13 @@ export function tradeDetail (params) {
* @param payClient 调起方式 可用值APP,NATIVE,JSAPI,H5
* @param sn 订单编号
*/
export function pay (params) {
export function pay (params, config = {}) {
return request({
url: `/buyer/payment/cashier/pay/${params.paymentMethod}/${params.paymentClient}`,
needToken: true,
method: Method.GET,
params
params,
skipMessage: !!config.skipMessage,
});
}

View File

@@ -46,7 +46,8 @@ export function applyThird (params) {
export function getDetailById (id) {
return request({
url: `/buyer/store/store/get/detail/${id}`,
needToken: true,
needToken: false,
optionalToken: true,
method: Method.GET
})
}

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"
>
@@ -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;
@@ -158,6 +165,36 @@
</div>
</div>
</div>
<!-- 限时折扣 -->
<div class="item-price-row" v-if="promotionMap['DISCOUNT']">
<p>
<span class="item-price-title">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<span class="item-promotion">限时折扣</span>
<span class="item-desc-pintuan" v-if="promotionMap['DISCOUNT'].discount">
{{ promotionMap["DISCOUNT"].discount }}
</span>
</p>
</div>
<!-- 第N件优惠 -->
<div class="item-price-row" v-if="promotionMap['NTH']">
<p>
<span class="item-price-title">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
<span class="item-promotion">第N件</span>
<span class="item-desc-pintuan">
{{ promotionMap["NTH"].nthNum || promotionMap["NTH"].num }} 件优惠
</span>
</p>
</div>
<!-- 拼团 -->
<div class="item-price-row" v-if="promotionMap['PINTUAN']">
<p>
<span class="item-price-title">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</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>
@@ -222,7 +259,6 @@
</div>
</div>
</div>
<br />
<div class="add-buy-car-box">
<div class="item-select">
<div class="item-select-title">
@@ -238,14 +274,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,19 +317,35 @@
<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"
v-if="promotionMap['PINTUAN']"
:loading="loadingPintuan"
:disabled="isOutOfStock"
@click="pintuanBuy"
>拼团购买</el-button
>
<el-button
class="goods-action-btn"
:loading="loading1"
:disabled="skuDetail.quantity === 0"
:disabled="isOutOfStock"
@click="buyNow"
>立即购买</el-button
>
<el-alert
v-if="isECouponGoods"
class="e-coupon-after-sale-notice"
type="warning"
:closable="false"
show-icon
:title="eCouponAfterSaleHint()"
/>
<el-tooltip content="观看视频" v-if="skuDetail.goodsVideo">
<img class="view-video" @click="showGoodsVideo = true" :src="playIcon" alt="">
</el-tooltip>
@@ -320,6 +374,18 @@ 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,
createEmptyPromotionMap,
resolveECouponCartType,
eCouponAfterSaleHint,
E_COUPON_CART_BUY_NOW,
E_COUPON_CART_PINTUAN,
E_COUPON_CART_POINTS,
} from "@/constants/goodsType";
export default {
name: "ShowGoods",
@@ -362,15 +428,11 @@ export default {
specList: [],
}, // sku详情
goodsSpecList: this.detail.specs, // 商品spec
promotionMap: {
// 活动状态
SECKILL: null,
FULL_DISCOUNT: null,
COUPON: [],
}, // 促销活动
promotionMap: createEmptyPromotionMap(),
formatList: [], // 选择商品品类的数组
loading: false, // 立即购买loading
loading1: false, // 加入购物车loading
loadingPintuan: false,
isCollected: false, // 是否收藏
};
},
@@ -397,15 +459,32 @@ export default {
})
: [];
},
/** 卡密商品:禁加购,仅立即购买;库存读 quantityM-02 起支持促销) */
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;
},
},
methods: {
eCouponAfterSaleHint,
// 初始化video
initVideo(){
if(!this.goodsVideo ){
@@ -425,17 +504,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 +553,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,
@@ -500,20 +587,32 @@ export default {
});
},
buyNow() {
// 立即购买
this.startCheckout(E_COUPON_CART_BUY_NOW, "loading1");
},
pintuanBuy() {
this.startCheckout(E_COUPON_CART_PINTUAN, "loadingPintuan");
},
pointPay() {
this.startCheckout(E_COUPON_CART_POINTS, "loading");
},
startCheckout(action, loadingKey) {
const params = {
num: this.count,
skuId: this.skuDetail.id,
cartType: "BUY_NOW",
};
// 虚拟商品购买
if (this.skuDetail.goodsType === "VIRTUAL_GOODS") {
params.cartType = "VIRTUAL";
} else if (this.isECouponGoods) {
params.cartType = resolveECouponCartType({
routeWay: this.$route.query.way,
action,
});
}
this.loading1 = true;
this[loadingKey] = true;
addCartGoods(params)
.then((res) => {
this.loading1 = false;
this[loadingKey] = false;
if (res.success) {
this.$router.push({
path: "/pay",
@@ -524,7 +623,7 @@ export default {
}
})
.catch(() => {
this.loading1 = false;
this[loadingKey] = false;
});
},
async collect() {
@@ -602,16 +701,20 @@ export default {
});
},
promotion() {
this.promotionMap = {SECKILL: null, FULL_DISCOUNT: null, COUPON: []};
// 格式化促销活动,返回当前促销的对象
if (!this.detail.promotionMap) return false;
let keysArr = Object.keys(this.detail.promotionMap);
this.promotionMap = createEmptyPromotionMap();
if (!this.detail || !this.detail.promotionMap) return false;
const keysArr = Object.keys(this.detail.promotionMap);
if (keysArr.length === 0) return false;
for (let i = 0; i < keysArr.length; i++) {
let key = keysArr[i].split("-")[0];
const key = keysArr[i].split("-")[0];
if (key === "COUPON") {
this.promotionMap[key].push(this.detail.promotionMap[keysArr[i]]);
if (!Array.isArray(this.promotionMap.COUPON)) {
this.promotionMap.COUPON = [];
}
this.promotionMap.COUPON.push(this.detail.promotionMap[keysArr[i]]);
} else if (Object.prototype.hasOwnProperty.call(this.promotionMap, key)) {
this.promotionMap[key] = this.detail.promotionMap[keysArr[i]];
} else {
this.promotionMap[key] = this.detail.promotionMap[keysArr[i]];
}
@@ -630,8 +733,8 @@ export default {
},
},
mounted() {
// 用户登录才会判断是否收藏
if (this.Cookies.getItem("userInfo")) {
// 用户登录才会判断是否收藏(须同时有 token避免残留 userInfo 触发 401 跳转登录)
if (this.Cookies.getItem("accessToken") && this.Cookies.getItem("userInfo")) {
isCollection("GOODS", this.skuDetail.id).then((res) => {
if (res.success && res.result) {
this.isCollected = true;
@@ -958,6 +1061,22 @@ export default {
border-top: 1px dotted $border_color;
}
.e-coupon-after-sale-notice {
width: fit-content;
max-width: 420px;
flex: 0 0 auto;
padding: 4px 10px;
:deep(.el-alert__content) {
padding: 0;
}
:deep(.el-alert__title) {
font-size: 12px;
line-height: 1.5;
}
}
.add-buy-car-row {
margin-top: 25px;

View File

@@ -0,0 +1,208 @@
/**
* 卡密商品E_COUPON / 电子卡券)— 买家端公共常量与工具
*
* 与 VIRTUAL_GOODS核销型虚拟区分E_COUPON 常规促销走 BUY_NOW
* 特殊活动走 PINTUAN / POINTS / KANJIA禁止 CART / VIRTUAL。
* 需求lilishop/docs/requirements/card-key-goods-v4.md · M-02
*
* @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(quantity, 999) */
export const E_COUPON_MAX_BUY_NUM = 999;
/** 常规立即购买(秒杀/满减/券/限时/第N件等 */
export const E_COUPON_CART_BUY_NOW = "BUY_NOW";
export const E_COUPON_CART_PINTUAN = "PINTUAN";
export const E_COUPON_CART_POINTS = "POINTS";
export const E_COUPON_CART_KANJIA = "KANJIA";
export function isECoupon(goodsType) {
return goodsType === E_COUPON_GOODS_TYPE;
}
export function isECouponOrder(orderType) {
return orderType === E_COUPON_ORDER_TYPE;
}
/** 详情页提示卡密交付后不支持买家售后FR-B-04 */
export const E_COUPON_AFTER_SALE_HINT =
"卡密交付后不支持买家售后,请确认商品信息后再购买。";
export function eCouponAfterSaleHint() {
return E_COUPON_AFTER_SALE_HINT;
}
/** 可售库存E_COUPON 读 SKU quantity由卡池 syncSkuStock 同步) */
export function getECouponStock(sku) {
if (!sku) return 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);
}
/**
* 解析 E_COUPON 结算 cartType / way
* @param {object} opts
* @param {string} [opts.routeWay] 路由 query.way
* @param {'BUY_NOW'|'PINTUAN'|'POINTS'|'KANJIA'} [opts.action] 用户点击的购买入口
*/
export function resolveECouponCartType({ routeWay, action } = {}) {
if (action === E_COUPON_CART_PINTUAN) return E_COUPON_CART_PINTUAN;
if (action === E_COUPON_CART_POINTS) return E_COUPON_CART_POINTS;
if (action === E_COUPON_CART_KANJIA) return E_COUPON_CART_KANJIA;
if (action === E_COUPON_CART_BUY_NOW) return E_COUPON_CART_BUY_NOW;
const way = routeWay ? String(routeWay).toUpperCase() : "";
if (way === "KANJIA") return E_COUPON_CART_KANJIA;
if (way === "PINTUAN") return E_COUPON_CART_PINTUAN;
if (way === "POINT" || way === "POINTS") return E_COUPON_CART_POINTS;
return E_COUPON_CART_BUY_NOW;
}
/** 详情页 promotionMap 初始结构(切换 SKU 时完整重置) */
export function createEmptyPromotionMap() {
return {
SECKILL: null,
FULL_DISCOUNT: null,
COUPON: [],
DISCOUNT: null,
NTH: null,
PINTUAN: null,
KANJIA: null,
};
}
/** 卡密履约状态(与后端 CardKeyFulfillStatusEnum 一致) */
export const CARD_KEY_FULFILL_STATUS = {
PENDING: "PENDING",
DELIVERED: "DELIVERED",
FAILED: "FAILED",
NOT_APPLICABLE: "NOT_APPLICABLE",
};
export const CARD_KEY_FULFILL_STATUS_TEXT = {
PENDING: "待发放",
DELIVERED: "已发放",
FAILED: "发放失败",
NOT_APPLICABLE: "不适用",
};
export function cardKeyFulfillAlertType(status) {
const map = {
PENDING: "info",
DELIVERED: "success",
FAILED: "warning",
};
return map[status] || "info";
}
export function formatCardKeyFulfillStatus(status) {
return CARD_KEY_FULFILL_STATUS_TEXT[status] || status || "—";
}
export function resolveCardKeyFulfillMessage(line) {
if (line && line.message) return line.message;
return formatCardKeyFulfillStatus(line?.status);
}
export function isCardKeyFulfillApplicable(item) {
if (!item) return false;
const status = item.cardKeyFulfillStatus;
if (status) return status !== CARD_KEY_FULFILL_STATUS.NOT_APPLICABLE;
if (item.cardKeyDelivered != null || (item.cardKeys && item.cardKeys.length)) return true;
return item.goodsType === E_COUPON_GOODS_TYPE;
}
function normalizeCardKeyFulfillLine(source) {
const status =
source.cardKeyFulfillStatus ||
(source.cardKeyDelivered
? CARD_KEY_FULFILL_STATUS.DELIVERED
: CARD_KEY_FULFILL_STATUS.PENDING);
const cardKeys =
status === CARD_KEY_FULFILL_STATUS.DELIVERED && source.cardKeyDelivered
? source.cardKeys || []
: [];
return {
key: source.sn || source.orderSn || source.id || source.skuId,
goodsName: source.goodsName,
status,
message: source.cardKeyFulfillMessage,
cardKeys,
isGift: !!source.isGift,
};
}
/** 汇总当前订单 orderItems 中的卡密履约行(不含满赠子单;赠品卡密在赠品子单详情单独查看) */
export function collectCardKeyFulfillLines(orderItems = []) {
const lines = [];
(orderItems || []).forEach((item) => {
if (!isCardKeyFulfillApplicable(item)) return;
lines.push(normalizeCardKeyFulfillLine(item));
});
return lines;
}
export function orderHasCardKeySection({ orderItems, isECouponOrder } = {}) {
if (isECouponOrder) return true;
if (collectCardKeyFulfillLines(orderItems).length > 0) return true;
return (orderItems || []).some(
(item) =>
item.goodsType === E_COUPON_GOODS_TYPE ||
(Array.isArray(item.cardKeys) && item.cardKeys.length > 0)
);
}
export function flattenCardKeyFulfillLines(lines, withGoodsName = false) {
const rows = [];
(lines || []).forEach((line) => {
if (line.status !== CARD_KEY_FULFILL_STATUS.DELIVERED) return;
(line.cardKeys || []).forEach((ck) => {
rows.push(withGoodsName ? { ...ck, goodsName: line.goodsName } : { ...ck });
});
});
return rows;
}
/** 当前订单是否含已交付卡密(仅 orderItems不含满赠子单 */
export function orderHasCardKeyContent(orderItems) {
return flattenCardKeyFulfillLines(collectCardKeyFulfillLines(orderItems)).length > 0;
}
/** 扁平化当前订单 orderItems 卡密 */
export function flattenOrderCardKeys(orderItems, withGoodsName = false) {
const lines = collectCardKeyFulfillLines(orderItems);
if (lines.some((line) => line.status)) {
return flattenCardKeyFulfillLines(lines, withGoodsName);
}
const rows = [];
(orderItems || []).forEach((item) => {
if (!item.cardKeyDelivered || !item.cardKeys || !item.cardKeys.length) return;
item.cardKeys.forEach((ck) => {
rows.push(withGoodsName ? { ...ck, goodsName: item.goodsName } : { ...ck });
});
});
return rows;
}
/** createTrade 后是否无需进入收银台(积分单 / 0 元 / 服务端已标记) */
export function shouldSkipPaymentPage(tradeResult, way) {
if (!tradeResult) return false;
if (way === E_COUPON_CART_POINTS) return true;
if (tradeResult.needPay === false || tradeResult.paid === true) return true;
const price =
tradeResult.price != null
? Number(tradeResult.price)
: tradeResult.flowPrice != null
? Number(tradeResult.flowPrice)
: null;
if (price != null && !Number.isNaN(price) && price <= 0) return true;
return false;
}

View File

@@ -13,6 +13,7 @@ import storage from "@/plugins/storage";
import { fetchAndApplyTheme } from "@/utils/theme";
import { getThemeSetting } from "@/api/common.js";
import config from "@/config";
import { openLink } from "@/utils/buyerLogin";
const { aMapSecurityJsCode, inputMaxLength } = config;
@@ -36,12 +37,7 @@ app.config.globalProperties.Cookies = storage;
app.config.globalProperties.$inputMaxLength = inputMaxLength;
app.config.globalProperties.linkTo = function (url) {
if (!url) return;
if (url.substr(0, 1) === "/") {
window.open(location.origin + url, "_blank");
} else {
window.open(url, "_blank");
}
openLink(url, router);
};
app.config.globalProperties.connectCs = function (

View File

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

View File

@@ -140,8 +140,8 @@ export default {
if (!this.goodsMsg.data.intro) {
this.goodsMsg.data.intro = ''
}
// 判断是否收藏
if (this.Cookies.getItem("userInfo")) {
// 判断是否收藏(须同时有 token避免未登录/ token 失效时误跳登录页)
if (this.Cookies.getItem("accessToken") && this.Cookies.getItem("userInfo")) {
isStoreCollection("STORE", this.goodsMsg.data.storeId).then((res) => {
if (res.success && res.result) {
this.storeCollected = true;

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>
@@ -118,6 +125,7 @@ import { Message } from "@/utils/message";
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue';
import GoodsClassNav from "@/components/nav/GoodsClassNav";
import * as apiGoods from "@/api/goods";
import { isBuyerLoggedIn, redirectToLogin } from "@/utils/buyerLogin";
export default {
name: "GoodsList",
beforeRouteEnter(to, from, next) {
@@ -194,12 +202,15 @@ export default {
this.getGoodsList();
},
goGoodsDetail(skuId, goodsId) {
// 跳转商品详情
let routeUrl = this.$router.resolve({
const location = {
path: "/goodsDetail",
query: { skuId, goodsId },
});
window.open(routeUrl.href, "_blank");
};
if (!isBuyerLoggedIn()) {
redirectToLogin(this.$router, location);
return;
}
window.open(this.$router.resolve(location).href, "_blank");
},
goShopPage(id) {
if (!id) {
@@ -281,13 +292,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

@@ -197,6 +197,7 @@ export default {
mobile: "",
},
verifyStatus: false, // 是否图片验证通过
codeSent: false, // 是否已成功发送短信验证码
ruleInline: {
// 验证规则
username: [{required: true, message: "请输入用户名"}],
@@ -237,7 +238,15 @@ export default {
if (this.type) {
this.$refs.verify?.init();
} else {
let data = JSON.parse(JSON.stringify(this.formSms));
if (!this.verifyStatus) {
Message.warning("请先完成安全验证");
return;
}
if (!this.codeSent) {
Message.warning("请先获取短信验证码");
return;
}
const data = JSON.parse(JSON.stringify(this.formSms));
apiLogin.smsLogin(data).then((res) => {
this.hideVerify();
if (res.success) {
@@ -262,7 +271,7 @@ export default {
} else {
Message.error(res.message);
}
});
}).catch(() => {});
}
}
});
@@ -285,6 +294,7 @@ export default {
sendSms(params).then((res) => {
if (res.success) {
Message.success("验证码发送成功");
this.codeSent = true;
let that = this;
this.interval = setInterval(() => {
that.time--;
@@ -292,6 +302,7 @@ export default {
that.time = 60;
that.codeMsg = "重新发送";
that.verifyStatus = false;
that.codeSent = false;
clearInterval(that.interval);
} else {
that.codeMsg = that.time;
@@ -300,7 +311,7 @@ export default {
} else {
Message.warning(res.message);
}
});
}).catch(() => {});
}
},
verifyChange(con) {
@@ -453,11 +464,19 @@ export default {
this.$refs.formSms.resetFields();
}
this.verifyStatus = false;
this.codeSent = false;
this.hideVerify();
clearInterval(this.interval);
this.codeMsg = "发送验证码";
this.time = 60;
},
"formSms.mobile"() {
this.codeSent = false;
this.verifyStatus = false;
clearInterval(this.interval);
this.codeMsg = "发送验证码";
this.time = 60;
},
},
};
</script>

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,51 @@
</template>
<div v-else style="color: #999; margin-left: 5px">未开发票</div>
</div>
<!-- 卡密信息(当前订单 orderItems满赠赠品卡密在赠品子单详情查看 -->
<div class="order-card ecoupon-card-keys" v-if="hasCardKeySection">
<h3>卡密信息</h3>
<div
v-for="line in cardKeyFulfillLines"
:key="line.key"
class="ecoupon-fulfill-block"
>
<el-alert
v-if="line.status !== 'DELIVERED'"
:type="cardKeyFulfillAlertType(line.status)"
show-icon
:closable="false"
:title="resolveCardKeyFulfillMessage(line)"
class="mb_10"
/>
<template v-else-if="line.cardKeys.length">
<div class="ecoupon-card-keys-mobile">
<el-button type="primary" @click="cardKeyDialogVisible = true">查看卡密</el-button>
</div>
<div class="ecoupon-card-keys-pc">
<el-table border :data="lineCardKeyRows(line)" style="width: 100%">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="cardNo" label="卡号" min-width="140" show-overflow-tooltip />
<el-table-column prop="cardSecret" label="卡密" min-width="120" show-overflow-tooltip />
<el-table-column prop="allocatedTime" label="发卡时间" width="170" />
<el-table-column label="操作" width="120" align="center">
<template #default="{ row }">
<el-button v-if="row" link type="primary" @click="copyCardKey(row)">复制</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
</div>
<el-button
v-if="ecouponCardKeyRows.length"
class="mt_10 ecoupon-card-keys-pc"
size="small"
@click="copyAllCardKeys"
>
复制全部卡密
</el-button>
</div>
<!-- 订单商品 -->
<div class="goods">
<div class="shop-name">
@@ -144,8 +189,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 +215,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 +237,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 +259,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 +365,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 +392,19 @@ import {
getPackage
} from "@/api/order.js";
import { afterSaleReason, receiptDetail } from "@/api/member";
import {
isECouponOrder as checkECouponOrder,
collectCardKeyFulfillLines,
orderHasCardKeySection,
flattenOrderCardKeys,
cardKeyFulfillAlertType,
resolveCardKeyFulfillMessage,
} 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 +412,7 @@ export default {
order: {}, // 订单详情数据
progressList: [], // 订单流程
logistics: "", // 物流数据
cardKeyDialogVisible: false,
cancelParams: {
// 取消售后参数
orderSn: "",
@@ -352,7 +425,63 @@ 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;
},
/** 是否展示卡密区块(仅当前订单 orderItems */
hasCardKeySection() {
return orderHasCardKeySection({
orderItems: this.order.orderItems,
isECouponOrder: this.isECouponOrder,
});
},
cardKeyFulfillLines() {
return collectCardKeyFulfillLines(this.order.orderItems);
},
ecouponCardKeyRows() {
return flattenOrderCardKeys(this.order.orderItems, false);
},
},
methods: {
cardKeyFulfillAlertType,
resolveCardKeyFulfillMessage,
lineCardKeyRows(line) {
return line.cardKeys || [];
},
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 +789,38 @@ table {
color: $theme_color;
}
}
.ecoupon-card-keys-mobile {
display: none;
}
.ecoupon-fulfill-block + .ecoupon-fulfill-block {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed #eee;
}
.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>
@@ -150,8 +151,8 @@
</div>
</div>
</div>
<!-- 发票信息 -->
<div class="invoice">
<!-- 发票信息E_COUPON 虚拟型结算不开发票 -->
<div class="invoice" v-if="!isECouponCheckout">
<div class="card-head mt_20 mb_20">
<span class="relative">发票信息<span class="inv-tips">
<el-icon><Warning /></el-icon>开企业抬头发票须填写纳税人识别号以免影响报销
@@ -283,13 +284,13 @@
<!-- 底部支付栏 -->
<div class="order-footer">
<div class="pay ml_20" @click="pay">提交订单</div>
<div class="pay-address" v-if="addressList.length && selectedDeliverMethod === 'LOGISTICS'">
<div class="pay-address" v-if="!isVirtualLikeCheckout && addressList.length && selectedDeliverMethod === 'LOGISTICS'">
配送至{{ $filters.unitAddress(selectedAddress.consigneeAddressPath) }}
{{ selectedAddress.detail }}&nbsp;&nbsp;收货人{{
selectedAddress.name
}}&nbsp;&nbsp;{{ selectedAddress.mobile }}
</div>
<div class="pay-address" v-if="addressList.length && selectedDeliverMethod === 'SELF_PICK_UP'">
<div class="pay-address" v-if="!isVirtualLikeCheckout && addressList.length && selectedDeliverMethod === 'SELF_PICK_UP'">
自提地点{{selectedStoreAddress.address}} &nbsp;&nbsp;联系方式{{ selectedStoreAddress.mobile }}
</div>
</div>
@@ -328,6 +329,7 @@ import {
} from "@/api/cart";
import { getStoreAddress } from "@/api/shopentry.js"
import { canUseCouponList } from "@/api/member.js";
import { isECoupon, shouldSkipPaymentPage } from "@/constants/goodsType";
export default {
name: "Pay",
@@ -377,6 +379,14 @@ export default {
);
return Number.isFinite(n) ? n : 0;
},
/** 电子卡券结算:免地址、隐藏发票;优惠券/礼品卡/促销明细可用M-02 */
isECouponCheckout() {
return isECoupon(this.goodsType);
},
/** 虚拟型结算(核销虚拟 + 电子卡券):均不需要收货地址 */
isVirtualLikeCheckout() {
return this.goodsType === "VIRTUAL_GOODS" || this.isECouponCheckout;
},
},
data() {
return {
@@ -845,19 +855,24 @@ export default {
.then((res) => {
Spin.hide();
if (res.success) {
if (params.way === "POINTS") {
// 积分支付不需要跳转支付页面
const way = params.way;
if (shouldSkipPaymentPage(res.result, way)) {
this.$router.push("/payDone");
} else {
this.$router.push({
path: "/payment",
query: { orderType: "TRADE", sn: res.result.sn },
});
return;
}
this.$router.push({
path: "/payment",
query: { orderType: "TRADE", sn: res.result.sn },
});
} else if (res.message) {
Message.warning(res.message);
}
})
.catch(() => {
.catch((err) => {
Spin.hide();
if (err?.message && !err?.data?.message) {
Message.error(err.message);
}
});
},
// 优惠券可用范围

View File

@@ -37,7 +37,7 @@
</template>
<script>
import {tradeDetail, pay} from '@/api/pay.js';
import { tradeDetail, pay } from '@/api/pay.js';
import { Message, Modal } from "@/utils/message";
export default {
@@ -78,6 +78,10 @@ export default {
this.isStart = true
this.support = this.payDetail.support
this.walletValue = this.payDetail.walletValue
const price = Number(this.payDetail.price);
if (!Number.isNaN(price) && price <= 0) {
this.$router.replace('/payDone');
}
}
}).catch((err) => {
this.$router.push({name: 'MyOrder'});
@@ -102,14 +106,21 @@ export default {
title: '支付确认',
content: '确认使用余额支付吗?',
onOk: () => {
pay(params).then(res => {
if (res.success) {
Message.success(res.message)
this.$router.push('/payDone');
} else {
Message.warning(res.message)
}
})
return pay(params)
.then((res) => {
if (res.success) {
Message.success(res.message || '支付成功');
this.$router.push('/payDone');
} else {
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

@@ -11,19 +11,31 @@
</div>
</div>
<div class="content">
<div class="pay-way">{{params.paymentMethod === 'ALIPAY' ? '支付宝支付' : '微信支付'}}</div>
<div class="pay-way">{{ payWayName }}支付</div>
<div class="qrcode">
<div style="width:200px;height:200px;border:1px solid #eee;">
<vue-qr :text="qrcode" :margin="0" colorDark="#000" colorLight="#fff" :size="200"></vue-qr>
<div class="qrcode-box">
<div v-if="payLoading" class="qrcode-status">正在生成支付二维码...</div>
<div v-else-if="payError" class="qrcode-status qrcode-status--error">
<p>{{ payError }}</p>
<p class="qrcode-status-tip">您可点击重新支付重试或返回选择其他支付方式</p>
</div>
<vue-qr
v-else
:text="qrcode"
:margin="0"
colorDark="#000"
colorLight="#fff"
:size="200"
/>
</div>
<div class="intro">
<el-icon><Iphone /></el-icon> 请使用{{params.paymentMethod === 'ALIPAY' ? '支付宝' : '微信'}}扫码付款
<div v-if="!payError" class="intro">
<el-icon><Iphone /></el-icon> 请使用{{ payWayName }}扫码付款
</div>
</div>
<div class="btn-div">
<p class="mb_10">支付成功后自动跳转如未跳转请点击按钮手动跳转</p>
<div>
<el-button @click="handlePay">重新支付</el-button>
<el-button :loading="payLoading" @click="handlePay">重新支付</el-button>
<el-button type="success" @click="$router.push('/payDone')">支付成功</el-button>
</div>
</div>
@@ -41,47 +53,79 @@ export default {
components: { vueQr, Iphone },
data () {
return {
qrcode: '', // 二维码
params: this.$route.query, // 参数
interval: null, // 定时器
num: 0 // 商品数
qrcode: '',
params: this.$route.query,
interval: null,
num: 0,
payLoading: false,
payError: '',
};
},
methods: {
// 获取支付二维码
handlePay () {
const params = this.$route.query;
pay(params).then(res => {
if (res.success) {
this.qrcode = res.result;
this.num = 0;
this.interval = setInterval(this.callback, 5000);
} else {
Message.error(res.message)
}
});
computed: {
payWayName () {
return this.params.paymentMethod === 'ALIPAY' ? '支付宝' : '微信';
},
callback () { // 支付回调接口
this.num++;
if (this.num >= 7) {
},
methods: {
formatPayError (rawMessage) {
const message = (rawMessage || '').trim();
if (!message || message === '支付业务异常,请稍后重试') {
return `${this.payWayName}支付暂不可用,请稍后重试或选择其他支付方式`;
}
return message;
},
clearPayPoll () {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
let params = JSON.parse(JSON.stringify(this.$route.query));
},
handlePay () {
this.clearPayPoll();
const params = this.$route.query;
this.payLoading = true;
this.payError = '';
this.qrcode = '';
pay(params, { skipMessage: true })
.then((res) => {
this.payLoading = false;
if (res.success && res.result) {
this.qrcode = res.result;
this.num = 0;
this.interval = setInterval(this.callback, 5000);
return;
}
this.payError = this.formatPayError(res.message);
Message.error(this.payError);
})
.catch((err) => {
this.payLoading = false;
this.payError = this.formatPayError(err?.message || err?.data?.message);
Message.error(this.payError);
});
},
callback () {
this.num++;
if (this.num >= 7) {
this.clearPayPoll();
}
const params = JSON.parse(JSON.stringify(this.$route.query));
delete params.paymentMethod;
delete params.paymentClient;
payCallback(params).then(res => {
payCallback(params).then((res) => {
if (res.result) {
clearInterval(this.interval);
this.interval = null;
this.$router.push({path: '/payDone', query: {orderType: this.$route.query.orderType}});
this.clearPayPoll();
this.$router.push({ path: '/payDone', query: { orderType: this.$route.query.orderType } });
}
});
}
},
},
mounted () {
this.handlePay();
}
},
beforeUnmount () {
this.clearPayPoll();
},
};
</script>
<style scoped lang="scss">
@@ -125,6 +169,33 @@ export default {
margin: 30px 0 0 70px;
}
.qrcode-box {
width: 200px;
height: 200px;
border: 1px solid #eee;
display: flex;
align-items: center;
justify-content: center;
}
.qrcode-status {
padding: 16px;
text-align: center;
color: #666;
font-size: 14px;
line-height: 1.6;
}
.qrcode-status--error {
color: #f56c6c;
}
.qrcode-status-tip {
margin-top: 8px;
color: #909399;
font-size: 12px;
}
.intro {
background-color: #ff7674;
height: 40px;
@@ -143,6 +214,9 @@ export default {
position: absolute;
right: 20px;
top: 20px;
cursor: pointer;
color: #409eff;
text-decoration: none;
}
}
.price {
@@ -153,4 +227,7 @@ export default {
.head-right {
font-weight: bold;
}
.mb_10 {
margin-bottom: 10px;
}
</style>

View File

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

View File

@@ -18,7 +18,7 @@
@click="goGoodsDetail(item.id)"
>
<div class="goods-show-img">
<img :src="item.goodsSku.thumbnail" />
<img :src="itemThumbnail(item)" />
</div>
<div class="goods-show-price">
<span>
@@ -26,7 +26,7 @@
</span>
</div>
<div class="goods-show-detail">
<span>{{ item.goodsSku.goodsName }}</span>
<span>{{ itemGoodsName(item) }}</span>
</div>
<div class="goods-show-num">
已有<span>{{ item.commentNum || 0 }}</span>人评价
@@ -70,6 +70,13 @@ export default {
this.getCate()
},
methods: {
/** 列表 API 返回 PointsGoodsthumbnail/goodsName 在顶层);详情 API 才有 goodsSku */
itemThumbnail (item) {
return item?.goodsSku?.thumbnail || item?.thumbnail || ''
},
itemGoodsName (item) {
return item?.goodsSku?.goodsName || item?.goodsName || ''
},
getList () { // 获取列表
pointGoods(this.params).then(res => {
if (res.success) {

View File

@@ -70,7 +70,6 @@ service.interceptors.request.use(
const accessToken = Storage.getItem("accessToken");
if (accessToken && (config.needToken || config.optionalToken)) {
config.headers["accessToken"] = accessToken;
try {
const jwtData = JSON.parse(
decodeURIComponent(
@@ -80,12 +79,23 @@ service.interceptors.request.use(
)
)
);
if (jwtData.exp < Math.round(new Date() / 1000)) {
refresh({ response: { config } });
const expired = jwtData.exp < Math.round(new Date() / 1000);
if (expired) {
if (config.needToken) {
return refresh({ response: { config } });
}
// 公开接口:丢弃过期 token匿名继续请求避免浏览商品时被引导登录
Storage.removeItem("accessToken");
Storage.removeItem("refreshToken");
} else {
config.headers["accessToken"] = accessToken;
}
} catch {
Storage.removeItem("accessToken");
Storage.removeItem("refreshToken");
if (config.needToken) {
return refresh({ response: { config } });
}
}
}

View File

@@ -0,0 +1,59 @@
/**
* 买家端登录态与商品详情跳转校验
*
* @author Mike
* @date 2026-08-05
*/
import storage from "@/plugins/storage";
export function isBuyerLoggedIn() {
const rawUserInfo = storage.getItem("userInfo");
if (rawUserInfo) {
try {
return !!JSON.parse(rawUserInfo).username;
} catch {
return false;
}
}
return !!storage.getItem("accessToken");
}
export function parseInternalLink(url) {
const [path, search = ""] = url.split("?");
const query = {};
if (search) {
new URLSearchParams(search).forEach((value, key) => {
query[key] = value;
});
}
return { path, query };
}
export function isGoodsDetailPath(path) {
return path === "/goodsDetail";
}
export function redirectToLogin(router, location) {
router.push({
path: "/login",
query: {
rePath: location.path,
query: JSON.stringify(location.query || {}),
},
});
}
/** 内部链接跳转;访问商品详情前校验登录(首页装修、列表等共用) */
export function openLink(url, router) {
if (!url) return;
if (url.charAt(0) !== "/") {
window.open(url, "_blank");
return;
}
const location = parseInternalLink(url);
if (!isBuyerLoggedIn() && isGoodsDetailPath(location.path)) {
redirectToLogin(router, location);
return;
}
window.open(router.resolve(url).href, "_blank");
}