feat(gift-card): 新增提货卡类型支持

- 列表页拆分为提货卡和现金卡两个独立创建入口
- 创建页支持提货卡兑换方式配置(任选一种/全部兑换/任选N种)
- 集成 SKU 商品选择器,支持最多50个商品兑换配置
- 批次凭证页增加"已提货"状态标签及提货卡面额展示适配
- 批次凭证页新增导出卡密功能
- 重构表单布局为响应式 grid 并优化有效期选项交互样式
This commit is contained in:
pikachu1995@126.com
2026-11-06 15:20:42 +08:00
parent 0f08807f00
commit 2497f33cb4
12 changed files with 782 additions and 552 deletions

View File

@@ -0,0 +1,13 @@
/**
* 提货卡选品、试算及一次兑换接口。
* @author Bulbasaur
* @since 2026-11-05
*/
import request, { Method } from '@/plugins/request.js';
/** 查询当前会员的卡规则及候选 SKU。 */
export const getPickupCard = id => request({ url: `/buyer/promotion/pickupCard/${encodeURIComponent(id)}`, method: Method.GET, needToken: true });
/** 服务端校验商品与地址,返回零现金试算。 */
export const previewPickupCard = data => request({ url: '/buyer/promotion/pickupCard/preview', method: Method.POST, needToken: true, headers: { 'Content-Type': 'application/json' }, data });
/** 一次兑换;重试返回同一卡已创建的交易。 */
export const redeemPickupCard = data => request({ url: '/buyer/promotion/pickupCard/redeem', method: Method.POST, needToken: true, headers: { 'Content-Type': 'application/json' }, data });

View File

@@ -176,6 +176,7 @@
<el-skeleton size="large" fix v-if="spinShow"></el-skeleton>
</div>
<el-dialog v-model="cancelAvail" title="请选择取消订单原因">
<el-alert v-if="cancelIsPickup" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
<el-radio-group v-model="cancelParams.reason">
<el-radio :label="item.reason" v-for="item in cancelReason" :key="item.id">
{{ item.reason }}
@@ -227,6 +228,7 @@ const changeWay = ref(['全部', '待付款', '待发货', '待收货/使用']);
const activeOrderTab = ref(0);
const total = ref(0); // 数据总数
const spinShow = ref(false); // 加载状态
const cancelIsPickup = ref(false);
const cancelAvail = ref(false); // 取消订单modal控制
const cancelReason = ref([]); // 取消订单原因
@@ -365,6 +367,7 @@ function changePageSize (val) { // 修改页数
}
function handleCancelOrder (sn) {
// 取消订单
cancelIsPickup.value = !!orderList.value.find(order => order.sn === sn)?.pickupCardId;
cancelParams.orderSn = sn;
afterSaleReason('CANCEL').then(res => {
if (res.success) {

View File

@@ -341,6 +341,7 @@
v-model="cancelAvail"
title="请选择取消订单原因"
>
<el-alert v-if="order.order?.pickupCardId" title="此订单使用提货卡兑换,取消不退还提货权益,无法再次兑换。" type="warning" :closable="false" style="margin-bottom:16px" />
<el-radio-group
v-model="cancelParams.reason"
>

View File

@@ -1,19 +1,20 @@
<template>
<div class="wrapper my-gift-cards">
<UserCenterLayout>
<div class="gift-page-heading">
<div><h2>我的礼品卡</h2><p>查看卡片权益,兑换你的心意好礼</p></div>
</div>
<div class="bind-section mb_20">
<div class="section-title">兑换礼品卡</div>
<el-form ref="bindFormRef" :model="bindForm" :rules="bindRules" inline class="bind-form">
<el-form ref="bindFormRef" :model="bindForm" :rules="bindRules" class="bind-form">
<el-form-item label="卡号" prop="cardNo" label-width="60px">
<el-input v-model="bindForm.cardNo" placeholder="请输入卡号" maxlength="64" style="width: 220px" />
<el-input v-model="bindForm.cardNo" placeholder="请输入卡号" maxlength="64" />
</el-form-item>
<el-form-item label="兑换码" prop="cardSecret" label-width="70px">
<el-input
v-model="bindForm.cardSecret"
placeholder="请输入兑换码(卡密)"
maxlength="128"
style="width: 220px"
/>
</el-form-item>
<el-form-item label-width="0px">
@@ -25,15 +26,20 @@
<el-tabs v-model="statusTab" class="status-tabs mb_16" @tab-click="(tab) => onStatusTab(tab.paneName)">
<el-tab-pane label="可用" name="AVAILABLE" />
<el-tab-pane label="不可用" name="UNAVAILABLE" />
<el-tab-pane label="待激活" name="PENDING_ACTIVATION" />
<el-tab-pane name="PENDING_ACTIVATION">
<template #label>
<span class="pending-tab-label">待激活<span v-if="pendingCount !== null" class="pending-count">{{ pendingCount }}</span></span>
</template>
</el-tab-pane>
</el-tabs>
<div class="card-list-wrap">
<el-skeleton v-if="loading" fix />
<el-skeleton v-if="loading" :rows="5" animated />
<template v-if="!loading">
<empty v-if="list.length === 0" />
<div v-if="loadFailed" class="gift-load-error"><p>卡片加载失败,请稍后重试</p><el-button @click="getList">重新加载</el-button></div>
<el-empty v-else-if="list.length === 0" :description="emptyDescription" :image-size="100" />
<div v-else class="gift-card-grid">
<div v-for="item in list" :key="item.id || item.cardNo" class="gcc-card-item" :class="{ 'is-unavailable': statusTab === 'UNAVAILABLE' }">
<div v-for="item in list" :key="item.id || item.cardNo" class="gcc-card-item" :class="{ 'is-unavailable': statusTab === 'UNAVAILABLE', 'is-pickup': item.cardType === 'PICKUP' }">
<div class="gcc-card-header">
<div class="gcc-header-pattern" aria-hidden="true" />
<div class="gcc-header-inner">
@@ -42,17 +48,19 @@
<div class="gcc-title">{{ item.giftCardName || "礼品卡" }}</div>
<span class="gcc-status-chip">{{ statusChipLabel }}</span>
</div>
<div class="gcc-face"><span class="gcc-face-symbol">¥</span>{{ formatYuan(item.faceValue) }}</div>
<div class="gcc-cardno">{{ item.cardNo }}</div>
<div v-if="item.cardType === 'PICKUP'" class="gcc-face">兑换 <strong>{{ item.pickupCount }}</strong> 种商品</div>
<div v-else class="gcc-face"><span class="gcc-face-symbol">¥</span>{{ formatYuan(item.faceValue) }}</div>
<div class="gcc-cardno">卡号 {{ item.cardNo }}</div>
<div class="gcc-expire">{{ formatValidity(item) }}</div>
</div>
<div class="gcc-header-right">
<div class="gcc-type">现金卡</div>
<div class="gcc-expire">{{ formatValidity(item) }}</div>
<div class="gcc-type">{{ item.cardType === 'PICKUP' ? '提货卡' : '现金卡' }}</div>
</div>
</div>
</div>
<div class="gcc-card-body">
<div class="gcc-row">
<div v-if="item.cardType === 'PICKUP'" class="gcc-row">每种一件 · 一次兑完 · 免邮</div>
<div v-else class="gcc-row">
当前余额:<span class="gcc-strong" v-if="item.balance != null">¥{{ $filters.unitPrice(item.balance) }}</span><span class="gcc-strong" v-else>—</span>
</div>
@@ -112,6 +120,10 @@ const router = useRouter();
const statusTab = ref("AVAILABLE");
const loading = ref(false);
const total = ref(0);
const pendingCount = ref(null);
const loadFailed = ref(false);
let listRequestId = 0;
let pendingCountRequestId = 0;
const list = ref([]);
const params = reactive({
pageNumber: 1,
@@ -130,7 +142,6 @@ const bindRules = {
cardSecret: [{ required: true, message: "请输入兑换码", trigger: "blur" }],
};
const noticeModal = ref(false);
const serviceHotline = ref("12221111");
const activatingId = ref("");
const bindFormRef = ref(null);
@@ -153,11 +164,30 @@ const statusChipLabel = computed(() => {
const map = { AVAILABLE: "可用", UNAVAILABLE: "不可用", PENDING_ACTIVATION: "待激活" };
return map[statusTab.value] || "";
});
const emptyDescription = computed(() => ({
AVAILABLE: "暂无可用礼品卡,可输入卡号和兑换码添加",
UNAVAILABLE: "暂无不可用礼品卡",
PENDING_ACTIVATION: "暂无待激活礼品卡",
}[statusTab.value]));
onMounted(() => {
getList();
refreshPendingCount();
});
/** 复用会员分页接口的 total 获取全部待激活数量,独立于当前标签和每页条数。 */
async function refreshPendingCount() {
const requestId = ++pendingCountRequestId;
try {
const res = await getGiftCardCashMemberCardPage({ memberCardStatus: "PENDING_ACTIVATION", pageNumber: 1, pageSize: 1 });
if (requestId === pendingCountRequestId && res.success && res.result) {
pendingCount.value = Number(res.result.total) || 0;
}
} catch {
// 统计失败时保留已知数量,不将未知数量显示成零。
}
}
function formatYuan(val) {
if (val == null || val === "") {
return "—";
@@ -195,21 +225,32 @@ function onStatusTab(name) {
}
function getList() {
const requestId = ++listRequestId;
loading.value = true;
loadFailed.value = false;
const p = { ...params };
if (p.memberCardStatus) {
p.memberCardStatus = String(p.memberCardStatus);
}
getGiftCardCashMemberCardPage(p)
.then((res) => {
if (requestId !== listRequestId) return;
loading.value = false;
if (res.success && res.result) {
list.value = res.result.records || [];
total.value = res.result.total || 0;
if (p.memberCardStatus === "PENDING_ACTIVATION") {
++pendingCountRequestId;
pendingCount.value = Number(total.value);
}
} else {
loadFailed.value = true;
}
})
.catch(() => {
if (requestId !== listRequestId) return;
loading.value = false;
loadFailed.value = true;
});
}
@@ -250,6 +291,7 @@ function submitBind() {
resetBindForm();
params.pageNumber = 1;
getList();
refreshPendingCount();
}
})
.catch(() => {
@@ -274,6 +316,7 @@ function onCardPrimaryAction(item) {
if (res.success) {
Message.success(res.message || "激活成功");
getList();
refreshPendingCount();
}
})
.catch((err) => {
@@ -285,7 +328,7 @@ function onCardPrimaryAction(item) {
return;
}
if (statusTab.value === "AVAILABLE") {
router.push({ path: "/goodsList" });
router.push(item.cardType === "PICKUP" ? { name: "PickupCard", query: { id: resolveMemberCardId(item) } } : { path: "/goodsList" });
}
}
@@ -308,195 +351,125 @@ function isActivating(item) {
<style scoped lang="scss">
.my-gift-cards {
.gift-page-heading {
padding: 10px 4px 22px;
h2 { margin: 0 0 8px; font-size: 22px; font-weight: 600; color: #252833; }
p { margin: 0; font-size: 13px; color: #909399; }
}
.bind-section {
padding: 16px 18px;
border-radius: 8px;
border: 1px solid #eee;
@include white_background_color();
}
.section-title {
font-size: 15px;
font-weight: 500;
margin-bottom: 14px;
@include title_color($light_title_color);
padding: 20px 24px 24px;
border-radius: 10px;
border: 1px solid #f3e7e8;
background: #fff9f9;
}
.section-title { margin-bottom: 18px; font-size: 15px; font-weight: 600; color: #303133; }
.bind-form {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 4px 8px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
align-items: start;
gap: 16px;
:deep(.el-form-item) { margin: 0; min-width: 0; }
:deep(.el-form-item__content) { min-width: 0; }
:deep(.el-input__wrapper) { min-height: 36px; box-sizing: border-box; background: #fff; }
:deep(.el-button) { height: 36px; min-width: 88px; border-radius: 6px; }
}
.status-tabs {
:deep(.el-tabs__header) {
margin-bottom: 0;
}
margin-top: 28px;
:deep(.el-tabs__header) { margin-bottom: 0; }
:deep(.el-tabs__item) { height: 48px; font-size: 15px; }
:deep(.el-tabs__nav-wrap::after) { height: 1px; background: #eceef2; }
}
.card-list-wrap {
position: relative;
min-height: 200px;
margin-top: 24px;
.pending-tab-label { display: inline-flex; align-items: center; gap: 8px; }
.pending-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
padding: 0 6px;
height: 20px;
box-sizing: border-box;
border-radius: 10px;
color: $theme_color;
background: #fff0f1;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.card-list-wrap { position: relative; min-height: 290px; margin-top: 24px; }
.gift-load-error { padding: 64px 0; text-align: center; color: #909399; }
.gift-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
align-items: start;
}
.gcc-card-item {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
@include white_background_color();
}
.gcc-card-header {
position: relative;
min-height: 128px;
padding: 0;
background: linear-gradient(125deg, #ff9a4a 0%, #ff7729 42%, #ff8f3d 100%);
border: 1px solid #f0e3e4;
border-radius: 12px;
background: #fff;
transition: box-shadow .2s, border-color .2s;
&:hover { border-color: #edc9cd; box-shadow: 0 6px 20px rgba(100, 33, 40, .06); }
}
.gcc-card-header { position: relative; overflow: hidden; background: linear-gradient(120deg, #fff3f3, #fff9f7); }
.gcc-header-pattern {
position: absolute;
inset: 0;
opacity: 0.22;
width: 180px;
height: 180px;
right: -70px;
bottom: -110px;
border: 24px solid rgba(211, 134, 141, .07);
border-radius: 50%;
pointer-events: none;
background-image: repeating-linear-gradient(
-36deg,
transparent,
transparent 5px,
rgba(255, 255, 255, 0.45) 5px,
rgba(255, 255, 255, 0.45) 6px
);
}
.gcc-header-inner {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px 14px 14px;
color: #fff;
}
.gcc-header-left {
flex: 1;
min-width: 0;
padding-right: 8px;
}
.gcc-title {
font-size: 14px;
font-weight: 600;
line-height: 1.3;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
}
.gcc-name-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.gcc-status-chip {
padding: 0 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.2);
font-size: 11px;
line-height: 18px;
}
.gcc-face {
margin-top: 10px;
font-size: 26px;
font-weight: 700;
line-height: 1.2;
}
.gcc-face-symbol {
margin-right: 2px;
font-size: 15px;
font-weight: 500;
}
.gcc-cardno {
margin-top: 14px;
font-size: 12px;
letter-spacing: 0.02em;
opacity: 0.75;
word-break: break-all;
}
.gcc-header-right {
text-align: right;
flex-shrink: 0;
}
.gcc-type {
font-size: 18px;
font-weight: 700;
line-height: 1.2;
}
.gcc-expire {
margin-top: 10px;
font-size: 14px;
opacity: 0.9;
white-space: nowrap;
}
.gcc-card-body {
padding: 14px 14px 16px;
}
.gcc-row {
font-size: 13px;
line-height: 1.85;
@include title_color($light_title_color);
}
.gcc-muted {
color: #999;
}
.gcc-strong {
color: #ff6b22;
font-weight: 600;
}
.gcc-link {
color: $theme_color;
cursor: pointer;
}
.gcc-service {
color: #515a6e;
}
.gcc-header-inner { position: relative; display: flex; gap: 12px; padding: 22px 20px 18px; }
.gcc-header-left { flex: 1; min-width: 0; }
.gcc-name-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.gcc-title { max-width: 100%; overflow-wrap: anywhere; font-size: 15px; font-weight: 600; line-height: 1.5; color: #523338; }
.gcc-status-chip { padding: 1px 7px; border-radius: 4px; background: rgba(255,255,255,.8); color: #a9757b; font-size: 11px; line-height: 18px; }
.gcc-face { margin-top: 18px; color: #a63e4a; font-size: 30px; font-weight: 600; line-height: 1.3; }
.gcc-face strong { font-size: 32px; font-weight: 600; }
.gcc-face-symbol { margin-right: 3px; font-size: 18px; }
.gcc-cardno { margin-top: 18px; color: #96777b; font-size: 12px; overflow-wrap: anywhere; letter-spacing: .03em; }
.gcc-header-right { flex-shrink: 0; text-align: right; }
.gcc-type { padding: 3px 7px; border: 1px solid #e9cdd0; border-radius: 4px; font-size: 12px; color: #a46f75; white-space: nowrap; }
.gcc-expire { margin-top: 8px; color: #a08a8d; font-size: 12px; }
.gcc-card-body { padding: 16px 20px 20px; }
.gcc-row { min-height: 22px; color: #777e89; font-size: 13px; line-height: 22px; }
.gcc-strong { color: #a63e4a; font-weight: 600; }
.gcc-action {
display: block;
width: 100%;
margin-top: 14px;
padding: 9px 16px;
margin-top: 16px;
padding: 10px 16px;
border: none;
border-radius: 999px;
border-radius: 6px;
background: $theme_color;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
color: #fff;
background: $theme_color;
transition: opacity 0.2s, background 0.2s;
}
.gcc-action:hover:not(.is-disabled) {
opacity: 0.9;
}
.gcc-action.is-disabled {
color: #c5c8ce;
cursor: not-allowed;
background: #f7f7f7;
transition: opacity .2s;
&:hover:not(.is-disabled) { opacity: .85; }
&:focus-visible { outline: 2px solid $theme_color; outline-offset: 3px; }
&.is-disabled { color: #aaa; background: #f3f4f6; cursor: not-allowed; }
}
.is-pickup .gcc-face { font-size: 22px; }
.gcc-card-item.is-unavailable {
.gcc-card-header {
background: linear-gradient(125deg, #c9c9c9 0%, #a8a8a8 100%);
}
.gcc-strong {
color: #999;
}
border-color: #e8eaed;
.gcc-card-header { background: #f5f6f8; }
.gcc-title, .gcc-face, .gcc-strong { color: #92969e; }
.gcc-cardno, .gcc-expire, .gcc-status-chip, .gcc-type { color: #a1a5ad; }
.gcc-type { border-color: #dddfe4; }
}
.page-size {
display: flex;
justify-content: flex-end;
margin-top: 20px;
.page-size { display: flex; justify-content: flex-end; margin-top: 28px; padding-top: 20px; border-top: 1px solid #f0f1f4; }
@media (max-width: 900px) {
.bind-form { grid-template-columns: minmax(0, 1fr); gap: 22px; }
.bind-form :deep(.el-form-item__label) { width: 70px !important; }
.bind-form :deep(.el-form-item:last-child .el-form-item__content) { padding-left: 70px; }
.gift-card-grid { grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); }
}
}
.gcc-notice-text {
line-height: 1.7;
color: #515a6e;
margin-bottom: 16px;
}
.gcc-notice-footer {
text-align: right;
}
</style>
.gcc-notice-text { line-height: 1.7; color: #515a6e; margin-bottom: 16px; }
.gcc-notice-footer { text-align: right; }
</style>

View File

@@ -0,0 +1,194 @@
<!--
提货卡选品与收货地址确认,兑换成功后进入普通订单履约。
@author Bulbasaur
@since 2026-11-05
-->
<template>
<UserCenterLayout>
<div v-loading="loading" class="pickup-page">
<header class="pickup-heading">
<div><div class="pickup-eyebrow">提货卡兑换</div><h2>{{ detail.giftCardName || '选择你的好礼' }}</h2></div>
<span v-if="detail.credentialId" class="pickup-validity">提货权益</span>
</header>
<el-result v-if="error" icon="warning" :title="error"><template #extra><el-button @click="load">重新加载</el-button></template></el-result>
<template v-else-if="detail.credentialId">
<el-result v-if="completed || detail.tradeSn" icon="success" title="该提货卡已兑换"><template #extra><el-button type="primary" @click="router.push({name:'MyOrder'})">查看订单</el-button></template></el-result>
<template v-else>
<div class="pickup-rules"><strong>{{ ruleText }}</strong><span>每种一件</span><span>一次兑完</span><span>免邮</span></div>
<section class="pickup-products">
<div class="pickup-section-title"><h3>选择兑换商品</h3><span>共 {{ detail.goods?.length || 0 }} 种商品 · 已选 <b>{{ selected.length }}</b> / {{ detail.pickupCount }} 种</span></div>
<el-checkbox-group v-model="selected" :max="detail.pickupCount" :disabled="busy || detail.pickupMode === 'ALL'" aria-label="可兑换商品">
<div class="pickup-grid">
<el-checkbox v-for="goods in detail.goods" :key="goods.id" :value="goods.id" :disabled="!goods.available" class="pickup-item" :class="{'is-selected': selected.includes(goods.id), 'is-unavailable': !goods.available}">
<div class="pickup-image"><el-image :src="goods.thumbnail" fit="contain" :alt="goods.goodsName"><template #error><span class="pickup-image-fallback">暂无图片</span></template></el-image></div>
<div class="pickup-goods-info">
<strong class="pickup-goods-name" :title="goods.goodsName">{{ goods.goodsName }}</strong>
<div class="pickup-specs" :title="specsText(goods.specs)">{{ specsText(goods.specs) }}</div>
<div class="pickup-store">{{ goods.storeName }}</div>
<div v-if="!goods.available" class="pickup-goods-unavailable">暂不可兑换</div>
</div>
</el-checkbox>
</div>
</el-checkbox-group>
</section>
<section class="pickup-address">
<div class="pickup-section-title"><h3>收货地址</h3><div><el-button link :disabled="busy" @click="router.push({name:'MyAddress'})">管理地址</el-button><el-button link :disabled="busy" @click="loadAddresses">刷新</el-button></div></div>
<el-select v-model="addressId" placeholder="请选择收货地址" :disabled="busy" aria-label="收货地址" class="pickup-address-select">
<el-option v-for="address in addresses" :key="address.id" :value="address.id" :label="addressText(address)" />
</el-select>
<p v-if="!addresses.length" class="pickup-address-empty">还没有收货地址,请先添加地址后刷新。</p>
</section>
<footer class="pickup-checkout">
<div class="pickup-selection"><strong>已选 <b>{{ selected.length }}</b> / {{ detail.pickupCount }} 种商品</strong><span>{{ selectionHint }}</span></div>
<div class="pickup-submit"><div class="pickup-payable">实付 <strong>¥0<span>.00</span></strong><small>免运费</small></div><el-button type="primary" :loading="busy" :disabled="!canSubmit" @click="submit">确认兑换</el-button></div>
</footer>
<p class="pickup-terms">兑换成功后取消不退权益,不支持退款或退货。</p>
</template>
</template>
</div>
</UserCenterLayout>
</template>
<script setup>
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { memberAddress } from '@/api/address';
import storage from '@/plugins/storage';
import { getPickupCard, previewPickupCard, redeemPickupCard } from '@/api/pickupCard';
const route = useRoute(), router = useRouter();
const detail = ref({}), selected = ref([]), addresses = ref([]), addressId = ref('');
const loading = ref(false), busy = ref(false), error = ref(''), completed = ref(false);
let active = true, requestVersion = 0;
const owner = storage.getItem('userInfo') || '';
/** 账号切换后丢弃旧请求结果,避免展示上一个账号的卡和地址。 */
function sameOwner() { return active && owner === (storage.getItem('userInfo') || ''); }
const canSubmit = computed(() => selected.value.length === detail.value.pickupCount && !!addressId.value && !loading.value && !detail.value.goods?.some(g => selected.value.includes(g.id) && !g.available));
const ruleText = computed(() => detail.value.pickupMode === 'ALL' ? '兑换以下全部商品' : `从以下商品任选 ${detail.value.pickupCount || 1} 种`);
const selectionHint = computed(() => {
const remaining = (detail.value.pickupCount || 0) - selected.value.length;
if (remaining > 0) return `还需选择 ${remaining} 种商品`;
return addressId.value ? '商品已选齐,请核对收货地址' : '请选择收货地址';
});
/** 兼容键值及旧数组规格,过滤图片元数据,避免展示原始 JSON。 */
function specsText(value) {
try {
const specs = typeof value === 'string' ? JSON.parse(value || '{}') : value;
const values = Array.isArray(specs) ? specs.filter(s => s && s.specName !== 'images').map(s => s.specValue) : Object.entries(specs || {}).filter(([key]) => key !== 'images').map(([, val]) => val);
return values.filter(v => typeof v === 'string' || typeof v === 'number').join(' / ') || '默认规格';
} catch { return typeof value === 'string' && !/^[\s]*[\[{]/.test(value) ? value : '默认规格'; }
}
/** 地址仅用于展示,提交后仍由服务端校验当前会员归属。 */
function addressText(address) { return `${address.name} ${address.mobile} ${address.consigneeAddressPath || ''} ${address.detail || ''}`; }
/** 刷新地址,保留仍存在的选择。 */
async function loadAddresses() {
const response = await memberAddress();
if (!sameOwner()) return;
if (!response.success) throw new Error(response.message || '地址加载失败');
addresses.value = response.result?.records || response.result || [];
if (!addresses.value.some(a => a.id === addressId.value)) addressId.value = (addresses.value.find(a => a.isDefault) || addresses.value[0])?.id || '';
}
/** 加载卡详情;离页或新请求发起后丢弃旧响应。 */
async function load() {
const version = ++requestVersion;
loading.value = true; error.value = '';
try {
const response = await getPickupCard(route.query.id);
if (!sameOwner() || version !== requestVersion) return;
if (!response.success) throw new Error(response.message || '提货卡加载失败');
detail.value = response.result;
selected.value = detail.value.pickupMode === 'ALL' ? detail.value.goods.map(g => g.id) : [];
await loadAddresses();
} catch (e) { if (sameOwner() && version === requestVersion) error.value = e.message || '加载失败,请重试'; }
finally { if (sameOwner() && version === requestVersion) loading.value = false; }
}
/** 先服务端试算再确认核销,网络异常保留选项,允许幂等重试。 */
async function submit() {
if (busy.value || !canSubmit.value || !sameOwner()) return;
busy.value = true;
const payload = { credentialId: detail.value.credentialId, skuIds: [...selected.value], addressId: addressId.value };
try {
const preview = await previewPickupCard(payload);
if (!sameOwner()) return;
if (!preview.success) throw new Error(preview.message || '兑换校验失败');
await ElMessageBox.confirm(`本次兑换 ${selected.value.length} 种商品,每种一件,免邮且实付为零。兑换后取消不退权益,不支持退款或退货。`, '确认兑换', { confirmButtonText: '确认兑换', cancelButtonText: '返回检查' });
if (!sameOwner()) return;
const response = await redeemPickupCard(payload);
if (!sameOwner()) return;
if (!response.success) throw new Error(response.message || '兑换失败');
completed.value = true;
} catch (e) {
if (sameOwner() && e !== 'cancel' && e !== 'close') {
// 网络超时可能发生在成功提交后,查询原卡确认结果,不重复创建订单。
try { const status = await getPickupCard(payload.credentialId); if (sameOwner() && status.success && status.result?.tradeSn) { detail.value = status.result; completed.value = true; return; } } catch { /* 保留原错误供重试 */ }
ElMessage.error(e.message || '兑换结果待确认,请重试或查看订单');
}
}
finally { if (sameOwner()) busy.value = false; }
}
onMounted(load);
onBeforeUnmount(() => { active = false; requestVersion++; });
</script>
<style scoped lang="scss">
.pickup-page { padding: 10px 4px 0; color: #303133; }
.pickup-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 20px; }
.pickup-eyebrow { margin-bottom: 8px; color: #909399; font-size: 12px; }
.pickup-heading h2 { margin: 0; color: #252833; font-size: 22px; font-weight: 600; overflow-wrap: anywhere; }
.pickup-validity { padding: 5px 10px; border: 1px solid #eee; border-radius: 4px; font-size: 12px; color: #777e89; white-space: nowrap; }
.pickup-rules { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 20px; padding: 16px 18px; border-radius: 6px; background: #f7f8fa; font-size: 13px; color: #858b95; }
.pickup-rules strong { color: #434953; font-weight: 500; }
.pickup-products { margin-top: 28px; }
.pickup-section-title { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
.pickup-section-title h3 { margin: 0; font-size: 16px; font-weight: 600; }
.pickup-section-title > span { color: #909399; font-size: 13px; }
.pickup-section-title b { color: $theme_color; font-weight: 500; }
.pickup-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; max-height: 570px; overflow-y: auto; padding: 2px 4px 2px 2px; }
.pickup-item {
position: relative;
display: block;
width: 100%;
height: auto;
margin: 0;
padding: 0;
box-sizing: border-box;
border: 1px solid #e9ebef;
border-radius: 8px;
overflow: hidden;
background: #fff;
transition: border-color .2s, box-shadow .2s;
&:hover:not(.is-unavailable) { border-color: #e7acb1; }
&.is-selected { border-color: $theme_color; box-shadow: 0 0 0 1px $theme_color; }
&.is-unavailable { background: #f8f9fa; }
:deep(.el-checkbox__input) { position: absolute; z-index: 1; top: 12px; right: 12px; }
:deep(.el-checkbox__label) { display: block; padding: 0; width: 100%; white-space: normal; color: inherit; }
}
.pickup-image { display: flex; justify-content: center; align-items: center; height: 140px; margin: 14px 20px 0; background: #fff; }
.pickup-image :deep(.el-image) { width: 100%; height: 100%; }
.pickup-image-fallback { display: flex; height: 100%; align-items: center; justify-content: center; color: #a8abb2; background: #f7f8fa; font-size: 12px; }
.pickup-goods-info { padding: 14px; }
.pickup-goods-name { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; min-height: 40px; line-height: 20px; font-size: 14px; font-weight: 500; color: #303133; }
.pickup-specs { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 8px; color: #6b7280; font-size: 12px; line-height: 18px; }
.pickup-store { margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #a0a5ae; font-size: 12px; line-height: 18px; }
.pickup-goods-unavailable { margin-top: 8px; color: #909399; font-size: 12px; }
.is-unavailable .pickup-image { opacity: .5; }
.pickup-address { margin-top: 28px; padding-top: 24px; border-top: 1px solid #eee; }
.pickup-address-select { width: 100%; }
.pickup-address-select :deep(.el-select__wrapper) { min-height: 42px; }
.pickup-address-empty { color: #909399; font-size: 13px; }
.pickup-checkout { position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-top: 24px; padding: 20px 0; border-top: 1px solid #eee; background: #fff; }
.pickup-selection { display: flex; flex-direction: column; gap: 8px; font-size: 14px; }
.pickup-selection strong { font-weight: 500; }
.pickup-selection b { color: $theme_color; font-weight: 600; }
.pickup-selection > span { color: #909399; font-size: 12px; }
.pickup-submit { display: flex; align-items: center; gap: 24px; }
.pickup-payable { font-size: 13px; color: #606266; }
.pickup-payable strong { margin-left: 6px; color: $theme_color; font-size: 26px; font-weight: 600; }
.pickup-payable strong span { font-size: 16px; }
.pickup-payable small { display: block; margin-top: 4px; text-align: right; font-size: 12px; color: #909399; }
.pickup-submit :deep(.el-button) { min-width: 136px; height: 42px; border-radius: 6px; font-size: 15px; }
.pickup-terms { margin: 0; padding-bottom: 6px; color: #a0a5ae; font-size: 12px; line-height: 1.6; }
@media (max-width: 900px) { .pickup-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .pickup-grid { grid-template-columns: minmax(0, 1fr); } .pickup-checkout { flex-wrap: wrap; } .pickup-submit { width: 100%; justify-content: space-between; } .pickup-section-title { flex-wrap: wrap; } }
</style>

View File

@@ -200,6 +200,12 @@ export const router = createRouter({
component: MoneyManagement,
meta: { title: "我的余额" },
},
{
path: "PickupCard",
name: "PickupCard",
component: () => import("@/pages/home/userCenter/PickupCard.vue"),
meta: { title: "提货卡兑换" },
},
{
path: "MyGiftCards",
name: "MyGiftCards",