Merge branch 'master' into salesman

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

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

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

View File

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

View File

@@ -0,0 +1,144 @@
/**
* 卡密商品E_COUPON— 商家端公共常量
*
* @author Mike
* @date 2026-08-02
*/
export const E_COUPON_GOODS_TYPE = "E_COUPON";
export function isECoupon(goodsType) {
return goodsType === E_COUPON_GOODS_TYPE;
}
export function goodsTypeLabel(goodsType) {
const map = {
E_COUPON: "电子卡券",
VIRTUAL_GOODS: "虚拟商品",
PHYSICAL_GOODS: "实物商品",
};
return map[goodsType] || goodsType || "—";
}
export function goodsTypeTagType(goodsType) {
if (goodsType === E_COUPON_GOODS_TYPE) return "warning";
if (goodsType === "VIRTUAL_GOODS") return "info";
return "";
}
/** E_COUPON 活动库存提示 */
export function eCouponStockHint(goodsType) {
if (!isECoupon(goodsType)) return "";
return "库存来自卡池同步quantity";
}
/** 活动价下限E_COUPON 允许 0 元 */
export function promotionPriceMin(goodsType) {
return isECoupon(goodsType) ? 0 : 0.01;
}
export const CARD_KEY_FULFILL_STATUS = {
PENDING: "PENDING",
DELIVERED: "DELIVERED",
FAILED: "FAILED",
NOT_APPLICABLE: "NOT_APPLICABLE",
};
export const CARD_KEY_FULFILL_STATUS_TEXT = {
PENDING: "待发放",
DELIVERED: "已发放",
FAILED: "发放失败",
NOT_APPLICABLE: "不适用",
};
export function cardKeyFulfillAlertType(status) {
const map = { PENDING: "info", DELIVERED: "success", FAILED: "warning" };
return map[status] || "info";
}
export function formatCardKeyFulfillStatus(status) {
return CARD_KEY_FULFILL_STATUS_TEXT[status] || status || "—";
}
export function resolveCardKeyFulfillMessage(line) {
if (line && line.message) return line.message;
return formatCardKeyFulfillStatus(line?.status);
}
export function isCardKeyFulfillApplicable(item) {
if (!item) return false;
const status = item.cardKeyFulfillStatus;
if (status) return status !== CARD_KEY_FULFILL_STATUS.NOT_APPLICABLE;
if (item.cardKeyDelivered != null || (item.cardKeys && item.cardKeys.length)) return true;
return item.goodsType === E_COUPON_GOODS_TYPE;
}
function normalizeCardKeyFulfillLine(source) {
const status =
source.cardKeyFulfillStatus ||
(source.cardKeyDelivered
? CARD_KEY_FULFILL_STATUS.DELIVERED
: CARD_KEY_FULFILL_STATUS.PENDING);
const cardKeys =
status === CARD_KEY_FULFILL_STATUS.DELIVERED && source.cardKeyDelivered
? source.cardKeys || []
: [];
return {
key: source.sn || source.orderSn || source.id || source.skuId,
goodsName: source.goodsName,
status,
message: source.cardKeyFulfillMessage,
cardKeys,
isGift: !!source.isGift,
};
}
/** 汇总当前订单 orderItems 中的卡密履约行(不含满赠子单;赠品卡密在赠品子单详情单独查看) */
export function collectCardKeyFulfillLines(orderItems = []) {
const lines = [];
(orderItems || []).forEach((item) => {
if (!isCardKeyFulfillApplicable(item)) return;
lines.push(normalizeCardKeyFulfillLine(item));
});
return lines;
}
export function orderHasCardKeySection({ orderItems, isECouponOrder } = {}) {
if (isECouponOrder) return true;
if (collectCardKeyFulfillLines(orderItems).length > 0) return true;
return (orderItems || []).some(
(item) =>
item.goodsType === E_COUPON_GOODS_TYPE ||
(Array.isArray(item.cardKeys) && item.cardKeys.length > 0)
);
}
export function flattenCardKeyFulfillLines(lines, withGoodsName = false) {
const rows = [];
(lines || []).forEach((line) => {
if (line.status !== CARD_KEY_FULFILL_STATUS.DELIVERED) return;
(line.cardKeys || []).forEach((ck) => {
rows.push(withGoodsName ? { ...ck, goodsName: line.goodsName } : { ...ck });
});
});
return rows;
}
/** 当前订单是否含已交付卡密(仅 orderItems */
export function orderHasCardKeyContent(orderItems) {
return flattenCardKeyFulfillLines(collectCardKeyFulfillLines(orderItems)).length > 0;
}
export function flattenOrderCardKeys(orderItems, withGoodsName = false) {
const lines = collectCardKeyFulfillLines(orderItems);
if (lines.some((line) => line.status)) {
return flattenCardKeyFulfillLines(lines, withGoodsName);
}
const rows = [];
(orderItems || []).forEach((item) => {
if (!item.cardKeyDelivered || !item.cardKeys || !item.cardKeys.length) return;
item.cardKeys.forEach((ck) => {
rows.push(withGoodsName ? { ...ck, goodsName: item.goodsName } : { ...ck });
});
});
return rows;
}

View File

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

View File

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

View File

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

View File

@@ -1,58 +1,31 @@
<template>
<div class="goods-operation">
<div class="step-list">
<el-steps :active="activestep" align-center style="height: 60px; margin-top: 10px">
<el-step title="选择商品品类" />
<el-step title="填写商品详情" />
<el-step title="商品发布成功" />
</el-steps>
</div>
<!-- 第一步 选择分类 -->
<first-step ref='first' v-show="activestep === 0" @change="getFirstData"></first-step>
<!-- 第二步 商品详细信息 -->
<second-step ref='second' :firstData="firstData" v-if="activestep === 1"></second-step>
<!-- 第三步 发布完成 -->
<third-step ref='third' v-if="activestep === 2"></third-step>
<!-- 填写商品详细信息含商品类型商品分类 -->
<second-step ref="second" :firstData="firstData" v-if="activestep === 1"></second-step>
<!-- 发布完成 -->
<third-step ref="third" v-if="activestep === 2"></third-step>
</div>
</template>
<script>
import firstStep from './goodsOperationFirst'
import secondStep from './goodsOperationSec'
import thirdStep from './goodsOperationThird'
import secondStep from "./goodsOperationSec";
import thirdStep from "./goodsOperationThird";
export default {
name: "addGoods",
components: {
firstStep,
secondStep,
thirdStep
thirdStep,
},
data() {
return {
/** 当前激活步骤*/
activestep: 0,
firstData: {}, // 第一步传递的数据
/** 当前激活步骤1 填写详情2 发布成功 */
activestep: 1,
firstData: {},
};
},
methods: {
// 选择商品分类回调
getFirstData (item) {
this.firstData = item;
this.activestep = 1;
}
},
mounted() {
// 编辑商品、模板、复制商品
if (this.$route.query.id || this.$route.query.draftId || this.$route.query.copyId) {
this.activestep = 1;
} else {
this.activestep = 0
this.$refs.first.selectGoodsType = true;
}
}
window.scrollTo(0, 0);
},
};
</script>
<style lang="scss" scoped>

View File

@@ -1,233 +1,9 @@
<template>
<div>
<!-- 选择商品类型 -->
<el-dialog v-model="selectGoodsType" width="550px" :show-close="false">
<div class="goods-type-list">
<div
class="goods-type-item"
:class="{ 'active-goods-type': item.check }"
@click="handleClickGoodsType(item)"
v-for="(item, index) in goodsTypeWay"
:key="index"
>
<img :src="item.img" />
<div>
<h2>{{ item.title }}</h2>
<p>{{ item.desc }}</p>
</div>
</div>
</div>
<template #footer>
<div class="goods-type-actions">
<el-button @click="cancelGoodsType">取消</el-button>
<el-button type="primary" @click="confirmGoodsType">确认</el-button>
</div>
</template>
</el-dialog>
<!-- 商品分类 -->
<div class="content-goods-publish">
<div class="goods-category">
<ul v-if="categoryListLevel1.length > 0">
<li
v-for="(item, index) in categoryListLevel1"
:class="{ activeClass: category[0].name === item.name }"
@click="handleSelectCategory(item, index, 1)"
:key="index"
>
<span>{{ item.name }}</span>
<span>&gt;</span>
</li>
</ul>
<ul v-if="categoryListLevel2.length > 0">
<li
v-for="(item, index) in categoryListLevel2"
:class="{ activeClass: category[1].name === item.name }"
@click="handleSelectCategory(item, index, 2)"
:key="index"
>
<span>{{ item.name }}</span>
<span>&gt;</span>
</li>
</ul>
<ul v-if="categoryListLevel3.length > 0">
<li
v-for="(item, index) in categoryListLevel3"
:class="{ activeClass: category[2].name === item.name }"
@click="handleSelectCategory(item, index, 3)"
:key="index"
>
<span>{{ item.name }}</span>
</li>
</ul>
</div>
<p class="current-goods-category">
您当前选择的商品类别是
<span>{{ category[0].name }}</span>
<span v-show="category[1].name">> {{ category[1].name }}</span>
<span v-show="category[2].name">> {{ category[2].name }}</span>
</p>
</div>
<!-- 底部按钮 -->
<div class="footer">
<div class="footer-btns">
<el-button type="primary" @click="openGoodsTypeDialog">商品类型</el-button>
<el-button type="primary" @click="next">下一步</el-button>
</div>
</div>
</div>
<div></div>
</template>
<script>
import * as API_GOODS from "@/api/goods";
import goodsType1Img from "@/assets/goodsType1.png";
import goodsType2Img from "@/assets/goodsType2.png";
/** 商品类型与分类已迁移至 goodsOperationSec.vue 基本信息区 */
export default {
data() {
return {
selectGoodsType: false, // 展示选择商品分类modal
goodsTypeWay: [
{
title: "实物商品",
img: goodsType1Img,
desc: "零售批发,物流配送",
type: "PHYSICAL_GOODS",
check: false,
},
{
title: "虚拟商品",
img: goodsType2Img,
desc: "虚拟核验,无需物流",
type: "VIRTUAL_GOODS",
check: false,
},
],
// 商品分类选择数组
category: [
{ name: "", id: "" },
{ name: "", id: "" },
{ name: "", id: "" },
],
// 商品类型
goodsType: "",
pendingGoodsType: "",
/** 1级分类列表*/
categoryListLevel1: [],
/** 2级分类列表*/
categoryListLevel2: [],
/** 3级分类列表*/
categoryListLevel3: [],
};
},
watch: {
selectGoodsType(val) {
if (val) {
this.syncGoodsTypeSelection();
}
},
},
methods: {
syncGoodsTypeSelection() {
this.pendingGoodsType = this.goodsType;
this.goodsTypeWay.forEach((item) => {
item.check = item.type === this.goodsType;
});
},
openGoodsTypeDialog() {
this.selectGoodsType = true;
},
// 点击商品类型(仅临时选中,确认后才生效)
handleClickGoodsType(val) {
this.goodsTypeWay.forEach((item) => {
item.check = item.type === val.type;
});
this.pendingGoodsType = val.type;
},
cancelGoodsType() {
this.syncGoodsTypeSelection();
this.selectGoodsType = false;
},
confirmGoodsType() {
if (!this.pendingGoodsType) {
this.$Message.error("请选择商品类型");
return;
}
this.goodsType = this.pendingGoodsType;
this.selectGoodsType = false;
},
/** 选择商城商品分类 */
handleSelectCategory(row, index, level) {
if (level === 1) {
this.category.forEach((cate) => {
(cate.name = ""), (cate.id = "");
});
this.category[0].name = row.name;
this.category[0].id = row.id;
this.categoryListLevel2 = this.categoryListLevel1[index].children;
this.categoryListLevel3 = [];
} else if (level === 2) {
this.category[1].name = row.name;
this.category[1].id = row.id;
this.category[2].name = "";
this.category[2].id = "";
this.categoryListLevel3 = this.categoryListLevel2[index].children;
} else {
this.category[2].name = row.name;
this.category[2].id = row.id;
}
},
/** 查询下一级 商城商品分类*/
GET_NextLevelCategory(row) {
const _id = row && row.id !== 0 ? row.id : 0;
API_GOODS.getGoodsCategoryAll().then((res) => {
if (res.success && res.result) {
this.categoryListLevel1 = res.result;
}
});
},
// 下一步
next() {
window.scrollTo(0, 0);
if (!this.goodsType) {
this.$Message.error("请选择商品类型");
return;
}
if (!this.category[0].name) {
this.$Message.error("请选择商品分类");
return;
} else if (!this.category[2].name) {
this.$Message.error("必须选择到三级分类");
return;
} else if (this.category[2].name) {
let params = {
category: this.category,
goodsType: this.goodsType,
};
this.$emit("change", params);
}
},
},
mounted() {
this.GET_NextLevelCategory();
},
name: "goodsOperationFirst",
};
</script>
<style lang="scss" scoped>
@import "./addGoods.scss";
.footer {
display: flex;
justify-content: center;
}
.footer-btns {
display: flex;
gap: 12px;
justify-content: center;
}
.goods-type-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
width: 100%;
}
</style>

View File

@@ -8,12 +8,37 @@
<div class="base-info-item">
<h4>基本信息</h4>
<div class="form-item-view">
<el-form-item label="商品类">
<span class="goods-category-name">{{
baseInfoForm.categoryName[0]
}}</span>
<span> &gt; {{ baseInfoForm.categoryName[1] }}</span>
<span> &gt; {{ baseInfoForm.categoryName[2] }}</span>
<el-form-item label="商品类型" prop="goodsType">
<el-select
v-model="baseInfoForm.goodsType"
placeholder="请选择商品类型"
style="width: 260px"
popper-class="goods-type-select-popper"
@change="handleGoodsTypeChange"
>
<el-option
v-for="item in goodsTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
<div class="goods-type-option">
<span class="goods-type-option-label">{{ item.label }}</span>
<span class="goods-type-option-desc">{{ item.desc }}</span>
</div>
</el-option>
</el-select>
<div v-if="currentGoodsTypeDesc" class="form-tip">{{ currentGoodsTypeDesc }}</div>
</el-form-item>
<el-form-item label="商品分类" prop="categoryPath">
<el-cascader
v-model="selectedCategory"
:options="categoryList"
placeholder="请选择商品分类"
clearable
style="width: 260px"
@change="handleCategoryChange"
/>
</el-form-item>
<el-form-item label="商品名称" prop="goodsName">
<el-input v-model="baseInfoForm.goodsName" clearable placeholder="商品名称" style="width: 260px" type="text" />
@@ -44,7 +69,7 @@
</el-form-item>
<el-form-item class="form-item-view-el" label="销售模式" prop="salesModel">
<el-radio-group
v-if="baseInfoForm.goodsType != 'VIRTUAL_GOODS'"
v-if="!isVirtualLikeGoods"
v-model="baseInfoForm.salesModel"
@change="handleSalesModeChange"
>
@@ -52,7 +77,7 @@
<el-radio-button value="WHOLESALE">批发型</el-radio-button>
</el-radio-group>
<el-radio-group v-else v-model="baseInfoForm.salesModel">
<el-radio-button value="RETAIL">虚拟型</el-radio-button>
<el-radio-button value="RETAIL">{{ isECouponGoods ? "电子卡券" : "虚拟型" }}</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item v-if="baseInfoForm.salesModel == 'WHOLESALE'" class="form-item-view-el" label="销售规则"
@@ -336,7 +361,7 @@
@change="updateSkuTable(row, 'sn', $index)"
/>
<el-input
v-else-if="col.slot === 'weight' && baseInfoForm.goodsType !== 'VIRTUAL_GOODS'"
v-else-if="col.slot === 'weight' && needsLogistics && baseInfoForm.salesModel !== 'WHOLESALE'"
v-model="row.weight"
clearable
placeholder="请输入重量"
@@ -344,6 +369,14 @@
>
<template #append>kg</template>
</el-input>
<el-input
v-else-if="col.slot === 'quantity' && isECouponGoods"
:model-value="row.quantity ?? 0"
disabled
placeholder="库存"
>
<template #append>{{ baseInfoForm.goodsUnit || "" }}</template>
</el-input>
<el-input
v-else-if="col.slot === 'quantity'"
v-model="row.quantity"
@@ -444,7 +477,7 @@
</div>
</el-form-item>
</div>
<div v-if="baseInfoForm.goodsType != 'VIRTUAL_GOODS'">
<div v-if="needsLogistics">
<h4>商品物流信息</h4>
<div class="form-item-view">
<el-form-item class="form-item-view-el" label="物流模板" prop="templateId">
@@ -486,7 +519,6 @@
<!-- 底部按钮 -->
<div class="footer">
<div class="footer-btns">
<el-button type="primary" @click="pre">上一步</el-button>
<el-button :loading="submitLoading" type="primary" @click="save">
{{ $route.query.id ? "保存" : "保存商品" }}
</el-button>
@@ -513,7 +545,7 @@
@selected="(list) => { selectedImage = list }"
/>
<template #footer>
<el-button @click="picModelFlag = false">取消</el-button>
<el-button @click="picModelFlag = false; selectedImage = []">取消</el-button>
<el-button type="primary" @click="confirmUrls">确定</el-button>
</template>
</el-dialog>
@@ -552,6 +584,28 @@ export default {
type: Object,
},
},
computed: {
/** 卡密商品:库存只读展示 quantity提交时 quantity 传 0FR-S-01 / P-02 */
isECouponGoods() {
return this.baseInfoForm.goodsType === "E_COUPON";
},
isVirtualGoods() {
return this.baseInfoForm.goodsType === "VIRTUAL_GOODS";
},
isVirtualLikeGoods() {
return this.isVirtualGoods || this.isECouponGoods;
},
/** 仅实物需要运费模板E_COUPON 强制 templateId=0§6.1 */
needsLogistics() {
return this.baseInfoForm.goodsType === "PHYSICAL_GOODS";
},
currentGoodsTypeDesc() {
const matched = this.goodsTypeOptions.find(
(item) => item.value === this.baseInfoForm.goodsType
);
return matched ? matched.desc : "";
},
},
data() {
// 表单验证项,商品价格
const checkPrice = (rule, value, callback) => {
@@ -584,6 +638,16 @@ export default {
accessToken: "", //令牌token
goodsParams: [],
categoryId: "", // 商品分类第三级id
/** 商品类型选项 */
goodsTypeOptions: [
{ label: "实物商品", value: "PHYSICAL_GOODS", desc: "零售批发物流配送" },
{ label: "虚拟商品", value: "VIRTUAL_GOODS", desc: "虚拟核验无需物流" },
{ label: "电子卡券", value: "E_COUPON", desc: "卡密自动发卡无需物流" },
],
/** 商城分类级联数据 */
categoryList: [],
/** 商城分类选中值 [一级id, 二级id, 三级id] */
selectedCategory: [],
//提交状态
submitLoading: false,
//上传图片路径
@@ -705,6 +769,8 @@ export default {
/** 存储未通过校验的单元格位置 */
validateError: [],
baseInfoFormRule: {
goodsType: [{ required: true, message: "请选择商品类型", trigger: "change" }],
categoryPath: [{ required: true, message: "请选择商品分类", trigger: "change" }],
goodsName: [regular.REQUIRED, regular.WHITE_SPACE, regular.VARCHAR60],
price: [regular.REQUIRED, { validator: checkPrice }],
sellingPoint: [regular.REQUIRED, regular.VARCHAR60],
@@ -759,11 +825,22 @@ export default {
}
},
methods: {
/** E_COUPON 提交固定传 0真实库存由卡池 syncSkuStock 同步至 quantity */
resolveSubmitQuantity(sku) {
if (this.isECouponGoods) {
return 0;
}
return sku.quantity;
},
defaultSkuQuantity() {
return this.isECouponGoods ? 0 : "";
},
draggableItemKey(item) {
return item;
},
// 选择图片modal
handleCLickImg(val, index) {
this.selectedImage = [];
this.picModelFlag = true;
this.selectedFormBtnName = val;
this.$nextTick(() => {
@@ -793,22 +870,60 @@ export default {
desc: "视频大小不能超过10MB",
});
},
parseOssSelectionUrl(item) {
if (!item) return "";
if (typeof item === "string") {
const index = item.indexOf(",");
return index >= 0 ? item.slice(index + 1) : item;
}
return item.url || "";
},
// 图片选择后回调
callbackSelected(val) {
this.picModelFlag = false;
if (val && this.selectedFormBtnName == 'selectedSkuImages') {
this.selectedSku.images.push(val);
if (!val?.url) {
return;
}
if (this.selectedFormBtnName === "selectedSkuImages") {
if (!this.selectedSku.images) {
this.selectedSku.images = [];
}
this.selectedSku.images.push(val.url);
} else {
this.baseInfoForm[this.selectedFormBtnName].push(val.url);
}
},
confirmUrls() {
if (this.selectedImage && this.selectedFormBtnName == 'selectedSkuImages') {
this.selectedSku.images = [...this.selectedSku.images, ...this.selectedImage];
} else {
this.baseInfoForm[this.selectedFormBtnName] = [...this.baseInfoForm[this.selectedFormBtnName], ...this.selectedImage];
const urls = (this.selectedImage || [])
.map((item) => this.parseOssSelectionUrl(item))
.filter(Boolean);
if (!urls.length) {
this.$Message.warning("请选择图片");
return;
}
if (this.selectedFormBtnName === "selectedSkuImages") {
if (!this.selectedSku.images) {
this.selectedSku.images = [];
}
urls.forEach((url) => {
if (this.selectedSku.images.length < 5) {
this.selectedSku.images.push(url);
}
});
} else if (this.selectedFormBtnName === "goodsGalleryFiles") {
urls.forEach((url) => {
if (this.baseInfoForm.goodsGalleryFiles.length < 5) {
this.baseInfoForm.goodsGalleryFiles.push(url);
}
});
} else if (this.selectedFormBtnName) {
const target = this.baseInfoForm[this.selectedFormBtnName];
if (Array.isArray(target)) {
urls.forEach((url) => target.push(url));
}
}
this.selectedImage = [];
this.picModelFlag = false;
},
// 局部刷新
refresh(v) {
@@ -913,9 +1028,79 @@ export default {
});
}
},
pre() {
// 上一步
this.$parent.activestep--;
/** 加载商城商品分类树 */
loadCategoryList() {
return API_GOODS.getGoodsCategoryAll().then((res) => {
if (res.success && res.result) {
this.categoryList = this.formatCategoryTree(res.result);
this.syncSelectedCategoryFromPath();
}
});
},
formatCategoryTree(list) {
return (list || []).map((item) => ({
value: String(item.id),
label: item.name,
children: (item.children || []).map((child) => ({
value: String(child.id),
label: child.name,
children: (child.children || []).map((grandson) => ({
value: String(grandson.id),
label: grandson.name,
})),
})),
}));
},
syncSelectedCategoryFromPath() {
const path = this.baseInfoForm.categoryPath;
if (!path) {
return;
}
const ids = path.split(",").filter(Boolean).map(String);
if (ids.length >= 3) {
this.selectedCategory = ids;
this.categoryId = ids[2];
}
},
getCategoryNames(ids) {
const names = [];
let options = this.categoryList;
ids.forEach((id) => {
const found = (options || []).find((item) => item.value === id);
if (found) {
names.push(found.label);
options = found.children || [];
}
});
return names;
},
handleGoodsTypeChange() {
if (this.isVirtualLikeGoods) {
this.baseInfoForm.salesModel = "RETAIL";
}
if (this.isECouponGoods) {
this.baseInfoForm.templateId = 0;
}
this.renderTableData(this.skuTableData);
},
handleCategoryChange(val) {
if (!val || val.length < 3) {
this.selectedCategory = val || [];
this.categoryId = "";
this.baseInfoForm.categoryPath = "";
this.baseInfoForm.categoryName = [];
this.baseInfoForm.brandId = 0;
this.brandList = [];
this.goodsParams = [];
this.params_panel = [];
return;
}
this.categoryId = val[2];
this.baseInfoForm.categoryPath = val.join(",");
this.baseInfoForm.categoryName = this.getCategoryNames(val);
this.baseInfoForm.brandId = 0;
this.getGoodsBrandList();
this.GET_GoodsParams();
},
// 预览图片
handleView(url) {
@@ -1192,6 +1377,7 @@ export default {
this.baseInfoForm = { ...this.baseInfoForm, ...response.result };
this.baseInfoForm.release = 1; //即使是被放入仓库,修改的时候也会显示会立即发布
this.categoryId = response.result.categoryPath.split(",")[2];
this.syncSelectedCategoryFromPath();
// 如果是复制商品需要清除ID确保提交时作为新商品
if (this.$route.query.copyId) {
@@ -1228,19 +1414,6 @@ export default {
this.GET_ShopGoodsLabel();
this.GET_GoodsUnit();
if (this.firstData.category) {
const cateId = [];
this.baseInfoForm.categoryName = [];
this.firstData.category.forEach((cate) => {
this.baseInfoForm.categoryName.push(cate.name);
cateId.push(cate.id);
});
this.categoryId = cateId[2];
this.baseInfoForm.categoryPath = cateId.toString();
}
this.firstData.goodsType &&
(this.baseInfoForm.goodsType = this.firstData.goodsType);
/** 查询商品参数 */
this.GET_GoodsParams();
},
@@ -1724,14 +1897,19 @@ export default {
...combination,
id: existingCombination.id || "",
sn: existingCombination.sn || "",
quantity: existingCombination.quantity || "",
quantity: this.isECouponGoods
? (existingCombination.quantity ?? 0)
: (existingCombination.quantity || ""),
cost: existingCombination.cost || "",
price: existingCombination.price || "",
weight: existingCombination.weight || ""
};
} else {
// 新组合使用默认值
return combination;
return {
...combination,
quantity: this.defaultSkuQuantity(),
};
}
});
this.baseInfoForm.regeneratorSkuFlag = true;
@@ -1779,14 +1957,19 @@ export default {
...combination,
id: existingCombination.id || "",
sn: existingCombination.sn || "",
quantity: existingCombination.quantity || "",
quantity: this.isECouponGoods
? (existingCombination.quantity ?? 0)
: (existingCombination.quantity || ""),
cost: existingCombination.cost || "",
price: existingCombination.price || "",
weight: existingCombination.weight || ""
};
} else {
// 新组合使用默认值
return combination;
return {
...combination,
quantity: this.defaultSkuQuantity(),
};
}
});
@@ -1833,7 +2016,7 @@ export default {
// 有重量的情况
if (
this.baseInfoForm.goodsType !== "VIRTUAL_GOODS" &&
this.needsLogistics &&
this.baseInfoForm.salesModel !== "WHOLESALE"
) {
pushData.push({
@@ -1878,7 +2061,9 @@ export default {
...combination,
id: existingCombination.id || "",
sn: existingCombination.sn || "",
quantity: existingCombination.quantity || "",
quantity: this.isECouponGoods
? (existingCombination.quantity ?? 0)
: (existingCombination.quantity || ""),
cost: existingCombination.cost || "",
price: existingCombination.price || (this.baseInfoForm.salesModel === 'WHOLESALE' && this.wholesaleData.length > 0 ? this.wholesaleData[0].price : ""),
weight: existingCombination.weight || ""
@@ -1889,7 +2074,7 @@ export default {
...combination,
id: "",
sn: "",
quantity: "",
quantity: this.defaultSkuQuantity(),
cost: "",
price: this.baseInfoForm.salesModel === 'WHOLESALE' && this.wholesaleData.length > 0 ? this.wholesaleData[0].price : "",
weight: ""
@@ -2119,6 +2304,10 @@ export default {
this.$Message.error(`以下参数为必填项:${missingParams.join('、')}`);
return;
}
if (!this.selectedCategory || this.selectedCategory.length < 3) {
this.$Message.error("必须选择到三级分类");
return;
}
this.submitLoading = true;
this.$refs["baseInfoForm"].validate((valid) => {
if (valid) {
@@ -2153,6 +2342,10 @@ export default {
return;
}
if (submit.templateId === "") submit.templateId = 0;
if (this.isECouponGoods) {
submit.templateId = 0;
submit.salesModel = "RETAIL";
}
let flag = false;
let paramValue = "";
@@ -2175,7 +2368,7 @@ export default {
let skuCopy = {
cost: 1,
price: sku.price,
quantity: sku.quantity,
quantity: this.resolveSubmitQuantity(sku),
// alertQuantity: sku.alertQuantity,
sn: sku.sn,
images: [],
@@ -2273,37 +2466,20 @@ export default {
accessToken: this.getStore("accessToken"),
};
this.GET_ShipTemplate()
this.loadCategoryList();
if (this.$route.query.id || this.$route.query.draftId) {
// 编辑商品、模板
this.GET_GoodData(this.$route.query.id, this.$route.query.draftId);
} else if (this.$route.query.copyId) {
// 复制商品
this.GET_GoodData(this.$route.query.copyId);
} else if (this.firstData.tempId) {
// 选择模板
this.GET_GoodData("", this.firstData.tempId);
} else {
// 新增商品、模板
if (this.firstData.tempId) {
// 选择模板
this.GET_GoodData("", this.firstData.tempId);
} else {
const cateId = [];
this.firstData.category.forEach((cate) => {
this.baseInfoForm.categoryName.push(cate.name);
cateId.push(cate.id);
});
this.categoryId = cateId[2];
this.baseInfoForm.categoryPath = cateId.toString();
this.baseInfoForm.goodsType = this.firstData.goodsType;
/** 获取该商城分类下 商品参数信息 */
this.GET_GoodsParams();
/** 查询品牌列表 */
this.getGoodsBrandList();
// 获取商品单位
this.GET_GoodsUnit();
// 获取当前店铺分类
this.GET_ShopGoodsLabel();
}
// 新增商品
this.GET_GoodsUnit();
this.GET_ShopGoodsLabel();
}
},
};
@@ -2416,4 +2592,58 @@ export default {
.refresh-icon {
margin-left: 10px;
}
.form-tip {
color: #666;
font-size: 12px;
line-height: 1.5;
margin-top: 4px;
max-width: 260px;
}
.goods-type-option {
display: flex;
flex-direction: column;
line-height: 1.4;
padding: 2px 0;
}
.goods-type-option-label {
color: #303133;
}
.goods-type-option-desc {
color: #909399;
font-size: 12px;
}
</style>
<style lang="scss">
.goods-type-select-popper {
.el-select-dropdown__item {
height: auto;
min-height: 58px;
padding: 10px 12px;
line-height: 1.5;
white-space: normal;
}
.goods-type-option {
display: flex;
flex-direction: column;
gap: 4px;
}
.goods-type-option-label {
color: #303133;
font-size: 14px;
line-height: 1.4;
}
.goods-type-option-desc {
color: #909399;
font-size: 12px;
line-height: 1.5;
white-space: normal;
}
}
</style>

View File

@@ -2,9 +2,6 @@
<div class="content-goods-publish">
<div class="success" style="text-align: left">
<h1>恭喜您商品发布成功!</h1>
<div class="goToGoodsList" @click="gotoGoodsList">
<a>去店铺查看商品列表>></a>
</div>
<div class="operation">
<h3>您还可以</h3>
<div>
@@ -34,5 +31,17 @@ export default {
}
</script>
<style lang="scss" scoped>
@import "./addGoods.scss";
@import "./addGoods.scss";
.success {
a {
color: #409eff;
cursor: pointer;
text-decoration: none;
&:hover {
color: #66b1ff;
}
}
}
</style>

View File

@@ -253,6 +253,7 @@ export default {
goodsData: [],
loading: false,
tableHeight: 400,
syncingSelection: false,
};
},
watch: {
@@ -339,14 +340,15 @@ export default {
initGoods(res) {
const records = res?.result?.records || [];
this.total = res?.result?.total || 0;
const selectedIds = new Set(
(this.selectedList || [])
.map((e) => e.id || e.skuId)
.filter((id) => id != null)
.map(String)
);
records.forEach((item) => {
item.selected = false;
item.___type = "goods";
this.selectedList.forEach((e) => {
if (e.id && e.id === item.id) {
item.selected = true;
}
});
item.selected = selectedIds.has(String(item.id));
});
this.goodsData = records;
this.syncTableSelection();
@@ -356,12 +358,18 @@ export default {
const table = this.$refs.goodsTable;
if (!table) return;
if (this.type === "multiple") {
// clearSelection/toggleRowSelection 都会触发 selection-change
// 同步期间忽略,避免把已选状态清掉
this.syncingSelection = true;
table.clearSelection();
this.goodsData.forEach((row) => {
if (row.selected) {
table.toggleRowSelection(row, true);
}
});
this.$nextTick(() => {
this.syncingSelection = false;
});
return;
}
const selected = this.goodsData.find((item) => item.selected);
@@ -382,13 +390,16 @@ export default {
this.$refs.goodsTable?.setCurrentRow(row);
},
handleSelectionChange(selection) {
if (this.type !== "multiple") return;
const selectedIds = new Set(selection.map((item) => item.id));
if (this.type !== "multiple" || this.syncingSelection) return;
const selectedIds = new Set(selection.map((item) => String(item.id)));
this.goodsData.forEach((item) => {
item.selected = selectedIds.has(item.id);
item.selected = selectedIds.has(String(item.id));
});
const currentPageIds = new Set(this.goodsData.map((item) => String(item.id)));
const otherPages = this.selectedList.filter((item) => {
const id = item.id || item.skuId;
return id != null && !currentPageIds.has(String(id));
});
const currentPageIds = new Set(this.goodsData.map((item) => item.id));
const otherPages = this.selectedList.filter((item) => !currentPageIds.has(item.id));
this.emitSelected([...otherPages, ...selection]);
},
formatShopCategory(list) {

View File

@@ -27,7 +27,7 @@
@click="toPrint"
>打印电子面单</el-button>
<el-button
v-if="$route.query.orderType != 'VIRTUAL'"
v-if="!isNonPhysicalOrder"
type="primary"
plain
style="float: right"
@@ -122,7 +122,7 @@
</div>
</div>
<div style="width: 36%; float: left">
<div class="div-item" v-if="orderInfo.order.deliveryMethod != 'SELF_PICK_UP'">
<div class="div-item" v-if="!isECouponOrder && orderInfo.order.deliveryMethod != 'SELF_PICK_UP'">
<div class="div-item-left">收货信息</div>
<div class="div-item-right">
{{ orderInfo.order.consigneeName }}
@@ -204,7 +204,7 @@
</div>
</div> -->
<div class="div-item" v-if="$route.query.orderType != 'VIRTUAL'">
<div class="div-item" v-if="!isNonPhysicalOrder">
<div class="div-item-left">配送方式</div>
<div class="div-item-right">
{{ orderInfo.deliveryMethodValue }}
@@ -256,6 +256,48 @@
</template>
</el-table-column>
</el-table>
<!-- 卡密信息当前订单 orderItems满赠赠品卡密在赠品子单详情查看 -->
<div v-if="hasCardKeySection" class="ecoupon-card-keys mt_10">
<h4>卡密信息</h4>
<div
v-for="line in cardKeyFulfillLines"
:key="line.key"
class="ecoupon-fulfill-block"
>
<el-alert
v-if="line.status !== 'DELIVERED'"
:type="cardKeyFulfillAlertType(line.status)"
show-icon
:closable="false"
:title="resolveCardKeyFulfillMessage(line)"
class="mb_10"
/>
<el-table
v-else-if="line.cardKeys.length"
border
:data="lineCardKeyRows(line)"
style="width: 100%"
class="mb_10"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="cardNo" label="卡号" min-width="140" show-overflow-tooltip />
<el-table-column prop="cardSecret" label="卡密" min-width="120" show-overflow-tooltip />
<el-table-column prop="allocatedTime" label="发卡时间" width="170" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<span v-if="row">{{ formatCardKeyStatus(row.status || 'ALLOCATED') }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-button v-if="row" link type="primary" @click="copyCardKey(row)">复制</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<div class="goods-total">
<ul>
<li>
@@ -319,7 +361,7 @@
<span class="label" v-if="typeList.length == 1 && index == 0" style="font-size:10px !important;"><a @click="gotoHomes" style="display: inline-block;border-top: 1px dashed;border-bottom: 1px dashed;color:black;width:80px;">{{item.promotionName}}</a><span class="op-split">|</span>
<span class="txt" v-if="typeList.length == 1 && index == 0" style="border-top: 1px dashed;border-bottom: 1px dashed;font-size:10px !important;">¥{{ $filters.unitPrice(item.discountPrice) }}</span>
</li> -->
<li>
<li v-if="!isECouponOrder">
<span class="label">运费:</span>
<span class="txt">{{
$filters.unitPrice(orderInfo.order.freightPrice, "¥")
@@ -694,6 +736,14 @@ import * as RegExp from "@/libs/RegExp.js";
import multipleMap from "@/views/my-components/map/multiple-map";
import vueQr from "vue-qr";
import { printElement } from "@/utils/print";
import { formatCardKeyStatus } from "@/constants/cardKey";
import {
collectCardKeyFulfillLines,
orderHasCardKeySection,
flattenOrderCardKeys,
cardKeyFulfillAlertType,
resolveCardKeyFulfillMessage,
} from "@/constants/goodsType";
export default {
name: "orderDetail",
components: {
@@ -814,11 +864,35 @@ export default {
};
},
computed: {
/** 电子卡券订单:隐藏物流/发货/售后,展示 orderItems[].cardKeysFR-S-04 / FR-S-05 */
isECouponOrder() {
const t = this.orderInfo?.order?.orderType;
return t === "E_COUPON" || this.$route.query.orderType === "E_COUPON";
},
isVirtualOrder() {
const t = this.orderInfo?.order?.orderType;
return t === "VIRTUAL" || this.$route.query.orderType === "VIRTUAL";
},
isNonPhysicalOrder() {
return this.isVirtualOrder || this.isECouponOrder;
},
hasCardKeySection() {
return orderHasCardKeySection({
orderItems: this.data,
isECouponOrder: this.isECouponOrder,
});
},
cardKeyFulfillLines() {
return collectCardKeyFulfillLines(this.data);
},
ecouponCardKeyRows() {
return flattenOrderCardKeys(this.data, true);
},
canPartDelivery() {
if (!this.allowOperation.ship) return false;
const status = this.orderInfo.order && this.orderInfo.order.orderStatus;
return (
this.$route.query.orderType !== "VIRTUAL" &&
!this.isNonPhysicalOrder &&
["UNDELIVERED", "PARTS_DELIVERED"].includes(status) &&
this.deliverableOrderItems.length > 0
);
@@ -845,6 +919,28 @@ export default {
},
},
methods: {
formatCardKeyStatus,
cardKeyFulfillAlertType,
resolveCardKeyFulfillMessage,
lineCardKeyRows(line) {
return line.cardKeys || [];
},
copyCardKey(row) {
const text = `卡号:${row.cardNo || ""}\n卡密${row.cardSecret || ""}`;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
this.$Message.success("已复制到剪贴板");
});
} else {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
this.$Message.success("已复制到剪贴板");
}
},
getPromotionText(row) {
let resultText = "";
if (row && row.promotionType) {

View File

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

View File

@@ -6,7 +6,14 @@
<el-input v-model="form.promotionName" style="width: 320px" />
</el-form-item>
<el-form-item label="活动时间" required>
<el-date-picker v-model="form.rangeTime" type="datetimerange" style="width: 360px" />
<el-date-picker
v-model="form.rangeTime"
class="flash-discount-range-picker"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
<el-form-item label="限购数量">
<el-input-number v-model="form.limitNum" :min="0" /> <span class="tip">0 表示不限</span>
@@ -16,12 +23,22 @@
</el-form-item>
<el-form-item label="活动商品">
<el-button type="primary" @click="openGoods">选择商品</el-button>
<el-table v-if="form.promotionGoodsList.length" :data="form.promotionGoodsList" border class="mt_10">
<el-table-column prop="goodsName" label="商品" />
<el-table-column prop="originalPrice" label="原价" width="100" />
<el-table-column label="活动价" width="140">
<el-table
v-if="form.promotionGoodsList.length"
:data="form.promotionGoodsList"
border
class="mt_10 flash-discount-goods-table"
>
<el-table-column prop="goodsName" label="商品" width="180" show-overflow-tooltip />
<el-table-column prop="originalPrice" label="原价" width="90" align="center" />
<el-table-column label="活动价" width="150" align="center">
<template #default="{ row }">
<el-input-number v-model="row.price" :min="0.01" :precision="2" />
<el-input-number
v-model="row.price"
:min="promotionPriceMin(row.goodsType)"
:precision="2"
size="small"
/>
</template>
</el-table-column>
</el-table>
@@ -38,6 +55,7 @@
<script>
import { getFlashDiscountDetail, saveFlashDiscount, editFlashDiscount } from "@/api/promotion.js";
import { promotionPriceMin } from "@/constants/goodsType";
export default {
data() {
@@ -63,6 +81,7 @@ export default {
}
},
methods: {
promotionPriceMin,
openGoods() {
this.$refs.liliDialog.open("goods");
},
@@ -76,6 +95,7 @@ export default {
originalPrice: g.price,
price: g.price,
quantity: g.quantity,
goodsType: g.goodsType,
});
}
});
@@ -83,9 +103,18 @@ export default {
submit() {
const payload = { ...this.form };
if (payload.rangeTime && payload.rangeTime.length === 2) {
payload.startTime = payload.rangeTime[0];
payload.endTime = payload.rangeTime[1];
const start =
payload.rangeTime[0] instanceof Date
? payload.rangeTime[0]
: new Date(payload.rangeTime[0]);
const end =
payload.rangeTime[1] instanceof Date
? payload.rangeTime[1]
: new Date(payload.rangeTime[1]);
payload.startTime = this.$filters.unixToDate(start.getTime() / 1000);
payload.endTime = this.$filters.unixToDate(end.getTime() / 1000);
}
delete payload.rangeTime;
const req = payload.id ? editFlashDiscount(payload) : saveFlashDiscount(payload);
req.then((res) => {
if (res.success) {
@@ -101,4 +130,16 @@ export default {
<style scoped>
.tip { margin-left: 8px; color: #999; }
.mt_10 { margin-top: 10px; }
:deep(.flash-discount-range-picker.el-date-editor--datetimerange) {
width: 380px;
max-width: 380px;
flex-grow: 0;
--el-date-editor-width: 380px;
}
.flash-discount-goods-table {
width: fit-content;
max-width: 100%;
}
</style>

View File

@@ -113,23 +113,26 @@
</el-select>
</el-form-item>
<el-form-item v-if="form.giftFlag" label="赠品" prop="giftId">
<el-select
v-model="form.giftId"
filterable
remote
:remote-method="getGiftList"
placeholder="输入赠品名称搜索"
:disabled="fieldDisabled"
:loading="giftLoading"
style="width: 260px"
>
<el-option
v-for="item in giftList"
:value="item.id"
:key="item.id"
:label="item.goodsName"
/>
</el-select>
<div class="gift-field">
<el-select
v-model="form.giftId"
filterable
remote
:remote-method="getGiftList"
placeholder="输入赠品名称搜索"
:disabled="fieldDisabled"
:loading="giftLoading"
style="width: 260px"
>
<el-option
v-for="item in giftList"
:value="item.id"
:key="item.id"
:label="giftOptionLabel(item)"
/>
</el-select>
<p class="describe describe-block">电子卡券赠品支付后自动发卡卡池不足时仅取消赠品子单不影响主订单</p>
</div>
</el-form-item>
<el-form-item v-if="form.pointFlag" label="赠积分" prop="point">
<el-input
@@ -241,6 +244,7 @@ import {
import { getGoodsSkuListDataSeller } from "@/api/goods";
import { regular } from "@/utils";
import vueQr from "vue-qr";
import { goodsTypeLabel } from "@/constants/goodsType";
export default {
name: "add-full-discount",
@@ -313,6 +317,16 @@ export default {
this.getGiftList();
},
methods: {
giftOptionLabel(item) {
if (!item) return "";
const typeLabel =
item.goodsType === "E_COUPON" ? ` [${goodsTypeLabel(item.goodsType)}]` : "";
const stock =
item.goodsType === "E_COUPON" && item.quantity != null
? ` · 库存${item.quantity}`
: "";
return `${item.goodsName || ""}${typeLabel}${stock}`;
},
closeCurrentPage() {
this.$router.back();
},
@@ -442,6 +456,7 @@ export default {
thumbnail: e.thumbnail,
skuId: e.id,
goodsId: e.goodsId,
goodsType: e.goodsType,
});
});
this.form.promotionGoodsList = list;
@@ -500,6 +515,17 @@ h4 {
margin-left: 10px;
color: #999;
}
.describe-block {
margin-left: 0;
margin-top: 4px;
line-height: 1.5;
max-width: 260px;
}
.gift-field {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.link-text {
color: #409eff;
cursor: pointer;

View File

@@ -6,7 +6,14 @@
<el-input v-model="form.promotionName" style="width: 320px" />
</el-form-item>
<el-form-item label="活动时间" required>
<el-date-picker v-model="form.rangeTime" type="datetimerange" style="width: 360px" />
<el-date-picker
v-model="form.rangeTime"
class="nth-item-range-picker"
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
/>
</el-form-item>
<el-form-item label="第N件" required>
<el-input-number v-model="form.nthNum" :min="2" />
@@ -92,9 +99,18 @@ export default {
submit() {
const payload = { ...this.form };
if (payload.rangeTime && payload.rangeTime.length === 2) {
payload.startTime = payload.rangeTime[0];
payload.endTime = payload.rangeTime[1];
const start =
payload.rangeTime[0] instanceof Date
? payload.rangeTime[0]
: new Date(payload.rangeTime[0]);
const end =
payload.rangeTime[1] instanceof Date
? payload.rangeTime[1]
: new Date(payload.rangeTime[1]);
payload.startTime = this.$filters.unixToDate(start.getTime() / 1000);
payload.endTime = this.$filters.unixToDate(end.getTime() / 1000);
}
delete payload.rangeTime;
const req = payload.id ? editNthItemDiscount(payload) : saveNthItemDiscount(payload);
req.then((res) => {
if (res.success) {
@@ -110,4 +126,11 @@ export default {
<style scoped>
.tip { margin-left: 8px; color: #999; }
.mt_10 { margin-top: 10px; }
:deep(.nth-item-range-picker.el-date-editor--datetimerange) {
width: 380px;
max-width: 380px;
flex-grow: 0;
--el-date-editor-width: 380px;
}
</style>

View File

@@ -44,7 +44,7 @@
<el-input-number
v-if="row"
v-model="row.price"
:min="0.01"
:min="promotionPriceMin(row.goodsType)"
:precision="2"
controls-position="right"
size="small"
@@ -133,6 +133,7 @@
import { getPintuanGoodsList, getPintuanDetail, editPintuan } from "@/api/promotion.js";
import liliDialog from "@/views/lili-dialog";
import vueQr from "vue-qr";
import { promotionPriceMin } from "@/constants/goodsType";
export default {
components: { liliDialog, vueQr },
@@ -158,6 +159,7 @@ export default {
},
},
methods: {
promotionPriceMin,
promotionStatusText(status) {
const map = {
NEW: "未开始",

View File

@@ -68,7 +68,7 @@
<el-input-number
v-if="row"
v-model="row.price"
:min="0.01"
:min="promotionPriceMin(row.goodsType)"
:precision="2"
:disabled="row.promotionApplyStatus === 'PASS'"
controls-position="right"
@@ -185,6 +185,7 @@ import {
delSeckillGoods,
} from "@/api/promotion.js";
import liliDialog from "@/views/lili-dialog";
import { promotionPriceMin } from "@/constants/goodsType";
export default {
components: {
@@ -215,6 +216,7 @@ export default {
},
},
methods: {
promotionPriceMin,
seckillStatusText(status) {
const map = {
NEW: "未开始",
@@ -349,6 +351,7 @@ export default {
storeName: item.storeName,
skuId: item.id,
timeLine,
goodsType: item.goodsType,
};
});
this.goodsList[this.tabIndex].list = list;