mirror of
https://gitee.com/beijing_hongye_huicheng/lilishop-ui.git
synced 2026-11-06 17:09:08 +08:00
feat(gift-card): 新增提货卡类型支持
- 列表页拆分为提货卡和现金卡两个独立创建入口 - 创建页支持提货卡兑换方式配置(任选一种/全部兑换/任选N种) - 集成 SKU 商品选择器,支持最多50个商品兑换配置 - 批次凭证页增加"已提货"状态标签及提货卡面额展示适配 - 批次凭证页新增导出卡密功能 - 重构表单布局为响应式 grid 并优化有效期选项交互样式
This commit is contained in:
13
buyer/src/api/pickupCard.js
Normal file
13
buyer/src/api/pickupCard.js
Normal 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 });
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
.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: 200px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
.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%);
|
||||
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; }
|
||||
}
|
||||
.gcc-strong {
|
||||
color: #999;
|
||||
.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)); }
|
||||
}
|
||||
}
|
||||
.page-size {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
.gcc-notice-text {
|
||||
line-height: 1.7;
|
||||
color: #515a6e;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.gcc-notice-footer {
|
||||
text-align: right;
|
||||
}
|
||||
.gcc-notice-text { line-height: 1.7; color: #515a6e; margin-bottom: 16px; }
|
||||
.gcc-notice-footer { text-align: right; }
|
||||
</style>
|
||||
194
buyer/src/pages/home/userCenter/PickupCard.vue
Normal file
194
buyer/src/pages/home/userCenter/PickupCard.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -319,6 +319,11 @@ export const giftCardCashBatchCreate = (activityId, params) => {
|
||||
);
|
||||
};
|
||||
|
||||
/** 导出指定制卡批次的全部卡号、卡密,不受页面筛选和分页限制。 */
|
||||
export const exportGiftCardCashCreateBatch = (createBatchId) => {
|
||||
return getRequest(`/promotion/giftCardCash/createBatch/${encodeURIComponent(createBatchId)}/export`, {}, "blob");
|
||||
};
|
||||
|
||||
// 下载批量发卡 Excel 模板
|
||||
export const downloadGiftCardCashIssueTemplate = () => {
|
||||
return getRequest("/promotion/giftCardCash/issue/template", {}, "blob");
|
||||
|
||||
44
manager/src/utils/specs.js
Normal file
44
manager/src/utils/specs.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 商品规格展示:兼容简要规格、键值 JSON 和旧数组结构,过滤图片数据,不展示原始 JSON。
|
||||
* @author Bulbasaur
|
||||
* @since 2026-11-06
|
||||
*/
|
||||
|
||||
/** 将规格中的简单值转为文字,图片对象等复杂数据不参与规格展示。 */
|
||||
function specValueText(value) {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === "boolean") return String(value);
|
||||
if (Array.isArray(value)) return value.filter(item => !item || typeof item !== "object").map(specValueText).filter(Boolean).join("、");
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成可读的 SKU 规格,优先复用接口已有的 simpleSpecs;无有效规格时显示默认规格。
|
||||
* @param {string|object|Array|null} specs SKU 原始规格
|
||||
* @param {string|null} simpleSpecs 接口提供的简要规格
|
||||
* @returns {string} 排除 images 后的规格值,以斜线分隔
|
||||
*/
|
||||
export function formatSkuSpecs(specs, simpleSpecs) {
|
||||
if (typeof simpleSpecs === "string" && simpleSpecs.trim()) return simpleSpecs.trim();
|
||||
let parsed = specs;
|
||||
if (typeof parsed === "string") {
|
||||
const text = parsed.trim();
|
||||
if (!text) return "默认规格";
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return /^[\[{]/.test(text) ? "默认规格" : text;
|
||||
}
|
||||
}
|
||||
let values;
|
||||
if (Array.isArray(parsed)) {
|
||||
values = parsed.filter(item => item && typeof item === "object" && item.specName !== "images")
|
||||
.map(item => specValueText(item.specValue));
|
||||
} else if (parsed && typeof parsed === "object") {
|
||||
values = Object.entries(parsed).filter(([name]) => name !== "images").map(([, value]) => specValueText(value));
|
||||
} else {
|
||||
values = [specValueText(parsed)];
|
||||
}
|
||||
return values.filter(Boolean).join(" / ") || "默认规格";
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="gift-card-activity-page">
|
||||
<!-- 顶部标题与返回栏 -->
|
||||
<el-card shadow="never" class="header-card">
|
||||
<div class="gift-card-activity-page" :class="{ 'pickup-page': form.cardType === 'PICKUP', 'view-page': onlyView }">
|
||||
<!-- 礼品卡基础信息卡片 -->
|
||||
<el-card shadow="never" v-loading="detailLoading" class="info-card">
|
||||
<template #header>
|
||||
<div class="page-header-row">
|
||||
<div class="header-left">
|
||||
<div class="header-title">
|
||||
@@ -20,19 +21,8 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button @click="closeCurrentPage">返回礼品卡列表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 礼品卡基础信息卡片 -->
|
||||
<el-card shadow="never" v-loading="detailLoading" class="info-card mt_10">
|
||||
<div class="section-header">
|
||||
<span class="section-indicator"></span>
|
||||
<span class="section-title">礼品卡配置信息</span>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<!-- 查看模式:以结构化描述列表呈现 -->
|
||||
<template v-if="onlyView">
|
||||
<el-descriptions :column="2" border class="custom-descriptions">
|
||||
@@ -46,7 +36,7 @@
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="礼品卡面值">
|
||||
<el-descriptions-item v-if="form.cardType === 'CASH'" label="礼品卡面值">
|
||||
<span class="price-highlight font-mono font-semibold">
|
||||
{{ form.faceValue ? $filters.unitPrice(form.faceValue, "¥") : "-" }}
|
||||
</span>
|
||||
@@ -56,7 +46,6 @@
|
||||
<span class="font-mono text-dark font-semibold">
|
||||
{{ form.stockTotal != null ? `${form.stockTotal} 张` : "-" }}
|
||||
</span>
|
||||
<span class="hint-inline">(最多可制发凭证张数)</span>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="有效期限" :span="2">
|
||||
@@ -71,6 +60,17 @@
|
||||
<span class="font-mono text-secondary">{{ route.query.id }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template v-if="form.cardType === 'PICKUP'">
|
||||
<p>兑换规则:{{ {ONE:'任选一种',ALL:'全部兑换',ANY:'任选 ' + form.pickupCount + ' 种'}[form.pickupMode] }},每种一件</p>
|
||||
<el-table :data="pickupGoods">
|
||||
<el-table-column prop="id" label="SKU ID" />
|
||||
<el-table-column prop="goodsName" label="商品" />
|
||||
<el-table-column label="规格" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ formatSkuSpecs(row.specs, row.simpleSpecs) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="storeName" label="店铺" />
|
||||
</el-table>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 编辑 / 新增模式:表单输入 -->
|
||||
@@ -78,11 +78,12 @@
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
label-width="120px"
|
||||
label-position="top"
|
||||
size="large"
|
||||
:rules="formRule"
|
||||
class="edit-form"
|
||||
>
|
||||
<el-form-item label="礼品卡名称" prop="giftCardName">
|
||||
<el-form-item label="礼品卡名称" prop="giftCardName" class="full-row">
|
||||
<el-input
|
||||
v-model="form.giftCardName"
|
||||
type="text"
|
||||
@@ -90,65 +91,82 @@
|
||||
clearable
|
||||
maxlength="64"
|
||||
show-word-limit
|
||||
style="width: 380px"
|
||||
class="form-control"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="礼品卡类型" prop="cardType">
|
||||
<el-radio-group v-model="form.cardType">
|
||||
<el-radio value="CASH">现金卡</el-radio>
|
||||
<el-radio value="PICKUP" disabled>提货卡(暂未开放)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="面值" prop="faceValue">
|
||||
<el-form-item v-if="form.cardType === 'CASH'" label="面值" prop="faceValue">
|
||||
<el-input
|
||||
v-model="form.faceValue"
|
||||
:disabled="coreLocked"
|
||||
type="number"
|
||||
placeholder="请填写面值金额"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
class="form-control"
|
||||
>
|
||||
<template #append>元</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="总库存" prop="stockTotal">
|
||||
<template v-if="form.cardType === 'PICKUP'">
|
||||
<el-form-item label="兑换方式" required class="full-row">
|
||||
<el-radio-group v-model="form.pickupMode" :disabled="coreLocked">
|
||||
<el-radio value="ONE">任选一种</el-radio>
|
||||
<el-radio value="ALL">全部兑换</el-radio>
|
||||
<el-radio value="ANY">任选 N 种</el-radio>
|
||||
</el-radio-group>
|
||||
<el-input-number v-if="form.pickupMode === 'ANY'" v-model="form.pickupCount" :min="1" :max="50" :precision="0" :controls="false" :disabled="coreLocked" style="width:100px;margin-left:12px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="兑换商品" required class="full-row">
|
||||
<el-button :disabled="coreLocked" @click="openPickupGoods">选择商品</el-button>
|
||||
<span class="input-tip-text">最多 50 个 SKU,每种一件,每卡一次兑完</span>
|
||||
</el-form-item>
|
||||
<el-table :data="pickupGoods" border class="full-row" style="margin-bottom:4px">
|
||||
<el-table-column prop="id" label="SKU ID" min-width="160" />
|
||||
<el-table-column prop="goodsName" label="商品名称" min-width="220" />
|
||||
<el-table-column label="规格" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ formatSkuSpecs(row.specs, row.simpleSpecs) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="storeName" label="店铺" min-width="140" />
|
||||
<el-table-column label="销售价(元)" width="130"><template #default="{row}">{{ row.price }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="90"><template #default="{row}"><el-button link :disabled="coreLocked" @click="pickupGoods = pickupGoods.filter(g => g.id !== row.id)">移除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<el-form-item label="总库存" prop="stockTotal" class="stock-form-item">
|
||||
<div class="form-control unit-control">
|
||||
<el-input-number
|
||||
v-model="form.stockTotal"
|
||||
:min="1"
|
||||
:precision="0"
|
||||
placeholder="最多可制发凭证张数"
|
||||
style="width: 220px"
|
||||
:controls="false"
|
||||
placeholder="请输入总库存"
|
||||
/>
|
||||
<span class="input-tip-text">张(最多可制发凭证张数)</span>
|
||||
<span class="unit-addon">张</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="有效期限" required class="validity-form-item">
|
||||
<el-radio-group v-model="form.validityType" class="validity-radio-group">
|
||||
<div class="validity-row">
|
||||
<el-form-item label="有效期限" required class="validity-form-item full-row">
|
||||
<div class="validity-control">
|
||||
<el-radio-group v-model="form.validityType" :disabled="coreLocked" class="validity-radio-group">
|
||||
<el-radio value="LONG_TERM">长期有效</el-radio>
|
||||
</div>
|
||||
|
||||
<div class="validity-row">
|
||||
<el-radio value="AFTER_ACTIVATE_MONTHS">
|
||||
<span class="radio-inline-text">自激活后</span>
|
||||
<el-radio value="AFTER_ACTIVATE_MONTHS">激活后有效</el-radio>
|
||||
<el-radio value="FIXED_UNTIL">指定到期</el-radio>
|
||||
</el-radio-group>
|
||||
<div v-if="form.validityType === 'AFTER_ACTIVATE_MONTHS'" class="validity-detail">
|
||||
<div class="unit-control month-control">
|
||||
<el-input-number
|
||||
v-model="form.validMonthsAfterActivate"
|
||||
:min="1"
|
||||
:max="99"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
:disabled="coreLocked"
|
||||
placeholder="1-99"
|
||||
:disabled="form.validityType !== 'AFTER_ACTIVATE_MONTHS'"
|
||||
class="validity-input-num"
|
||||
/>
|
||||
<span class="radio-inline-text">个月内有效</span>
|
||||
</el-radio>
|
||||
<span class="unit-addon">个月</span>
|
||||
</div>
|
||||
|
||||
<div class="validity-row">
|
||||
<el-radio value="FIXED_UNTIL">
|
||||
<span class="radio-inline-text">指定有效期至</span>
|
||||
</div>
|
||||
<div v-else-if="form.validityType === 'FIXED_UNTIL'" class="validity-detail">
|
||||
<el-date-picker
|
||||
v-model="form.validEndTime"
|
||||
type="datetime"
|
||||
@@ -156,34 +174,29 @@
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
placeholder="选择有效截止时间"
|
||||
clearable
|
||||
:disabled="form.validityType !== 'FIXED_UNTIL'"
|
||||
class="validity-date-picker"
|
||||
style="width: 220px"
|
||||
:disabled="coreLocked"
|
||||
class="validity-date-control"
|
||||
/>
|
||||
</el-radio>
|
||||
</div>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<el-card shadow="never" class="footer-card mt_10">
|
||||
<div class="footer-actions">
|
||||
<el-button @click="closeCurrentPage">
|
||||
{{ onlyView ? "返回" : "取消" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="!onlyView"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ isEditMode ? "保存修改" : "提交创建" }}
|
||||
{{ isEditMode ? "保存修改" : (form.cardType === "PICKUP" ? "创建提货卡" : "创建现金卡") }}
|
||||
</el-button>
|
||||
<el-button @click="closeCurrentPage">{{ onlyView ? "返回" : "取消" }}</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<lili-dialog ref="pickupSelector" :query-params="{ goodsType: 'PHYSICAL_GOODS', marketEnable: 'UPPER', authFlag: 'PASS' }" @selectedGoodsData="confirmPickupGoods" />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -197,8 +210,10 @@ import {
|
||||
} from "@/api/promotion";
|
||||
import { regular } from "@/utils";
|
||||
import { unixToDate } from "@/utils/filters";
|
||||
import { formatSkuSpecs } from "@/utils/specs";
|
||||
import { Message } from "@/utils/message";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import LiliDialog from "@/components/lili-dialog/index.vue";
|
||||
|
||||
defineOptions({ name: "addGiftCardCashActivity" });
|
||||
|
||||
@@ -212,7 +227,9 @@ const activityDetail = ref({});
|
||||
|
||||
const form = reactive({
|
||||
giftCardName: "",
|
||||
cardType: "CASH",
|
||||
cardType: route.query.cardType === "PICKUP" ? "PICKUP" : "CASH",
|
||||
pickupMode: "ONE",
|
||||
pickupCount: 1,
|
||||
faceValue: "",
|
||||
stockTotal: null,
|
||||
validityType: "LONG_TERM",
|
||||
@@ -221,6 +238,19 @@ const form = reactive({
|
||||
});
|
||||
|
||||
const formRef = ref(null);
|
||||
const pickupGoods = ref([]);
|
||||
const pickupSelector = ref(null);
|
||||
const coreLocked = computed(() => Number(activityDetail.value.stockTotal || 0) - Number(activityDetail.value.remainingStock ?? activityDetail.value.stockTotal ?? 0) > 0);
|
||||
/** 复用公共 SKU 选择器,取消不改变已确认商品。 */
|
||||
function openPickupGoods() {
|
||||
pickupSelector.value.goodsData = pickupGoods.value.map(g => ({ ...g }));
|
||||
pickupSelector.value.open('goods');
|
||||
}
|
||||
/** 选择确认时校验数量并按 SKU 去重,服务端仍执行完整校验。 */
|
||||
function confirmPickupGoods(rows) {
|
||||
if (rows.length > 50) return Message.warning('最多选择 50 个 SKU');
|
||||
pickupGoods.value = Array.from(new Map(rows.map(row => [String(row.id || row.skuId), { ...row, id: String(row.id || row.skuId) }])).values());
|
||||
}
|
||||
|
||||
const onlyView = computed(() => {
|
||||
const q = route.query.onlyView;
|
||||
@@ -232,13 +262,14 @@ const isEditMode = computed(() => {
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const name = form.cardType === "PICKUP" ? "提货卡" : "现金卡";
|
||||
if (onlyView.value) {
|
||||
return "查看礼品卡";
|
||||
return `查看${name}`;
|
||||
}
|
||||
if (isEditMode.value) {
|
||||
return "编辑礼品卡";
|
||||
return `编辑${name}`;
|
||||
}
|
||||
return "添加礼品卡";
|
||||
return `添加${name}`;
|
||||
});
|
||||
|
||||
const validityDisplayText = computed(() => {
|
||||
@@ -288,7 +319,7 @@ const formRule = {
|
||||
{ required: true, message: "活动名称不能为空", trigger: "blur" },
|
||||
{ type: "string", max: 64, message: "活动名称过长", trigger: "blur" },
|
||||
],
|
||||
faceValue: [{ required: true, message: "请输入面额" }, { validator: checkFaceValue }],
|
||||
faceValue: [{ validator: (rule, value, cb) => form.cardType === "PICKUP" ? cb() : checkFaceValue(rule, value, cb) }],
|
||||
stockTotal: [{ required: true, validator: validateStock, trigger: "blur" }],
|
||||
};
|
||||
|
||||
@@ -314,6 +345,8 @@ const loadDetail = (id) => {
|
||||
const d = res.result;
|
||||
form.giftCardName = d.giftCardName || "";
|
||||
form.cardType = d.cardType || "CASH";
|
||||
form.pickupMode = d.pickupMode || "ONE"; form.pickupCount = d.pickupCount || 1;
|
||||
pickupGoods.value = d.pickupGoods || [];
|
||||
form.faceValue =
|
||||
d.faceValue !== null && d.faceValue !== undefined ? String(d.faceValue) : "";
|
||||
form.stockTotal =
|
||||
@@ -370,7 +403,10 @@ const buildPayload = () => {
|
||||
const payload = {
|
||||
giftCardName: (form.giftCardName || "").trim(),
|
||||
cardType: form.cardType || "CASH",
|
||||
faceValue: parseFloat(form.faceValue),
|
||||
faceValue: form.cardType === "PICKUP" ? 0 : parseFloat(form.faceValue),
|
||||
pickupMode: form.cardType === "PICKUP" ? form.pickupMode : null,
|
||||
pickupCount: form.pickupMode === "ALL" ? pickupGoods.value.length : form.pickupMode === "ONE" ? 1 : form.pickupCount,
|
||||
pickupSkuIds: form.cardType === "PICKUP" ? pickupGoods.value.map(g => g.id) : null,
|
||||
validityType: form.validityType || "LONG_TERM",
|
||||
stockTotal: form.stockTotal,
|
||||
};
|
||||
@@ -397,6 +433,9 @@ const handleSubmit = () => {
|
||||
if (!validateByValidityType()) {
|
||||
return;
|
||||
}
|
||||
if (form.cardType === 'PICKUP' && (!pickupGoods.value.length || (form.pickupMode === 'ANY' && (!Number.isInteger(form.pickupCount) || form.pickupCount < 1 || form.pickupCount > pickupGoods.value.length)))) {
|
||||
Message.warning('请选择兑换商品,并填写不超过商品种数的兑换数量'); return;
|
||||
}
|
||||
submitLoading.value = true;
|
||||
const req = isEditMode.value
|
||||
? updateGiftCardCashActivity(route.query.id, buildPayload())
|
||||
@@ -426,6 +465,13 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.query.cardType,
|
||||
(type) => {
|
||||
if (!route.query.id) form.cardType = type === "PICKUP" ? "PICKUP" : "CASH";
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.query.id,
|
||||
(id) => {
|
||||
@@ -444,180 +490,59 @@ onMounted(() => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.gift-card-activity-page {
|
||||
padding: 16px 20px;
|
||||
background-color: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
padding: 28px 24px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.mt_10 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-dark {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.header-card {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.page-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.header-left {
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1f2d3d;
|
||||
|
||||
.mode-tag {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.activity-id {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-card {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.section-indicator {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background-color: #409eff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-descriptions {
|
||||
:deep(.el-descriptions__label) {
|
||||
width: 130px;
|
||||
background-color: #f8f9fb;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__content) {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.price-highlight {
|
||||
color: #f56c6c;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.hint-inline {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
padding: 8px 12px;
|
||||
|
||||
.input-tip-text {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.validity-radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
.validity-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
:deep(.el-radio) {
|
||||
margin-right: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.el-radio__label) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.radio-inline-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.validity-input-num {
|
||||
width: 130px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.validity-date-picker {
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-card {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
.info-card { width: 100%; max-width: 1040px; margin: 0 auto; border: 1px solid var(--el-border-color-extra-light); border-radius: 8px; }
|
||||
.info-card :deep(.el-card__header) { padding: 30px 40px 0; border-bottom: 0; }
|
||||
.info-card :deep(.el-card__body) { padding: 28px 40px 32px; }
|
||||
.page-header-row { max-width: 640px; margin: 0 auto; }
|
||||
.header-title { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; color: var(--el-text-color-primary); font-size: 20px; font-weight: 600; line-height: 28px; }
|
||||
.mode-tag { font-weight: normal; }
|
||||
.activity-id { color: var(--el-text-color-secondary); font-size: 12px; font-weight: normal; }
|
||||
.edit-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px 20px; max-width: 640px; margin: 0 auto; }
|
||||
.full-row { grid-column: 1 / -1; }
|
||||
.edit-form :deep(.el-form-item) { min-width: 0; margin: 0; }
|
||||
.edit-form :deep(.el-form-item__label) { margin-bottom: 10px; padding: 0; font-size: 14px; line-height: 20px; color: var(--el-text-color-regular); }
|
||||
.edit-form :deep(.el-form-item__content) { min-width: 0; }
|
||||
.edit-form :deep(.el-input__wrapper) { border-radius: 5px; }
|
||||
.form-control { width: 100%; max-width: 100%; }
|
||||
.input-tip-text { margin-left: 12px; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.unit-control { display: flex; align-items: stretch; }
|
||||
.unit-control :deep(.el-input-number) { flex: 1; width: 0; min-width: 0; }
|
||||
.unit-control :deep(.el-input__wrapper) { border-radius: 5px 0 0 5px; }
|
||||
.unit-control :deep(.el-input__inner) { text-align: left; }
|
||||
.unit-addon { display: flex; align-items: center; flex-shrink: 0; padding: 0 14px; border: 1px solid var(--el-border-color); border-left: 0; border-radius: 0 5px 5px 0; color: var(--el-text-color-regular); background: var(--el-fill-color-light); box-sizing: border-box; font-size: 14px; }
|
||||
.validity-control { width: 100%; min-width: 0; }
|
||||
.validity-radio-group { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; width: 100%; }
|
||||
.validity-radio-group :deep(.el-radio) { display: flex; justify-content: center; height: 48px; margin: 0; padding: 0 12px; border: 1px solid var(--el-border-color); border-radius: 6px; box-sizing: border-box; transition: border-color .15s, background-color .15s; }
|
||||
.validity-radio-group :deep(.el-radio:hover:not(.is-disabled)) { border-color: var(--el-color-primary-light-5); }
|
||||
.validity-radio-group :deep(.el-radio.is-checked) { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
|
||||
.validity-radio-group :deep(.el-radio__label) { font-size: 14px; }
|
||||
.validity-detail { display: flex; margin-top: 12px; padding: 16px; background: #f7f9fc; border: 1px solid var(--el-border-color-extra-light); border-radius: 6px; }
|
||||
.month-control { width: 200px; max-width: 100%; }
|
||||
.validity-date-control { width: 100%; max-width: 360px; }
|
||||
.footer-actions { display: flex; flex-wrap: wrap; gap: 12px; max-width: 640px; margin: 32px auto 0; padding-top: 24px; border-top: 1px solid var(--el-border-color-lighter); }
|
||||
.footer-actions :deep(.el-button) { min-width: 88px; height: 36px; border-radius: 5px; }
|
||||
.footer-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.font-mono { font-variant-numeric: tabular-nums; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
.text-dark { color: var(--el-text-color-primary); }
|
||||
.text-secondary { color: var(--el-text-color-regular); }
|
||||
.custom-descriptions :deep(.el-descriptions__label) { width: 130px; font-weight: normal; }
|
||||
.price-highlight { color: var(--el-text-color-primary); }
|
||||
&.pickup-page .info-card { max-width: 1200px; }
|
||||
&.pickup-page .page-header-row, &.pickup-page .edit-form, &.pickup-page .footer-actions { max-width: none; }
|
||||
&.pickup-page .stock-form-item { max-width: 420px; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.gift-card-activity-page {
|
||||
padding: 16px 12px;
|
||||
.info-card :deep(.el-card__header) { padding: 24px 20px 0; }
|
||||
.info-card :deep(.el-card__body) { padding: 24px 20px; }
|
||||
.edit-form { grid-template-columns: minmax(0, 1fr); }
|
||||
.validity-radio-group { grid-template-columns: minmax(0, 1fr); gap: 8px; }
|
||||
.validity-radio-group :deep(.el-radio) { justify-content: flex-start; height: 40px; }
|
||||
.validity-detail { padding: 12px; }
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
|
||||
<el-card>
|
||||
<div class="operation padding-row">
|
||||
<el-button type="primary" @click="addActivity">添加礼品卡</el-button>
|
||||
<el-button type="primary" @click="addActivity('PICKUP')">添加提货卡</el-button>
|
||||
<el-button type="primary" @click="addActivity('CASH')">添加现金卡</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
@@ -45,10 +46,11 @@
|
||||
<template #default="{ row }">
|
||||
<span v-if="row">
|
||||
<span
|
||||
v-if="row.faceValue != null && row.faceValue !== ''"
|
||||
v-if="row.cardType !== 'PICKUP' && row.faceValue != null && row.faceValue !== ''"
|
||||
:style="{ color: $mainColor }"
|
||||
>
|
||||
{{ $filters.unitPrice(row.faceValue, "¥") }}</span>
|
||||
<span v-else-if="row.cardType === 'PICKUP'">兑换 {{ row.pickupCount }} 种</span>
|
||||
<span v-else>-</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -387,8 +389,9 @@ function formatValidity(row) {
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
function addActivity() {
|
||||
router.push({ name: "add-gift-card-cash-activity" });
|
||||
/** 从独立入口创建指定类型的卡,类型在创建页固定展示。 */
|
||||
function addActivity(cardType) {
|
||||
router.push({ name: "add-gift-card-cash-activity", query: { cardType } });
|
||||
}
|
||||
function goEdit(row) {
|
||||
if (!canEdit(row)) {
|
||||
|
||||
@@ -2,52 +2,56 @@
|
||||
<div class="search gcc-batch-credentials">
|
||||
<el-card>
|
||||
<div class="gcc-records-toolbar">
|
||||
<el-button type="primary" class="gcc-records-back" @click="closePage">返回</el-button>
|
||||
<div class="gcc-batch-heading">
|
||||
<h3>{{ giftCardName || "批次凭证" }}</h3>
|
||||
<div class="gcc-records-meta">
|
||||
<span>{{ batchTypeLabel }}:{{ batchIdDisplay || "—" }}</span>
|
||||
<span v-if="batchRemark">本批备注:{{ batchRemark }}</span>
|
||||
</div>
|
||||
<div v-if="giftCardName" class="gcc-records-meta mb_10">
|
||||
<strong>礼品卡:</strong>{{ giftCardName }}
|
||||
</div>
|
||||
<div class="gcc-records-meta mb_10">
|
||||
<strong>{{ batchTypeLabel }}:</strong>{{ batchIdDisplay || "—" }}
|
||||
<span v-if="batchRemark" style="margin-left: 12px">
|
||||
<strong>本批备注:</strong>{{ batchRemark }}</span>
|
||||
<div class="gcc-batch-actions">
|
||||
<el-button
|
||||
v-if="activityId && createBatchId && !isIssueBatchMode"
|
||||
type="primary"
|
||||
:loading="exporting"
|
||||
title="导出当前制卡批次全部卡号、卡密,不受筛选和分页限制"
|
||||
@click="handleExport"
|
||||
>导出卡密</el-button>
|
||||
<el-button @click="closePage">返回</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="!activityId || !hasBatchId" type="warning" show-icon :closable="false">
|
||||
缺少活动或批次参数,请从{{ batchSourceLabel }}进入。
|
||||
</el-alert>
|
||||
<template v-else>
|
||||
<div class="gcc-status-tabs">
|
||||
<span
|
||||
<el-tabs :model-value="lifecycleTab" class="gcc-status-tabs" @tab-change="onLifecycleTab">
|
||||
<el-tab-pane
|
||||
v-for="t in statusTabList"
|
||||
:key="t.key"
|
||||
:class="{ active: lifecycleTab === t.key }"
|
||||
@click="onLifecycleTab(t.key)"
|
||||
>
|
||||
{{ t.label }}</span>
|
||||
</div>
|
||||
<el-form ref="searchFormRef" :model="searchForm" inline class="search-form mb_10 gcc-filter-form">
|
||||
<el-form-item label="卡号" prop="cardNo" label-width="60px">
|
||||
:name="t.key"
|
||||
:label="t.label"
|
||||
/>
|
||||
</el-tabs>
|
||||
<el-form ref="searchFormRef" :model="searchForm" label-width="72px" class="gcc-filter-form">
|
||||
<el-form-item label="卡号" prop="cardNo">
|
||||
<el-input
|
||||
v-model="searchForm.cardNo"
|
||||
placeholder="请输入卡号"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label-width="0" class="gcc-combo-item">
|
||||
<span class="gcc-combo-label">兑换会员</span>
|
||||
<el-select v-model="searchForm.redeemMemberField" style="width: 130px">
|
||||
<el-option label="兑换会员昵称" value="nickName" />
|
||||
<el-option label="兑换会员手机" value="mobile" />
|
||||
<el-form-item label="兑换会员" class="gcc-combo-item">
|
||||
<el-select v-model="searchForm.redeemMemberField" aria-label="兑换会员查询方式">
|
||||
<el-option label="昵称" value="nickName" />
|
||||
<el-option label="手机号" value="mobile" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="searchForm.redeemMemberKeyword"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
style="width: 140px; margin-left: 4px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="兑换时间" prop="redeemDateRange" label-width="72px">
|
||||
<el-form-item label="兑换时间" prop="redeemDateRange">
|
||||
<el-date-picker
|
||||
v-model="redeemDateRange"
|
||||
type="daterange"
|
||||
@@ -55,23 +59,20 @@
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label-width="0" class="gcc-combo-item">
|
||||
<span class="gcc-combo-label">激活会员</span>
|
||||
<el-select v-model="searchForm.activateMemberField" style="width: 130px">
|
||||
<el-option label="激活会员昵称" value="nickName" />
|
||||
<el-option label="激活会员手机" value="mobile" />
|
||||
<el-form-item label="激活会员" class="gcc-combo-item">
|
||||
<el-select v-model="searchForm.activateMemberField" aria-label="激活会员查询方式">
|
||||
<el-option label="昵称" value="nickName" />
|
||||
<el-option label="手机号" value="mobile" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="searchForm.activateMemberKeyword"
|
||||
placeholder="请输入"
|
||||
clearable
|
||||
style="width: 140px; margin-left: 4px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="激活时间" prop="activateDateRange" label-width="72px">
|
||||
<el-form-item label="激活时间" prop="activateDateRange">
|
||||
<el-date-picker
|
||||
v-model="activateDateRange"
|
||||
type="daterange"
|
||||
@@ -79,10 +80,11 @@
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
|
||||
<el-form-item label-width="0" class="gcc-filter-actions">
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-if="!isIssueBatchMode" v-loading="loading" :data="data" style="width: 100%">
|
||||
@@ -90,12 +92,19 @@
|
||||
<el-table-column prop="cardSecret" label="卡密" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="面额" prop="faceValue" width="90" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row">{{ formatPrice(row.faceValue) }}</span>
|
||||
<template v-if="row">
|
||||
<span v-if="row.cardType === 'PICKUP'">提货权益</span>
|
||||
<priceColorScheme v-else-if="hasValue(row.faceValue)" :value="row.faceValue" :color="$mainColor" />
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="余额" prop="balance" width="90" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row">{{ formatPrice(row.balance) }}</span>
|
||||
<template v-if="row">
|
||||
<priceColorScheme v-if="row.cardType !== 'PICKUP' && hasValue(row.balance)" :value="row.balance" :color="$mainColor" />
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" prop="status" width="90" align="center">
|
||||
@@ -143,14 +152,13 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt_10" style="display: flex; justify-content: flex-end">
|
||||
<div class="gcc-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="searchForm.pageNumber"
|
||||
v-model:page-size="searchForm.pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
size="small"
|
||||
@current-change="changePage"
|
||||
@size-change="changePageSize"
|
||||
/>
|
||||
@@ -163,8 +171,9 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { getGiftCardCashCredentialPage } from "@/api/promotion";
|
||||
import { unitPrice } from "@/utils/filters";
|
||||
import { exportGiftCardCashCreateBatch, getGiftCardCashCredentialPage } from "@/api/promotion";
|
||||
import { downloadBlob } from "@/utils/downloadBlob";
|
||||
import { Message } from "@/utils/message";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -177,6 +186,7 @@ const STATUS_LABEL = {
|
||||
UNACTIVATED: "未激活",
|
||||
BOUND: "已激活",
|
||||
ACTIVATED: "已激活",
|
||||
REDEEMED: "已提货",
|
||||
VOIDED: "已销卡",
|
||||
EXPIRED: "已过期",
|
||||
};
|
||||
@@ -206,10 +216,12 @@ const statusTabList = ref([
|
||||
{ key: "activated", label: "已激活", status: "BOUND" },
|
||||
{ key: "cancelled", label: "已销卡", status: "VOIDED" },
|
||||
{ key: "expired", label: "已过期", status: "EXPIRED" },
|
||||
{ key: "redeemed", label: "已提货", status: "REDEEMED" },
|
||||
]);
|
||||
const redeemDateRange = ref([]);
|
||||
const activateDateRange = ref([]);
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const total = ref(0);
|
||||
const data = ref([]);
|
||||
const searchForm = reactive({
|
||||
@@ -267,11 +279,9 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
function formatPrice(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return "—";
|
||||
}
|
||||
return unitPrice(value, "¥");
|
||||
/** 判断金额是否存在,保留零金额的有效展示。 */
|
||||
function hasValue(value) {
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
}
|
||||
function formatStatus(status) {
|
||||
return STATUS_LABEL[status] || status || "—";
|
||||
@@ -307,6 +317,30 @@ function closePage() {
|
||||
localStorage.pageOpenedList = JSON.stringify(appStore.pageOpenedList);
|
||||
router.go(-1);
|
||||
}
|
||||
/** 导出当前制卡批次全部凭证,复用后端 Excel 接口;错误响应不保存为文件。 */
|
||||
async function handleExport() {
|
||||
if (exporting.value || !activityId.value || !createBatchId.value || isIssueBatchMode.value) return;
|
||||
const batchId = createBatchId.value;
|
||||
exporting.value = true;
|
||||
try {
|
||||
const blob = await exportGiftCardCashCreateBatch(batchId);
|
||||
if (!(blob instanceof Blob) || !blob.size) {
|
||||
Message.error(blob?.message || "导出失败,请重试");
|
||||
return;
|
||||
}
|
||||
if (blob.type.includes("json")) {
|
||||
const result = JSON.parse(await blob.text());
|
||||
Message.error(result.message || "导出失败,请重试");
|
||||
return;
|
||||
}
|
||||
downloadBlob(blob, `制卡导出_${batchId}.xlsx`);
|
||||
Message.success("已开始下载");
|
||||
} catch {
|
||||
Message.error("导出失败,请重试");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
function formatMemberText(name, mobile) {
|
||||
const n = name != null && name !== "" ? String(name) : "";
|
||||
const m = mobile != null && mobile !== "" ? String(mobile) : "";
|
||||
@@ -474,64 +508,83 @@ function loadList() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gcc-batch-credentials .gcc-records-toolbar {
|
||||
margin-bottom: 22px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
}
|
||||
.gcc-batch-credentials .gcc-records-back {
|
||||
font-weight: 500;
|
||||
}
|
||||
.gcc-batch-credentials .gcc-records-meta {
|
||||
line-height: 1.6;
|
||||
color: #515a6e;
|
||||
}
|
||||
.gcc-batch-credentials .gcc-status-tabs {
|
||||
.gcc-records-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 16px;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
.gcc-batch-credentials .gcc-status-tabs span {
|
||||
padding: 10px 14px 12px;
|
||||
cursor: pointer;
|
||||
color: #808695;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
.gcc-batch-heading {
|
||||
min-width: 0;
|
||||
}
|
||||
.gcc-batch-credentials .gcc-status-tabs span:hover {
|
||||
color: #515a6e;
|
||||
.gcc-batch-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
}
|
||||
.gcc-batch-credentials .gcc-status-tabs span.active {
|
||||
color: #ff6600;
|
||||
font-weight: 500;
|
||||
.gcc-batch-heading h3 {
|
||||
margin: 0 0 10px;
|
||||
color: #303133;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.gcc-batch-credentials .gcc-status-tabs span.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
background: #ff6600;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.gcc-batch-credentials .gcc-filter-form {
|
||||
.gcc-records-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 8px 24px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.gcc-batch-credentials .gcc-combo-item {
|
||||
margin-right: 12px;
|
||||
.gcc-status-tabs {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.gcc-status-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.gcc-filter-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
align-items: start;
|
||||
gap: 16px 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.gcc-filter-form :deep(.el-form-item) {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.gcc-filter-form :deep(.el-form-item__content) {
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
.gcc-filter-form :deep(.el-input),
|
||||
.gcc-filter-form :deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.gcc-combo-item :deep(.el-select) {
|
||||
flex: 0 0 88px;
|
||||
width: 88px;
|
||||
}
|
||||
.gcc-combo-item :deep(.el-input) {
|
||||
flex: 1;
|
||||
}
|
||||
.gcc-filter-actions :deep(.el-form-item__content) {
|
||||
padding-left: 72px;
|
||||
}
|
||||
.gcc-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.gcc-filter-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.gcc-batch-credentials .gcc-combo-label {
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
color: #515a6e;
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
<el-form-item label="交易类型" prop="transactionType">
|
||||
<el-select v-model="usageSearchForm.transactionType" clearable style="width: 150px">
|
||||
<el-option label="订单抵扣" value="ORDER_DEDUCT" />
|
||||
<el-option label="提货兑换" value="PICKUP_REDEEM" />
|
||||
<el-option label="订单退款" value="ORDER_REFUND" />
|
||||
<el-option label="订单取消" value="ORDER_CANCEL" />
|
||||
</el-select>
|
||||
@@ -277,7 +278,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cardNo" label="卡号" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="现金卡信息" min-width="180">
|
||||
<el-table-column label="礼品卡信息" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row">
|
||||
<div>{{ formatUsageGiftCardName(row) }}</div>
|
||||
@@ -403,6 +404,7 @@ const issueSearchForm = reactive({
|
||||
const usageLoading = ref(false);
|
||||
const usageTotal = ref(0);
|
||||
const usageData = ref([]);
|
||||
let usageRequestId = 0;
|
||||
const usageSearchForm = reactive({
|
||||
pageNumber: 1,
|
||||
pageSize: 20,
|
||||
@@ -446,7 +448,7 @@ function formatExpireTime(v) {
|
||||
}
|
||||
|
||||
function formatUsageGiftCardName(row) {
|
||||
return row.giftCardName || giftCardName.value || "-";
|
||||
return row.giftCardName || "-";
|
||||
}
|
||||
|
||||
function formatUsageFaceValue(row) {
|
||||
@@ -703,12 +705,13 @@ function formatUsageBizType(type) {
|
||||
ORDER_REFUND: "订单退款",
|
||||
ORDER_CANCEL: "订单取消",
|
||||
CARD_ACTIVATE: "卡激活",
|
||||
PICKUP_REDEEM: "提货兑换",
|
||||
};
|
||||
return map[type] || type || "-";
|
||||
}
|
||||
|
||||
function getUsageAmountDisplay(row) {
|
||||
const raw =
|
||||
const raw = row.bizType === "PICKUP_REDEEM" || row.transactionType === "PICKUP_REDEEM" ? row.deductAmount :
|
||||
row.changeAmount != null && row.changeAmount !== "" ? row.changeAmount : row.deductAmount;
|
||||
if (raw === null || raw === undefined || raw === "") {
|
||||
return null;
|
||||
@@ -728,11 +731,16 @@ function getUsageAmountDisplay(row) {
|
||||
}
|
||||
|
||||
function loadUsageRecordList() {
|
||||
const requestId = ++usageRequestId;
|
||||
usageData.value = [];
|
||||
usageTotal.value = 0;
|
||||
if (!activityId.value) {
|
||||
usageLoading.value = false;
|
||||
return;
|
||||
}
|
||||
usageLoading.value = true;
|
||||
const params = {
|
||||
activityId: activityId.value,
|
||||
pageNumber: usageSearchForm.pageNumber,
|
||||
pageSize: usageSearchForm.pageSize,
|
||||
sort: usageSearchForm.sort,
|
||||
@@ -749,6 +757,7 @@ function loadUsageRecordList() {
|
||||
);
|
||||
getGiftCardCashUsageRecordPage(params)
|
||||
.then((res) => {
|
||||
if (requestId !== usageRequestId || params.activityId !== activityId.value) return;
|
||||
usageLoading.value = false;
|
||||
if (res.success && res.result) {
|
||||
const r = res.result;
|
||||
@@ -757,6 +766,7 @@ function loadUsageRecordList() {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (requestId !== usageRequestId || params.activityId !== activityId.value) return;
|
||||
usageLoading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user