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

@@ -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");
@@ -350,4 +355,4 @@ export const getGiftCardCashUsageRecordPage = (params) => {
// 卡密凭证分页(平台库存+会员资产)
export const getGiftCardCashCredentialPage = (params) => {
return getRequest("/promotion/giftCardCash/credential", params);
};
};

View 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(" / ") || "默认规格";
}

View File

@@ -1,38 +1,28 @@
<template>
<div class="gift-card-activity-page">
<!-- 顶部标题与返回栏 -->
<el-card shadow="never" class="header-card">
<div class="page-header-row">
<div class="header-left">
<div class="header-title">
<span>{{ pageTitle }}</span>
<el-tag
v-if="onlyView || isEditMode"
size="small"
effect="plain"
:type="onlyView ? 'info' : (isEditMode ? 'warning' : 'primary')"
class="mode-tag"
>
{{ onlyView ? "查看模式" : "编辑模式" }}
</el-tag>
<span v-if="route.query.id" class="activity-id font-mono">
ID: {{ route.query.id }}
</span>
<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">
<span>{{ pageTitle }}</span>
<el-tag
v-if="onlyView || isEditMode"
size="small"
effect="plain"
:type="onlyView ? 'info' : (isEditMode ? 'warning' : 'primary')"
class="mode-tag"
>
{{ onlyView ? "查看模式" : "编辑模式" }}
</el-tag>
<span v-if="route.query.id" class="activity-id font-mono">
ID: {{ route.query.id }}
</span>
</div>
</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,100 +91,112 @@
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">
<el-input-number
v-model="form.stockTotal"
:min="1"
:precision="0"
placeholder="最多可制发凭证张数"
style="width: 220px"
/>
<span class="input-tip-text">张(最多可制发凭证张数)</span>
<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"
:controls="false"
placeholder="请输入总库存"
/>
<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>
<div class="validity-row">
<el-radio value="FIXED_UNTIL">
<span class="radio-inline-text">指定有效期至</span>
<el-date-picker
v-model="form.validEndTime"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择有效截止时间"
clearable
:disabled="form.validityType !== 'FIXED_UNTIL'"
class="validity-date-picker"
style="width: 220px"
/>
</el-radio>
<div v-else-if="form.validityType === 'FIXED_UNTIL'" class="validity-detail">
<el-date-picker
v-model="form.validEndTime"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="选择有效截止时间"
clearable
:disabled="coreLocked"
class="validity-date-control"
/>
</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>

View File

@@ -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)) {

View File

@@ -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>
<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-heading">
<h3>{{ giftCardName || "批次凭证" }}</h3>
<div class="gcc-records-meta">
<span>{{ batchTypeLabel }}:{{ batchIdDisplay || "—" }}</span>
<span v-if="batchRemark">本批备注:{{ batchRemark }}</span>
</div>
</div>
<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-batch-credentials .gcc-combo-label {
display: inline-block;
margin-right: 6px;
color: #515a6e;
font-size: 14px;
vertical-align: middle;
.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);
}
}
</style>

View File

@@ -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;
});
}