feat:【ele】【mall】spu 代码迁移
This commit is contained in:
@@ -18,7 +18,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: '商品添加',
|
title: '商品添加',
|
||||||
activePath: '/mall/product/spu',
|
activePath: '/mall/product/spu',
|
||||||
},
|
},
|
||||||
component: () => import('#/views/mall/product/spu/modules/form.vue'),
|
component: () => import('#/views/mall/product/spu/form/index.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: String.raw`spu/edit/:id(\d+)`,
|
path: String.raw`spu/edit/:id(\d+)`,
|
||||||
@@ -27,25 +27,16 @@ const routes: RouteRecordRaw[] = [
|
|||||||
title: '商品编辑',
|
title: '商品编辑',
|
||||||
activePath: '/mall/product/spu',
|
activePath: '/mall/product/spu',
|
||||||
},
|
},
|
||||||
component: () => import('#/views/mall/product/spu/modules/form.vue'),
|
component: () => import('#/views/mall/product/spu/form/index.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: String.raw`spu/detail/:id(\d+)`,
|
path: String.raw`spu/detail/:id(\d+)`,
|
||||||
name: 'ProductSpuDetail',
|
name: 'ProductSpuDetail',
|
||||||
meta: {
|
meta: {
|
||||||
title: '商品详情',
|
title: '商品详情',
|
||||||
activePath: '/crm/business',
|
|
||||||
},
|
|
||||||
component: () => import('#/views/mall/product/spu/modules/detail.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/product/spu',
|
|
||||||
name: 'ProductSpu',
|
|
||||||
meta: {
|
|
||||||
title: '商品列表',
|
|
||||||
activePath: '/mall/product/spu',
|
activePath: '/mall/product/spu',
|
||||||
},
|
},
|
||||||
component: () => import('#/views/mall/product/spu/index.vue'),
|
component: () => import('#/views/mall/product/spu/form/index.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
export * from './property-util';
|
||||||
|
export { default as SkuList } from './sku-list.vue';
|
||||||
export { default as SkuTableSelect } from './sku-table-select.vue';
|
export { default as SkuTableSelect } from './sku-table-select.vue';
|
||||||
|
export { default as SpuAndSkuList } from './spu-and-sku-list.vue';
|
||||||
|
export { default as SpuSkuSelect } from './spu-select.vue';
|
||||||
export { default as SpuShowcase } from './spu-showcase.vue';
|
export { default as SpuShowcase } from './spu-showcase.vue';
|
||||||
export { default as SpuTableSelect } from './spu-table-select.vue';
|
export { default as SpuTableSelect } from './spu-table-select.vue';
|
||||||
|
export * from './type';
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
import type { PropertyAndValues } from '#/views/mall/product/spu/components/type';
|
||||||
|
|
||||||
|
/** 获得商品的规格列表 - 商品相关的公共函数(被其它模块如 promotion 使用) */
|
||||||
|
const getPropertyList = (spu: MallSpuApi.Spu): PropertyAndValues[] => {
|
||||||
|
// 直接拿返回的 skus 属性逆向生成出 propertyList
|
||||||
|
const properties: PropertyAndValues[] = [];
|
||||||
|
// 只有是多规格才处理
|
||||||
|
if (spu.specType) {
|
||||||
|
spu.skus?.forEach((sku) => {
|
||||||
|
sku.properties?.forEach(
|
||||||
|
({ propertyId, propertyName, valueId, valueName }) => {
|
||||||
|
// 添加属性
|
||||||
|
if (!properties?.some((item) => item.id === propertyId)) {
|
||||||
|
properties.push({
|
||||||
|
id: propertyId!,
|
||||||
|
name: propertyName!,
|
||||||
|
values: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 添加属性值
|
||||||
|
const index = properties?.findIndex((item) => item.id === propertyId);
|
||||||
|
if (
|
||||||
|
!properties[index]?.values?.some((value) => value.id === valueId)
|
||||||
|
) {
|
||||||
|
properties[index]?.values?.push({ id: valueId!, name: valueName! });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return properties;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { getPropertyList };
|
||||||
641
apps/web-ele/src/views/mall/product/spu/components/sku-list.vue
Normal file
641
apps/web-ele/src/views/mall/product/spu/components/sku-list.vue
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
import type {
|
||||||
|
PropertyAndValues,
|
||||||
|
RuleConfig,
|
||||||
|
} from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
copyValueToTarget,
|
||||||
|
formatToFraction,
|
||||||
|
getNestedValue,
|
||||||
|
isEmpty,
|
||||||
|
} from '@vben/utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElButton,
|
||||||
|
ElImage,
|
||||||
|
ElInput,
|
||||||
|
ElInputNumber,
|
||||||
|
ElMessage,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
|
||||||
|
import { ImageUpload } from '#/components/upload';
|
||||||
|
|
||||||
|
defineOptions({ name: 'SkuList' });
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
isActivityComponent?: boolean; // 是否作为 sku 活动配置组件
|
||||||
|
isBatch?: boolean; // 是否作为批量操作组件
|
||||||
|
isComponent?: boolean; // 是否作为 sku 选择组件
|
||||||
|
isDetail?: boolean; // 是否作为 sku 详情组件
|
||||||
|
propertyList?: PropertyAndValues[];
|
||||||
|
propFormData?: MallSpuApi.Spu;
|
||||||
|
ruleConfig?: RuleConfig[];
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
propFormData: () => ({}) as MallSpuApi.Spu,
|
||||||
|
propertyList: () => [],
|
||||||
|
ruleConfig: () => [],
|
||||||
|
isBatch: false,
|
||||||
|
isDetail: false,
|
||||||
|
isComponent: false,
|
||||||
|
isActivityComponent: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'selectionChange', value: MallSpuApi.Sku[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { isBatch, isDetail, isComponent, isActivityComponent } = props;
|
||||||
|
|
||||||
|
const formData: Ref<MallSpuApi.Spu | undefined> = ref<MallSpuApi.Spu>();
|
||||||
|
const tableHeaders = ref<{ label: string; prop: string }[]>([]);
|
||||||
|
|
||||||
|
/** 创建空 SKU 数据 */
|
||||||
|
function createEmptySku(): MallSpuApi.Sku {
|
||||||
|
return {
|
||||||
|
price: 0,
|
||||||
|
marketPrice: 0,
|
||||||
|
costPrice: 0,
|
||||||
|
barCode: '',
|
||||||
|
picUrl: '',
|
||||||
|
stock: 0,
|
||||||
|
weight: 0,
|
||||||
|
volume: 0,
|
||||||
|
firstBrokeragePrice: 0,
|
||||||
|
secondBrokeragePrice: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const skuList = ref<MallSpuApi.Sku[]>([createEmptySku()]);
|
||||||
|
|
||||||
|
/** 批量添加 */
|
||||||
|
function batchAdd() {
|
||||||
|
validateProperty();
|
||||||
|
formData.value!.skus!.forEach((item: MallSpuApi.Sku) => {
|
||||||
|
copyValueToTarget(item, skuList.value[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验商品属性属性值 */
|
||||||
|
function validateProperty() {
|
||||||
|
// 校验商品属性属性值是否为空,有一个为空都不给过
|
||||||
|
const warningInfo = '存在属性属性值为空,请先检查完善属性值后重试!!!';
|
||||||
|
for (const item of props.propertyList as PropertyAndValues[]) {
|
||||||
|
if (!item.values || isEmpty(item.values)) {
|
||||||
|
ElMessage.warning(warningInfo);
|
||||||
|
throw new Error(warningInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 SKU */
|
||||||
|
function deleteSku(row: MallSpuApi.Sku) {
|
||||||
|
const index = formData.value!.skus!.findIndex(
|
||||||
|
(sku: MallSpuApi.Sku) =>
|
||||||
|
JSON.stringify(sku.properties) === JSON.stringify(row.properties),
|
||||||
|
);
|
||||||
|
if (index !== -1) {
|
||||||
|
formData.value!.skus!.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验 SKU 数据:保存时,每个商品规格的表单要校验。例如:销售金额最低是 0.01 */
|
||||||
|
function validateSku() {
|
||||||
|
validateProperty();
|
||||||
|
let warningInfo = '请检查商品各行相关属性配置,';
|
||||||
|
let validate = true;
|
||||||
|
|
||||||
|
for (const sku of formData.value!.skus!) {
|
||||||
|
for (const rule of props?.ruleConfig as RuleConfig[]) {
|
||||||
|
const value = getNestedValue(sku, rule.name);
|
||||||
|
if (!rule.rule(value)) {
|
||||||
|
validate = false;
|
||||||
|
warningInfo += rule.message;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validate) {
|
||||||
|
ElMessage.warning(warningInfo);
|
||||||
|
throw new Error(warningInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选择时触发
|
||||||
|
*
|
||||||
|
* @param {object} param0 参数对象
|
||||||
|
* @param {MallSpuApi.Sku[]} param0.records 传递过来的选中的 sku 是一个数组
|
||||||
|
*/
|
||||||
|
function handleSelectionChange({ records }: { records: MallSpuApi.Sku[] }) {
|
||||||
|
emit('selectionChange', records);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将传进来的值赋值给 skuList */
|
||||||
|
watch(
|
||||||
|
() => props.propFormData,
|
||||||
|
(data) => {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
formData.value = data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 生成表数据 */
|
||||||
|
function generateTableData(propertyList: PropertyAndValues[]) {
|
||||||
|
const propertyValues = propertyList.map((item: PropertyAndValues) =>
|
||||||
|
(item.values || []).map((v: { id: number; name: string }) => ({
|
||||||
|
propertyId: item.id,
|
||||||
|
propertyName: item.name,
|
||||||
|
valueId: v.id,
|
||||||
|
valueName: v.name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildSkuList = build(propertyValues);
|
||||||
|
|
||||||
|
// 如果回显的 sku 属性和添加的属性不一致则重置 skus 列表
|
||||||
|
if (!validateData(propertyList)) {
|
||||||
|
formData.value!.skus = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of buildSkuList) {
|
||||||
|
const properties = Array.isArray(item) ? item : [item];
|
||||||
|
const row = {
|
||||||
|
...createEmptySku(),
|
||||||
|
properties,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果存在属性相同的 sku 则不做处理
|
||||||
|
const exists = formData.value!.skus!.some(
|
||||||
|
(sku: MallSpuApi.Sku) =>
|
||||||
|
JSON.stringify(sku.properties) === JSON.stringify(row.properties),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
formData.value!.skus!.push(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成 skus 前置校验 */
|
||||||
|
function validateData(propertyList: PropertyAndValues[]): boolean {
|
||||||
|
const skuPropertyIds: number[] = [];
|
||||||
|
formData.value!.skus!.forEach((sku: MallSpuApi.Sku) =>
|
||||||
|
sku.properties
|
||||||
|
?.map((property: MallSpuApi.Property) => property.propertyId)
|
||||||
|
?.forEach((propertyId?: number) => {
|
||||||
|
if (!skuPropertyIds.includes(propertyId!)) {
|
||||||
|
skuPropertyIds.push(propertyId!);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const propertyIds = propertyList.map((item: PropertyAndValues) => item.id);
|
||||||
|
return skuPropertyIds.length === propertyIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建所有排列组合 */
|
||||||
|
function build(
|
||||||
|
propertyValuesList: MallSpuApi.Property[][],
|
||||||
|
): (MallSpuApi.Property | MallSpuApi.Property[])[] {
|
||||||
|
if (propertyValuesList.length === 0) {
|
||||||
|
return [];
|
||||||
|
} else if (propertyValuesList.length === 1) {
|
||||||
|
return propertyValuesList[0] || [];
|
||||||
|
} else {
|
||||||
|
const result: MallSpuApi.Property[][] = [];
|
||||||
|
const rest = build(propertyValuesList.slice(1));
|
||||||
|
const firstList = propertyValuesList[0];
|
||||||
|
if (!firstList) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const element of firstList) {
|
||||||
|
for (const element_ of rest) {
|
||||||
|
// 第一次不是数组结构,后面的都是数组结构
|
||||||
|
if (Array.isArray(element_)) {
|
||||||
|
result.push([element!, ...(element_ as MallSpuApi.Property[])]);
|
||||||
|
} else {
|
||||||
|
result.push([element!, element_ as MallSpuApi.Property]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 监听属性列表,生成相关参数和表头 */
|
||||||
|
watch(
|
||||||
|
() => props.propertyList as PropertyAndValues[],
|
||||||
|
(propertyList: PropertyAndValues[]) => {
|
||||||
|
// 如果不是多规格则结束
|
||||||
|
if (!formData.value!.specType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果当前组件作为批量添加数据使用,则重置表数据
|
||||||
|
if (props.isBatch) {
|
||||||
|
skuList.value = [createEmptySku()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断代理对象是否为空
|
||||||
|
if (JSON.stringify(propertyList) === '[]') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置并生成表头
|
||||||
|
tableHeaders.value = propertyList.map((item, index) => ({
|
||||||
|
prop: `name${index}`,
|
||||||
|
label: item.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 如果回显的 sku 属性和添加的属性一致则不处理
|
||||||
|
if (validateData(propertyList)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新属性没有属性值也不做处理
|
||||||
|
if (propertyList.some((item) => !item.values || isEmpty(item.values))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 table 数据,即 sku 列表
|
||||||
|
generateTableData(propertyList);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const activitySkuListRef = ref();
|
||||||
|
|
||||||
|
/** 获取 SKU 表格引用 */
|
||||||
|
function getSkuTableRef() {
|
||||||
|
return activitySkuListRef.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
generateTableData,
|
||||||
|
validateSku,
|
||||||
|
getSkuTableRef,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<!-- 情况一:添加/修改 -->
|
||||||
|
<VxeTable
|
||||||
|
v-if="!isDetail && !isActivityComponent"
|
||||||
|
:data="isBatch ? skuList : formData?.skus || []"
|
||||||
|
border
|
||||||
|
max-height="500"
|
||||||
|
:column-config="{
|
||||||
|
resizable: true,
|
||||||
|
}"
|
||||||
|
:resizable-config="{
|
||||||
|
dragMode: 'fixed',
|
||||||
|
}"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<VxeColumn align="center" title="图片" width="120" fixed="left">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ImageUpload
|
||||||
|
v-model:value="row.picUrl"
|
||||||
|
:max-number="1"
|
||||||
|
:max-size="2"
|
||||||
|
:show-description="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<template v-if="formData?.specType && !isBatch">
|
||||||
|
<!-- 根据商品属性动态添加 -->
|
||||||
|
<VxeColumn
|
||||||
|
v-for="(item, index) in tableHeaders"
|
||||||
|
:key="index"
|
||||||
|
:title="item.label"
|
||||||
|
align="center"
|
||||||
|
fixed="left"
|
||||||
|
min-width="80"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-bold text-[#40aaff]">
|
||||||
|
{{ row.properties?.[index]?.valueName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
</template>
|
||||||
|
<VxeColumn align="center" title="商品条码" width="168">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElInput v-model="row.barCode" class="w-full" />
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="销售价" width="168">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="row.price"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="市场价" width="168">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="row.marketPrice"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="成本价" width="168">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="row.costPrice"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="库存" width="168">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="row.stock"
|
||||||
|
:min="0"
|
||||||
|
class="w-full"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="重量(kg)" width="168">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="row.weight"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="体积(m^3)" width="168">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="row.volume"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<template v-if="formData?.subCommissionType">
|
||||||
|
<VxeColumn align="center" title="一级返佣(元)" width="168">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="row.firstBrokeragePrice"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="二级返佣(元)" width="168">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="row.secondBrokeragePrice"
|
||||||
|
:min="0"
|
||||||
|
:precision="2"
|
||||||
|
:step="0.1"
|
||||||
|
class="w-full"
|
||||||
|
controls-position="right"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
</template>
|
||||||
|
<VxeColumn
|
||||||
|
v-if="formData?.specType"
|
||||||
|
align="center"
|
||||||
|
fixed="right"
|
||||||
|
title="操作"
|
||||||
|
width="100"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton
|
||||||
|
v-if="isBatch"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="batchAdd"
|
||||||
|
>
|
||||||
|
批量添加
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
v-else
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
size="small"
|
||||||
|
@click="deleteSku(row)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
</VxeTable>
|
||||||
|
|
||||||
|
<!-- 情况二:详情 -->
|
||||||
|
<VxeTable
|
||||||
|
v-if="isDetail"
|
||||||
|
ref="activitySkuListRef"
|
||||||
|
:data="formData?.skus || []"
|
||||||
|
border
|
||||||
|
max-height="500"
|
||||||
|
size="small"
|
||||||
|
:column-config="{
|
||||||
|
resizable: true,
|
||||||
|
}"
|
||||||
|
:resizable-config="{
|
||||||
|
dragMode: 'fixed',
|
||||||
|
}"
|
||||||
|
:checkbox-config="isComponent ? { reserve: true } : undefined"
|
||||||
|
@checkbox-change="handleSelectionChange"
|
||||||
|
@checkbox-all="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
|
||||||
|
<VxeColumn align="center" title="图片" max-width="140" fixed="left">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElImage
|
||||||
|
v-if="row.picUrl"
|
||||||
|
:src="row.picUrl"
|
||||||
|
class="h-[50px] w-[50px] cursor-pointer"
|
||||||
|
:preview-src-list="[row.picUrl]"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<template v-if="formData?.specType && !isBatch">
|
||||||
|
<!-- 根据商品属性动态添加 -->
|
||||||
|
<VxeColumn
|
||||||
|
v-for="(item, index) in tableHeaders"
|
||||||
|
:key="index"
|
||||||
|
:title="item.label"
|
||||||
|
align="center"
|
||||||
|
max-width="80"
|
||||||
|
fixed="left"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-bold text-[#40aaff]">
|
||||||
|
{{ row.properties?.[index]?.valueName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
</template>
|
||||||
|
<VxeColumn align="center" title="商品条码" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.barCode }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="销售价(元)" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.price }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="市场价(元)" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.marketPrice }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="成本价(元)" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.costPrice }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="库存" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.stock }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="重量(kg)" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.weight }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="体积(m^3)" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.volume }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<template v-if="formData?.subCommissionType">
|
||||||
|
<VxeColumn align="center" title="一级返佣(元)" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.firstBrokeragePrice }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="二级返佣(元)" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.secondBrokeragePrice }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
</template>
|
||||||
|
</VxeTable>
|
||||||
|
|
||||||
|
<!-- 情况三:作为活动组件 -->
|
||||||
|
<VxeTable
|
||||||
|
v-if="isActivityComponent"
|
||||||
|
:data="formData?.skus || []"
|
||||||
|
border
|
||||||
|
max-height="500"
|
||||||
|
size="small"
|
||||||
|
:column-config="{
|
||||||
|
resizable: true,
|
||||||
|
}"
|
||||||
|
:resizable-config="{
|
||||||
|
dragMode: 'fixed',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
|
||||||
|
<VxeColumn align="center" title="图片" max-width="140" fixed="left">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElImage
|
||||||
|
:src="row.picUrl"
|
||||||
|
class="h-[60px] w-[60px] cursor-pointer"
|
||||||
|
:preview-src-list="[row.picUrl]"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<template v-if="formData?.specType">
|
||||||
|
<!-- 根据商品属性动态添加 -->
|
||||||
|
<VxeColumn
|
||||||
|
v-for="(item, index) in tableHeaders"
|
||||||
|
:key="index"
|
||||||
|
:title="item.label"
|
||||||
|
align="center"
|
||||||
|
width="80"
|
||||||
|
fixed="left"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-bold text-[#40aaff]">
|
||||||
|
{{ row.properties?.[index]?.valueName }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
</template>
|
||||||
|
<VxeColumn align="center" title="商品条码" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.barCode }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="销售价(元)" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatToFraction(row.price) }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="市场价(元)" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatToFraction(row.marketPrice) }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="成本价(元)" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatToFraction(row.costPrice) }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn align="center" title="库存" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.stock }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<!-- 方便扩展每个活动配置的属性不一样 -->
|
||||||
|
<slot name="extension"></slot>
|
||||||
|
</VxeTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<script generic="T extends MallSpuApi.Spu" lang="ts" setup>
|
||||||
|
import type { MallSpuApi, RuleConfig, SpuProperty } from './type';
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { confirm } from '@vben/common-ui';
|
||||||
|
import { formatToFraction } from '@vben/utils';
|
||||||
|
|
||||||
|
import { ElButton, ElImage } from 'element-plus';
|
||||||
|
|
||||||
|
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import SkuList from './sku-list.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'PromotionSpuAndSkuList' });
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
deletable?: boolean; // SPU 是否可删除
|
||||||
|
ruleConfig: RuleConfig[];
|
||||||
|
spuList: T[];
|
||||||
|
spuPropertyListP: SpuProperty<T>[];
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
deletable: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'delete', spuId: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const spuData = ref<MallSpuApi.Spu[]>([]); // spu 详情数据列表
|
||||||
|
const skuListRef = ref<InstanceType<typeof SkuList> | undefined>(); // 商品属性列表Ref
|
||||||
|
const spuPropertyList = ref<SpuProperty<T>[]>([]); // spuId 对应的 sku 的属性列表
|
||||||
|
const expandRowKeys = ref<string[]>([]); // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有 sku 活动配置
|
||||||
|
*
|
||||||
|
* @param extendedAttribute 在 sku 上扩展的属性,例:秒杀活动 sku 扩展属性 productConfig 请参考 seckillActivity.ts
|
||||||
|
*/
|
||||||
|
function getSkuConfigs(extendedAttribute: string) {
|
||||||
|
// 验证 SKU 数据(如果有 ref 的话)
|
||||||
|
if (skuListRef.value) {
|
||||||
|
skuListRef.value.validateSku();
|
||||||
|
}
|
||||||
|
const seckillProducts: unknown[] = [];
|
||||||
|
spuPropertyList.value.forEach((item) => {
|
||||||
|
item.spuDetail.skus?.forEach((sku) => {
|
||||||
|
const extendedValue = (sku as Record<string, unknown>)[extendedAttribute];
|
||||||
|
if (extendedValue) {
|
||||||
|
seckillProducts.push(extendedValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return seckillProducts;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ getSkuConfigs }); // 暴露出给表单提交时使用
|
||||||
|
|
||||||
|
/** 多选时可以删除 SPU */
|
||||||
|
async function deleteSpu(spuId: number) {
|
||||||
|
await confirm(`是否删除商品编号为${spuId}的数据?`);
|
||||||
|
const index = spuData.value.findIndex((item) => item.id === spuId);
|
||||||
|
if (index !== -1) {
|
||||||
|
spuData.value.splice(index, 1);
|
||||||
|
emit('delete', spuId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将传进来的值赋值给 spuData */
|
||||||
|
watch(
|
||||||
|
() => props.spuList,
|
||||||
|
(data) => {
|
||||||
|
if (!data) return;
|
||||||
|
spuData.value = data as MallSpuApi.Spu[];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 将传进来的值赋值给 spuPropertyList */
|
||||||
|
watch(
|
||||||
|
() => props.spuPropertyListP,
|
||||||
|
(data) => {
|
||||||
|
if (!data) return;
|
||||||
|
spuPropertyList.value = data as SpuProperty<T>[];
|
||||||
|
// 解决如果之前选择的是单规格 spu 的话后面选择多规格 sku 多规格属性信息不展示的问题。解决方法:让 SkuList 组件重新渲染(行折叠会干掉包含的组件展开时会重新加载)
|
||||||
|
setTimeout(() => {
|
||||||
|
expandRowKeys.value = data.map((item) => String(item.spuId));
|
||||||
|
}, 200);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VxeTable
|
||||||
|
:data="spuData"
|
||||||
|
:expand-row-keys="expandRowKeys"
|
||||||
|
:row-config="{
|
||||||
|
keyField: 'id',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<VxeColumn type="expand" width="30">
|
||||||
|
<template #content="{ row }">
|
||||||
|
<SkuList
|
||||||
|
ref="skuListRef"
|
||||||
|
:is-activity-component="true"
|
||||||
|
:prop-form-data="
|
||||||
|
spuPropertyList.find((item) => item.spuId === row.id)?.spuDetail
|
||||||
|
"
|
||||||
|
:property-list="
|
||||||
|
spuPropertyList.find((item) => item.spuId === row.id)?.propertyList
|
||||||
|
"
|
||||||
|
:rule-config="ruleConfig"
|
||||||
|
>
|
||||||
|
<template #extension>
|
||||||
|
<slot></slot>
|
||||||
|
</template>
|
||||||
|
</SkuList>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn field="id" align="center" title="商品编号" />
|
||||||
|
<VxeColumn title="商品图" min-width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElImage
|
||||||
|
v-if="row.picUrl"
|
||||||
|
:src="row.picUrl"
|
||||||
|
class="h-[30px] w-[30px] cursor-pointer"
|
||||||
|
:preview-src-list="[row.picUrl]"
|
||||||
|
fit="cover"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn
|
||||||
|
field="name"
|
||||||
|
title="商品名称"
|
||||||
|
min-width="300"
|
||||||
|
show-overflow="tooltip"
|
||||||
|
/>
|
||||||
|
<VxeColumn align="center" title="商品售价" min-width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatToFraction(row.price) }}
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
<VxeColumn field="salesCount" align="center" title="销量" min-width="90" />
|
||||||
|
<VxeColumn field="stock" align="center" title="库存" min-width="90" />
|
||||||
|
<VxeColumn
|
||||||
|
v-if="spuData.length > 1 && deletable"
|
||||||
|
align="center"
|
||||||
|
title="操作"
|
||||||
|
min-width="90"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElButton type="danger" link @click="deleteSpu(row.id)">删除</ElButton>
|
||||||
|
</template>
|
||||||
|
</VxeColumn>
|
||||||
|
</VxeTable>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type { Ref } from 'vue';
|
||||||
|
|
||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type { VxeGridProps, VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { MallCategoryApi } from '#/api/mall/product/category';
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
import { fenToYuan } from '@vben/utils';
|
||||||
|
|
||||||
|
import { getRangePickerDefaultProps } from '#/utils';
|
||||||
|
|
||||||
|
/** 列表的搜索表单 */
|
||||||
|
export function useGridFormSchema(
|
||||||
|
categoryTreeList: Ref<MallCategoryApi.Category[] | unknown[]>,
|
||||||
|
): VbenFormSchema[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '商品名称',
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入商品名称',
|
||||||
|
clearable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'categoryId',
|
||||||
|
label: '商品分类',
|
||||||
|
component: 'TreeSelect',
|
||||||
|
componentProps: {
|
||||||
|
data: computed(() => categoryTreeList.value),
|
||||||
|
props: {
|
||||||
|
label: 'name',
|
||||||
|
value: 'id',
|
||||||
|
},
|
||||||
|
checkStrictly: true,
|
||||||
|
placeholder: '请选择商品分类',
|
||||||
|
clearable: true,
|
||||||
|
filterable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fieldName: 'createTime',
|
||||||
|
label: '创建时间',
|
||||||
|
component: 'RangePicker',
|
||||||
|
componentProps: {
|
||||||
|
...getRangePickerDefaultProps(),
|
||||||
|
clearable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表的字段 */
|
||||||
|
export function useGridColumns(
|
||||||
|
isSelectSku: boolean,
|
||||||
|
): VxeTableGridOptions['columns'] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'expand',
|
||||||
|
width: 30,
|
||||||
|
visible: isSelectSku,
|
||||||
|
slots: { content: 'expand_content' },
|
||||||
|
},
|
||||||
|
{ type: 'checkbox', width: 55 },
|
||||||
|
{
|
||||||
|
field: 'id',
|
||||||
|
title: '商品编号',
|
||||||
|
minWidth: 100,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'picUrl',
|
||||||
|
title: '商品图',
|
||||||
|
width: 100,
|
||||||
|
align: 'center',
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellImage',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'name',
|
||||||
|
title: '商品名称',
|
||||||
|
minWidth: 300,
|
||||||
|
showOverflow: 'tooltip',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'price',
|
||||||
|
title: '商品售价',
|
||||||
|
minWidth: 90,
|
||||||
|
align: 'center',
|
||||||
|
formatter: 'formatAmount2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'salesCount',
|
||||||
|
title: '销量',
|
||||||
|
minWidth: 90,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'stock',
|
||||||
|
title: '库存',
|
||||||
|
minWidth: 90,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'sort',
|
||||||
|
title: '排序',
|
||||||
|
minWidth: 70,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'createTime',
|
||||||
|
title: '创建时间',
|
||||||
|
width: 180,
|
||||||
|
align: 'center',
|
||||||
|
formatter: 'formatDateTime',
|
||||||
|
},
|
||||||
|
] as VxeTableGridOptions['columns'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SKU 列表的字段 */
|
||||||
|
export function useSkuGridColumns(): VxeGridProps['columns'] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'radio',
|
||||||
|
width: 55,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'id',
|
||||||
|
title: '商品编号',
|
||||||
|
minWidth: 100,
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'picUrl',
|
||||||
|
title: '图片',
|
||||||
|
width: 100,
|
||||||
|
align: 'center',
|
||||||
|
cellRender: {
|
||||||
|
name: 'CellImage',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'properties',
|
||||||
|
title: '规格',
|
||||||
|
minWidth: 120,
|
||||||
|
align: 'center',
|
||||||
|
formatter: ({ cellValue }) => {
|
||||||
|
return (
|
||||||
|
cellValue?.map((p: MallSpuApi.Property) => p.valueName)?.join(' ') ||
|
||||||
|
'-'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'price',
|
||||||
|
title: '销售价(元)',
|
||||||
|
width: 120,
|
||||||
|
align: 'center',
|
||||||
|
formatter: ({ cellValue }) => {
|
||||||
|
return fenToYuan(cellValue);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { PropertyAndValues } from './type';
|
||||||
|
|
||||||
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
|
import type { MallCategoryApi } from '#/api/mall/product/category';
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
|
import { computed, nextTick, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { handleTree } from '@vben/utils';
|
||||||
|
|
||||||
|
import { ElDialog, ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { getCategoryList } from '#/api/mall/product/category';
|
||||||
|
import { getSpu, getSpuPage } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
|
import { getPropertyList } from './property-util';
|
||||||
|
import SkuList from './sku-list.vue';
|
||||||
|
import { useGridColumns, useGridFormSchema } from './spu-select-data';
|
||||||
|
|
||||||
|
defineOptions({ name: 'SpuSelect' });
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SpuSelectProps>(), {
|
||||||
|
isSelectSku: false,
|
||||||
|
radio: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select', spuId: number, skuIds?: number[]): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
interface SpuSelectProps {
|
||||||
|
// 默认不需要(不需要的情况下只返回 spu,需要的情况下返回 选中的 spu 和 sku 列表)
|
||||||
|
// 其它活动需要选择商品和商品属性导入此组件即可,需添加组件属性 :isSelectSku='true'
|
||||||
|
isSelectSku?: boolean; // 是否需要选择 sku 属性
|
||||||
|
radio?: boolean; // 是否单选 sku
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 数据状态 ============
|
||||||
|
const categoryList = ref<MallCategoryApi.Category[]>([]); // 分类列表
|
||||||
|
const categoryTreeList = ref<MallCategoryApi.Category[]>([]); // 分类树
|
||||||
|
const propertyList = ref<PropertyAndValues[]>([]); // 商品属性列表
|
||||||
|
const spuData = ref<MallSpuApi.Spu>(); // 当前展开的商品详情
|
||||||
|
const isExpand = ref(false); // 控制 SKU 列表显示
|
||||||
|
|
||||||
|
// ============ 商品选择相关 ============
|
||||||
|
const selectedSpuId = ref<number>(0); // 选中的商品 spuId
|
||||||
|
const selectedSkuIds = ref<number[]>([]); // 选中的商品 skuIds
|
||||||
|
const skuListRef = ref<InstanceType<typeof SkuList>>(); // 商品属性选择 Ref
|
||||||
|
|
||||||
|
/** 处理 SKU 选择变化 */
|
||||||
|
function selectSku(val: MallSpuApi.Sku[]) {
|
||||||
|
const skuTable = skuListRef.value?.getSkuTableRef();
|
||||||
|
if (selectedSpuId.value === 0) {
|
||||||
|
ElMessage.warning('请先选择商品再选择相应的规格!!!');
|
||||||
|
skuTable?.clearSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (val.length === 0) {
|
||||||
|
selectedSkuIds.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.radio) {
|
||||||
|
// 只选择一个
|
||||||
|
const firstId = val[0]?.id;
|
||||||
|
if (firstId !== undefined) {
|
||||||
|
selectedSkuIds.value = [firstId];
|
||||||
|
}
|
||||||
|
// 如果大于1个
|
||||||
|
if (val.length > 1) {
|
||||||
|
// 清空选择
|
||||||
|
skuTable?.clearSelection();
|
||||||
|
// 变更为最后一次选择的
|
||||||
|
const lastItem = val.pop();
|
||||||
|
if (lastItem) {
|
||||||
|
skuTable?.toggleRowSelection(lastItem, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedSkuIds.value = val
|
||||||
|
.map((sku) => sku.id!)
|
||||||
|
.filter((id): id is number => id !== undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理 SPU 选择变化 */
|
||||||
|
function selectSpu(row: MallSpuApi.Spu) {
|
||||||
|
if (!row) {
|
||||||
|
selectedSpuId.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectedSpuId.value = row.id!;
|
||||||
|
|
||||||
|
// 切换选择 spu 如果有选择的 sku 则清空,确保选择的 sku 是对应的 spu 下面的
|
||||||
|
if (selectedSkuIds.value.length > 0) {
|
||||||
|
selectedSkuIds.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 处理行展开变化 */
|
||||||
|
async function expandChange(
|
||||||
|
row: MallSpuApi.Spu,
|
||||||
|
expandedRows?: MallSpuApi.Spu[],
|
||||||
|
) {
|
||||||
|
// 判断需要展开的 spuId === 选择的 spuId。如果选择了 A 就展开 A 的 skuList。如果选择了 A 手动展开 B 则阻断
|
||||||
|
// 目的:防止误选 sku
|
||||||
|
if (selectedSpuId.value !== 0) {
|
||||||
|
if (row.id !== selectedSpuId.value) {
|
||||||
|
ElMessage.warning('你已选择商品请先取消');
|
||||||
|
// 阻止展开,通过重新设置展开状态来保持当前选中行的展开
|
||||||
|
if (row.id !== undefined) {
|
||||||
|
const tableData = gridApi.grid.getTableData().fullData;
|
||||||
|
const selectedRow = tableData.find(
|
||||||
|
(item: MallSpuApi.Spu) => item.id === selectedSpuId.value,
|
||||||
|
);
|
||||||
|
if (selectedRow) {
|
||||||
|
// 关闭当前行,重新展开选中行
|
||||||
|
gridApi.grid.setRowExpand(selectedRow, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果已展开 skuList 则选择此对应的 spu 不需要重新获取渲染 skuList
|
||||||
|
if (isExpand.value && spuData.value?.id === row.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spuData.value = undefined;
|
||||||
|
propertyList.value = [];
|
||||||
|
isExpand.value = false;
|
||||||
|
if (expandedRows?.length === 0) {
|
||||||
|
// 如果展开个数为 0,直接返回
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 获取 SPU 详情
|
||||||
|
if (row.id === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = (await getSpu(row.id)) as MallSpuApi.Spu;
|
||||||
|
res.skus?.forEach((item) => {
|
||||||
|
if (typeof item.price === 'number') {
|
||||||
|
item.price = Math.round(item.price * 100);
|
||||||
|
}
|
||||||
|
if (typeof item.marketPrice === 'number') {
|
||||||
|
item.marketPrice = Math.round(item.marketPrice * 100);
|
||||||
|
}
|
||||||
|
if (typeof item.costPrice === 'number') {
|
||||||
|
item.costPrice = Math.round(item.costPrice * 100);
|
||||||
|
}
|
||||||
|
if (typeof item.firstBrokeragePrice === 'number') {
|
||||||
|
item.firstBrokeragePrice = Math.round(item.firstBrokeragePrice * 100);
|
||||||
|
}
|
||||||
|
if (typeof item.secondBrokeragePrice === 'number') {
|
||||||
|
item.secondBrokeragePrice = Math.round(item.secondBrokeragePrice * 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
propertyList.value = getPropertyList(res);
|
||||||
|
spuData.value = res;
|
||||||
|
isExpand.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formSchema = computed(() => useGridFormSchema(categoryTreeList)); // 搜索表单 Schema
|
||||||
|
const gridColumns = computed<VxeTableGridOptions['columns']>(() => {
|
||||||
|
const columns = useGridColumns(props.isSelectSku);
|
||||||
|
// 将 checkbox 替换为 radio
|
||||||
|
return columns?.map((col) => {
|
||||||
|
if (col.type === 'checkbox') {
|
||||||
|
return { ...col, type: 'radio' };
|
||||||
|
}
|
||||||
|
return col;
|
||||||
|
});
|
||||||
|
}); // 表格列配置
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: formSchema.value,
|
||||||
|
layout: 'horizontal',
|
||||||
|
collapsed: false,
|
||||||
|
},
|
||||||
|
gridOptions: {
|
||||||
|
columns: gridColumns.value,
|
||||||
|
height: 800,
|
||||||
|
border: true,
|
||||||
|
radioConfig: {
|
||||||
|
reserve: true,
|
||||||
|
highlight: true,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
isHover: true,
|
||||||
|
},
|
||||||
|
expandConfig: {
|
||||||
|
trigger: 'row',
|
||||||
|
reserve: true,
|
||||||
|
},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
async query({ page }: any, formValues: any) {
|
||||||
|
return await getSpuPage({
|
||||||
|
pageNo: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
tabType: 0,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gridEvents: {
|
||||||
|
radioChange: ({ row, $grid }: { $grid: any; row: MallSpuApi.Spu }) => {
|
||||||
|
selectSpu(row);
|
||||||
|
if (props.isSelectSku) {
|
||||||
|
$grid.clearRowExpand();
|
||||||
|
$grid.setRowExpand(row, true);
|
||||||
|
expandChange(row, [row]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleRowExpand: ({
|
||||||
|
row,
|
||||||
|
expanded,
|
||||||
|
}: {
|
||||||
|
expanded: boolean;
|
||||||
|
row: unknown;
|
||||||
|
}) => {
|
||||||
|
if (expanded) {
|
||||||
|
expandChange(row as MallSpuApi.Spu, [row as MallSpuApi.Spu]);
|
||||||
|
} else {
|
||||||
|
expandChange(row as MallSpuApi.Spu, []);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const visible = ref(false); // 弹窗显示状态
|
||||||
|
|
||||||
|
/** 打开弹窗 */
|
||||||
|
async function openModal() {
|
||||||
|
visible.value = true;
|
||||||
|
// 等待 Grid 组件完全初始化后再查询数据
|
||||||
|
await nextTick();
|
||||||
|
if (gridApi.grid) {
|
||||||
|
await gridApi.query();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭弹窗 */
|
||||||
|
function closeModal() {
|
||||||
|
visible.value = false;
|
||||||
|
selectedSpuId.value = 0;
|
||||||
|
selectedSkuIds.value = [];
|
||||||
|
spuData.value = undefined;
|
||||||
|
propertyList.value = [];
|
||||||
|
isExpand.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认选择 */
|
||||||
|
function handleConfirm() {
|
||||||
|
if (selectedSpuId.value === 0) {
|
||||||
|
ElMessage.warning('没有选择任何商品');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (props.isSelectSku && selectedSkuIds.value.length === 0) {
|
||||||
|
ElMessage.warning('没有选择任何商品属性');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 返回各自 id 列表
|
||||||
|
props.isSelectSku
|
||||||
|
? emit('select', selectedSpuId.value, selectedSkuIds.value)
|
||||||
|
: emit('select', selectedSpuId.value);
|
||||||
|
|
||||||
|
// 重置选中状态
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对外暴露的方法 */
|
||||||
|
defineExpose({
|
||||||
|
open: openModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 初始化分类数据 */
|
||||||
|
onMounted(async () => {
|
||||||
|
categoryList.value = await getCategoryList({});
|
||||||
|
categoryTreeList.value = handleTree(
|
||||||
|
categoryList.value,
|
||||||
|
'id',
|
||||||
|
'parentId',
|
||||||
|
) as MallCategoryApi.Category[];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
v-model="visible"
|
||||||
|
title="商品选择"
|
||||||
|
width="70%"
|
||||||
|
:destroy-on-close="true"
|
||||||
|
@close="closeModal"
|
||||||
|
>
|
||||||
|
<Grid>
|
||||||
|
<!-- 展开列内容(SKU 列表) -->
|
||||||
|
<template v-if="isSelectSku" #expand_content="{ row }">
|
||||||
|
<SkuList
|
||||||
|
v-if="isExpand && spuData?.id === row.id"
|
||||||
|
ref="skuListRef"
|
||||||
|
:is-component="true"
|
||||||
|
:is-detail="true"
|
||||||
|
:prop-form-data="spuData"
|
||||||
|
:property-list="propertyList"
|
||||||
|
@selection-change="selectSku"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="closeModal">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleConfirm">确定</el-button>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
32
apps/web-ele/src/views/mall/product/spu/components/type.ts
Normal file
32
apps/web-ele/src/views/mall/product/spu/components/type.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/** 商品属性及其值的树形结构(用于前端展示和操作) */
|
||||||
|
export interface PropertyAndValues {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
values?: PropertyAndValues[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleConfig {
|
||||||
|
// 需要校验的字段
|
||||||
|
// 例:name: 'name' 则表示校验 sku.name 的值
|
||||||
|
// 例:name: 'productConfig.stock' 则表示校验 sku.productConfig.name 的值,此处 productConfig 表示我在 Sku 上扩展的属性
|
||||||
|
name: string;
|
||||||
|
// 校验规格为一个毁掉函数,其中 arg 为需要校验的字段的值。
|
||||||
|
// 例:需要校验价格必须大于0.01
|
||||||
|
// {
|
||||||
|
// name:'price',
|
||||||
|
// rule:(arg: number) => arg > 0.01
|
||||||
|
// }
|
||||||
|
rule: (arg: any) => boolean;
|
||||||
|
// 校验不通过时的消息提示
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpuProperty<T> {
|
||||||
|
propertyList: PropertyAndValues[];
|
||||||
|
spuDetail: T;
|
||||||
|
spuId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export for use in generic constraint
|
||||||
|
|
||||||
|
export { type MallSpuApi } from '#/api/mall/product/spu';
|
||||||
@@ -2,11 +2,17 @@ import type { VbenFormSchema } from '#/adapter/form';
|
|||||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
|
||||||
import { handleTree } from '@vben/utils';
|
import { fenToYuan, handleTree, treeToString } from '@vben/utils';
|
||||||
|
|
||||||
import { getCategoryList } from '#/api/mall/product/category';
|
import { getCategoryList } from '#/api/mall/product/category';
|
||||||
import { getRangePickerDefaultProps } from '#/utils';
|
import { getRangePickerDefaultProps } from '#/utils';
|
||||||
|
|
||||||
|
/** 关联数据 */
|
||||||
|
let categoryList: any[] = [];
|
||||||
|
getCategoryList({}).then((data) => {
|
||||||
|
categoryList = handleTree(data, 'id', 'parentId', 'children');
|
||||||
|
});
|
||||||
|
|
||||||
/** 列表的搜索表单 */
|
/** 列表的搜索表单 */
|
||||||
export function useGridFormSchema(): VbenFormSchema[] {
|
export function useGridFormSchema(): VbenFormSchema[] {
|
||||||
return [
|
return [
|
||||||
@@ -14,16 +20,19 @@ export function useGridFormSchema(): VbenFormSchema[] {
|
|||||||
fieldName: 'name',
|
fieldName: 'name',
|
||||||
label: '商品名称',
|
label: '商品名称',
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入商品名称',
|
||||||
|
clearable: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'categoryId',
|
fieldName: 'categoryId',
|
||||||
label: '商品分类',
|
label: '商品分类',
|
||||||
component: 'ApiTreeSelect',
|
component: 'ApiTreeSelect',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
api: async () => {
|
placeholder: '请选择商品分类',
|
||||||
const res = await getCategoryList({});
|
clearable: true,
|
||||||
return handleTree(res, 'id', 'parentId', 'children');
|
options: categoryList,
|
||||||
},
|
|
||||||
labelField: 'name',
|
labelField: 'name',
|
||||||
valueField: 'id',
|
valueField: 'id',
|
||||||
childrenField: 'children',
|
childrenField: 'children',
|
||||||
@@ -49,16 +58,11 @@ export function useGridColumns(
|
|||||||
) => PromiseLike<boolean | undefined>,
|
) => PromiseLike<boolean | undefined>,
|
||||||
): VxeTableGridOptions['columns'] {
|
): VxeTableGridOptions['columns'] {
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
type: 'expand',
|
|
||||||
width: 80,
|
|
||||||
slots: { content: 'expand_content' },
|
|
||||||
fixed: 'left',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
field: 'id',
|
field: 'id',
|
||||||
title: '商品编号',
|
title: '商品编号',
|
||||||
fixed: 'left',
|
fixed: 'left',
|
||||||
|
minWidth: 100,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'name',
|
field: 'name',
|
||||||
@@ -69,30 +73,23 @@ export function useGridColumns(
|
|||||||
{
|
{
|
||||||
field: 'picUrl',
|
field: 'picUrl',
|
||||||
title: '商品图片',
|
title: '商品图片',
|
||||||
|
minWidth: 100,
|
||||||
cellRender: {
|
cellRender: {
|
||||||
name: 'CellImage',
|
name: 'CellImage',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'price',
|
field: 'categoryId',
|
||||||
title: '价格',
|
title: '商品分类',
|
||||||
formatter: 'formatFenToYuanAmount',
|
minWidth: 150,
|
||||||
},
|
formatter: ({ row }) => {
|
||||||
{
|
return treeToString(categoryList, row.categoryId);
|
||||||
field: 'salesCount',
|
},
|
||||||
title: '销量',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'stock',
|
|
||||||
title: '库存',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'sort',
|
|
||||||
title: '排序',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'status',
|
field: 'status',
|
||||||
title: '销售状态',
|
title: '销售状态',
|
||||||
|
minWidth: 100,
|
||||||
cellRender: {
|
cellRender: {
|
||||||
attrs: { beforeChange: onStatusChange },
|
attrs: { beforeChange: onStatusChange },
|
||||||
name: 'CellSwitch',
|
name: 'CellSwitch',
|
||||||
@@ -104,9 +101,57 @@ export function useGridColumns(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
field: 'price',
|
||||||
|
title: '价格',
|
||||||
|
minWidth: 100,
|
||||||
|
formatter: 'formatAmount2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'marketPrice',
|
||||||
|
title: '市场价',
|
||||||
|
minWidth: 100,
|
||||||
|
formatter: ({ row }) => {
|
||||||
|
return `${fenToYuan(row.marketPrice)} 元`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'costPrice',
|
||||||
|
title: '成本价',
|
||||||
|
minWidth: 100,
|
||||||
|
formatter: ({ row }) => {
|
||||||
|
return `${fenToYuan(row.costPrice)} 元`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'salesCount',
|
||||||
|
title: '销量',
|
||||||
|
minWidth: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'virtualSalesCount',
|
||||||
|
title: '虚拟销量',
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'stock',
|
||||||
|
title: '库存',
|
||||||
|
minWidth: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'browseCount',
|
||||||
|
title: '浏览量',
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'sort',
|
||||||
|
title: '排序',
|
||||||
|
minWidth: 80,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'createTime',
|
field: 'createTime',
|
||||||
title: '创建时间',
|
title: '创建时间',
|
||||||
|
minWidth: 160,
|
||||||
formatter: 'formatDateTime',
|
formatter: 'formatDateTime',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { DeliveryTypeEnum, DICT_TYPE } from '@vben/constants';
|
|||||||
import { getDictOptions } from '@vben/hooks';
|
import { getDictOptions } from '@vben/hooks';
|
||||||
import { handleTree } from '@vben/utils';
|
import { handleTree } from '@vben/utils';
|
||||||
|
|
||||||
import { z } from '#/adapter/form';
|
|
||||||
import { getSimpleBrandList } from '#/api/mall/product/brand';
|
import { getSimpleBrandList } from '#/api/mall/product/brand';
|
||||||
import { getCategoryList } from '#/api/mall/product/category';
|
import { getCategoryList } from '#/api/mall/product/category';
|
||||||
import { getSimpleTemplateList } from '#/api/mall/trade/delivery/expressTemplate';
|
import { getSimpleTemplateList } from '#/api/mall/trade/delivery/expressTemplate';
|
||||||
@@ -25,7 +24,7 @@ export function useInfoFormSchema(): VbenFormSchema[] {
|
|||||||
label: '商品名称',
|
label: '商品名称',
|
||||||
component: 'Input',
|
component: 'Input',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
allowClear: true,
|
clearable: true,
|
||||||
placeholder: '请输入商品名称',
|
placeholder: '请输入商品名称',
|
||||||
},
|
},
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
@@ -33,14 +32,15 @@ export function useInfoFormSchema(): VbenFormSchema[] {
|
|||||||
{
|
{
|
||||||
fieldName: 'categoryId',
|
fieldName: 'categoryId',
|
||||||
label: '分类名称',
|
label: '分类名称',
|
||||||
// component: 'ApiCascader',
|
|
||||||
component: 'ApiTreeSelect',
|
component: 'ApiTreeSelect',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
api: async () => {
|
api: async () => {
|
||||||
const data = await getCategoryList({});
|
const data = await getCategoryList({});
|
||||||
return handleTree(data);
|
return handleTree(data);
|
||||||
},
|
},
|
||||||
fieldNames: { label: 'name', value: 'id', children: 'children' },
|
labelField: 'name',
|
||||||
|
valueField: 'id',
|
||||||
|
childrenField: 'children',
|
||||||
placeholder: '请选择商品分类',
|
placeholder: '请选择商品分类',
|
||||||
},
|
},
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
@@ -53,7 +53,7 @@ export function useInfoFormSchema(): VbenFormSchema[] {
|
|||||||
api: getSimpleBrandList,
|
api: getSimpleBrandList,
|
||||||
labelField: 'name',
|
labelField: 'name',
|
||||||
valueField: 'id',
|
valueField: 'id',
|
||||||
allowClear: true,
|
clearable: true,
|
||||||
placeholder: '请选择商品品牌',
|
placeholder: '请选择商品品牌',
|
||||||
},
|
},
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
@@ -73,10 +73,10 @@ export function useInfoFormSchema(): VbenFormSchema[] {
|
|||||||
component: 'Textarea',
|
component: 'Textarea',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '请输入商品简介',
|
placeholder: '请输入商品简介',
|
||||||
autoSize: { minRows: 2, maxRows: 2 },
|
autosize: { minRows: 2, maxRows: 2 },
|
||||||
showCount: true,
|
showWordLimit: true,
|
||||||
maxlength: 128,
|
maxlength: 128,
|
||||||
allowClear: true,
|
clearable: true,
|
||||||
},
|
},
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
@@ -104,7 +104,10 @@ export function useInfoFormSchema(): VbenFormSchema[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 价格库存的表单 */
|
/** 价格库存的表单 */
|
||||||
export function useSkuFormSchema(): VbenFormSchema[] {
|
export function useSkuFormSchema(
|
||||||
|
propertyList: any[] = [],
|
||||||
|
isDetail: boolean = false,
|
||||||
|
): VbenFormSchema[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
fieldName: 'id',
|
fieldName: 'id',
|
||||||
@@ -119,7 +122,7 @@ export function useSkuFormSchema(): VbenFormSchema[] {
|
|||||||
label: '分销类型',
|
label: '分销类型',
|
||||||
component: 'RadioGroup',
|
component: 'RadioGroup',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
allowClear: true,
|
clearable: true,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
label: '默认设置',
|
label: '默认设置',
|
||||||
@@ -138,7 +141,7 @@ export function useSkuFormSchema(): VbenFormSchema[] {
|
|||||||
label: '商品规格',
|
label: '商品规格',
|
||||||
component: 'RadioGroup',
|
component: 'RadioGroup',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
allowClear: true,
|
clearable: true,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
label: '单规格',
|
label: '单规格',
|
||||||
@@ -152,7 +155,51 @@ export function useSkuFormSchema(): VbenFormSchema[] {
|
|||||||
},
|
},
|
||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
// TODO @xingyu:待补充商品属性
|
// 单规格时显示的 SkuList
|
||||||
|
{
|
||||||
|
fieldName: 'singleSkuList',
|
||||||
|
label: '',
|
||||||
|
component: 'Input',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: ['specType'],
|
||||||
|
// 当 specType 为 false(单规格)时显示
|
||||||
|
show: (values) => values.specType === false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 多规格时显示的商品属性(占位,实际通过插槽渲染)
|
||||||
|
{
|
||||||
|
fieldName: 'productAttributes',
|
||||||
|
label: '商品属性',
|
||||||
|
component: 'Input',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: ['specType'],
|
||||||
|
// 当 specType 为 true(多规格)时显示
|
||||||
|
show: (values) => values.specType === true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 多规格 - 批量设置
|
||||||
|
{
|
||||||
|
fieldName: 'batchSkuList',
|
||||||
|
label: '批量设置',
|
||||||
|
component: 'Input',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: ['specType'],
|
||||||
|
// 当 specType 为 true(多规格)且 propertyList 有数据时显示,且非详情模式
|
||||||
|
show: (values) =>
|
||||||
|
values.specType === true && propertyList.length > 0 && !isDetail,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 多规格 - 规格列表
|
||||||
|
{
|
||||||
|
fieldName: 'multiSkuList',
|
||||||
|
label: '规格列表',
|
||||||
|
component: 'Input',
|
||||||
|
dependencies: {
|
||||||
|
triggerFields: ['specType'],
|
||||||
|
// 当 specType 为 true(多规格)且 propertyList 有数据时显示
|
||||||
|
show: (values) => values.specType === true && propertyList.length > 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,10 +284,8 @@ export function useOtherFormSchema(): VbenFormSchema[] {
|
|||||||
component: 'InputNumber',
|
component: 'InputNumber',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
min: 0,
|
min: 0,
|
||||||
controlsPosition: 'right',
|
|
||||||
class: '!w-full',
|
|
||||||
},
|
},
|
||||||
rules: z.number().min(0).optional().default(0),
|
rules: 'required',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'giveIntegral',
|
fieldName: 'giveIntegral',
|
||||||
@@ -248,10 +293,8 @@ export function useOtherFormSchema(): VbenFormSchema[] {
|
|||||||
component: 'InputNumber',
|
component: 'InputNumber',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
min: 0,
|
min: 0,
|
||||||
controlsPosition: 'right',
|
|
||||||
class: '!w-full',
|
|
||||||
},
|
},
|
||||||
rules: z.number().min(0).optional().default(0),
|
rules: 'required',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fieldName: 'virtualSalesCount',
|
fieldName: 'virtualSalesCount',
|
||||||
@@ -259,10 +302,8 @@ export function useOtherFormSchema(): VbenFormSchema[] {
|
|||||||
component: 'InputNumber',
|
component: 'InputNumber',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
min: 0,
|
min: 0,
|
||||||
controlsPosition: 'right',
|
|
||||||
class: '!w-full',
|
|
||||||
},
|
},
|
||||||
rules: z.number().min(0).optional().default(0),
|
rules: 'required',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
412
apps/web-ele/src/views/mall/product/spu/form/index.vue
Normal file
412
apps/web-ele/src/views/mall/product/spu/form/index.vue
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MallSpuApi } from '#/api/mall/product/spu';
|
||||||
|
import type {
|
||||||
|
PropertyAndValues,
|
||||||
|
RuleConfig,
|
||||||
|
} from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
import { useTabs } from '@vben/hooks';
|
||||||
|
import { convertToInteger, formatToFraction } from '@vben/utils';
|
||||||
|
|
||||||
|
import { ElButton, ElCard, ElMessage, ElTabPane, ElTabs } from 'element-plus';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import { createSpu, getSpu, updateSpu } from '#/api/mall/product/spu';
|
||||||
|
import { getPropertyList, SkuList } from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useDeliveryFormSchema,
|
||||||
|
useDescriptionFormSchema,
|
||||||
|
useInfoFormSchema,
|
||||||
|
useOtherFormSchema,
|
||||||
|
useSkuFormSchema,
|
||||||
|
} from './data';
|
||||||
|
import ProductAttributes from './modules/product-attributes.vue';
|
||||||
|
import ProductPropertyAddForm from './modules/product-property-add-form.vue';
|
||||||
|
|
||||||
|
const spuId = ref<number>();
|
||||||
|
const { params, name } = useRoute();
|
||||||
|
const { closeCurrentTab } = useTabs();
|
||||||
|
const activeTabName = ref('info');
|
||||||
|
|
||||||
|
const formLoading = ref(false); // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
||||||
|
const isDetail = ref(name === 'ProductSpuDetail'); // 是否查看详情
|
||||||
|
const skuListRef = ref(); // 商品属性列表 Ref
|
||||||
|
|
||||||
|
const formData = ref<MallSpuApi.Spu>({
|
||||||
|
name: '',
|
||||||
|
categoryId: undefined,
|
||||||
|
keyword: '',
|
||||||
|
picUrl: '',
|
||||||
|
sliderPicUrls: [],
|
||||||
|
introduction: '',
|
||||||
|
deliveryTypes: [],
|
||||||
|
deliveryTemplateId: undefined,
|
||||||
|
brandId: undefined,
|
||||||
|
specType: false,
|
||||||
|
subCommissionType: false,
|
||||||
|
skus: [
|
||||||
|
{
|
||||||
|
price: 0,
|
||||||
|
marketPrice: 0,
|
||||||
|
costPrice: 0,
|
||||||
|
barCode: '',
|
||||||
|
picUrl: '',
|
||||||
|
stock: 0,
|
||||||
|
weight: 0,
|
||||||
|
volume: 0,
|
||||||
|
firstBrokeragePrice: 0,
|
||||||
|
secondBrokeragePrice: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
description: '',
|
||||||
|
sort: 0,
|
||||||
|
giveIntegral: 0,
|
||||||
|
virtualSalesCount: 0,
|
||||||
|
}); // spu 表单数据
|
||||||
|
const propertyList = ref<PropertyAndValues[]>([]); // 商品属性列表
|
||||||
|
const ruleConfig: RuleConfig[] = [
|
||||||
|
{
|
||||||
|
name: 'stock',
|
||||||
|
rule: (arg: number) => arg >= 0,
|
||||||
|
message: '商品库存必须大于等于 1 !!!',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'price',
|
||||||
|
rule: (arg: number) => arg >= 0.01,
|
||||||
|
message: '商品销售价格必须大于等于 0.01 元!!!',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'marketPrice',
|
||||||
|
rule: (arg: number) => arg >= 0.01,
|
||||||
|
message: '商品市场价格必须大于等于 0.01 元!!!',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'costPrice',
|
||||||
|
rule: (arg: number) => arg >= 0.01,
|
||||||
|
message: '商品成本价格必须大于等于 0.00 元!!!',
|
||||||
|
},
|
||||||
|
]; // sku 相关属性校验规则
|
||||||
|
|
||||||
|
const [InfoForm, infoFormApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-2',
|
||||||
|
labelWidth: 120,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useInfoFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [SkuForm, skuFormApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
labelWidth: 120,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useSkuFormSchema(propertyList.value, isDetail.value),
|
||||||
|
showDefaultActions: false,
|
||||||
|
handleValuesChange: (values, fieldsChanged) => {
|
||||||
|
if (fieldsChanged.includes('subCommissionType')) {
|
||||||
|
formData.value.subCommissionType = values.subCommissionType;
|
||||||
|
handleChangeSubCommissionType();
|
||||||
|
}
|
||||||
|
if (fieldsChanged.includes('specType')) {
|
||||||
|
formData.value.specType = values.specType;
|
||||||
|
handleChangeSpec();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [ProductPropertyAddFormModal, productPropertyAddFormApi] = useVbenModal({
|
||||||
|
connectedComponent: ProductPropertyAddForm,
|
||||||
|
destroyOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [DeliveryForm, deliveryFormApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-2',
|
||||||
|
labelWidth: 120,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useDeliveryFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [DescriptionForm, descriptionFormApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-2',
|
||||||
|
labelWidth: 120,
|
||||||
|
},
|
||||||
|
layout: 'vertical',
|
||||||
|
schema: useDescriptionFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [OtherForm, otherFormApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-2',
|
||||||
|
labelWidth: 120,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: useOtherFormSchema(),
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** tab 切换 */
|
||||||
|
function handleTabChange(key: string) {
|
||||||
|
activeTabName.value = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交表单 */
|
||||||
|
async function handleSubmit() {
|
||||||
|
const values: MallSpuApi.Spu = await infoFormApi
|
||||||
|
.merge(skuFormApi)
|
||||||
|
.merge(deliveryFormApi)
|
||||||
|
.merge(descriptionFormApi)
|
||||||
|
.merge(otherFormApi)
|
||||||
|
.submitAllForm(true);
|
||||||
|
values.skus = formData.value.skus;
|
||||||
|
if (values.skus) {
|
||||||
|
try {
|
||||||
|
// 校验 sku
|
||||||
|
skuListRef.value.validateSku();
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('【库存价格】不完善,请填写相关信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
values.skus.forEach((item) => {
|
||||||
|
// 金额转换:元转分
|
||||||
|
item.price = convertToInteger(item.price);
|
||||||
|
item.marketPrice = convertToInteger(item.marketPrice);
|
||||||
|
item.costPrice = convertToInteger(item.costPrice);
|
||||||
|
item.firstBrokeragePrice = convertToInteger(item.firstBrokeragePrice);
|
||||||
|
item.secondBrokeragePrice = convertToInteger(item.secondBrokeragePrice);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 处理轮播图列表
|
||||||
|
const newSliderPicUrls: any[] = [];
|
||||||
|
values.sliderPicUrls!.forEach((item: any) => {
|
||||||
|
// 如果是前端选的图
|
||||||
|
typeof item === 'object'
|
||||||
|
? newSliderPicUrls.push(item.url)
|
||||||
|
: newSliderPicUrls.push(item);
|
||||||
|
});
|
||||||
|
values.sliderPicUrls = newSliderPicUrls;
|
||||||
|
|
||||||
|
// 提交数据
|
||||||
|
await (spuId.value ? updateSpu(values) : createSpu(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获得详情 */
|
||||||
|
async function getDetail() {
|
||||||
|
if (isDetail.value) {
|
||||||
|
isDetail.value = true;
|
||||||
|
infoFormApi.setDisabled(true);
|
||||||
|
skuFormApi.setDisabled(true);
|
||||||
|
deliveryFormApi.setDisabled(true);
|
||||||
|
descriptionFormApi.setDisabled(true);
|
||||||
|
otherFormApi.setDisabled(true);
|
||||||
|
}
|
||||||
|
// 将 SKU 的属性,整理成 PropertyAndValues 数组
|
||||||
|
propertyList.value = getPropertyList(formData.value);
|
||||||
|
formLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getSpu(spuId.value!);
|
||||||
|
// 金额转换:元转分
|
||||||
|
res.skus?.forEach((item) => {
|
||||||
|
item.price = formatToFraction(item.price);
|
||||||
|
item.marketPrice = formatToFraction(item.marketPrice);
|
||||||
|
item.costPrice = formatToFraction(item.costPrice);
|
||||||
|
item.firstBrokeragePrice = formatToFraction(item.firstBrokeragePrice);
|
||||||
|
item.secondBrokeragePrice = formatToFraction(item.secondBrokeragePrice);
|
||||||
|
});
|
||||||
|
formData.value = res;
|
||||||
|
// 初始化各表单值
|
||||||
|
infoFormApi.setValues(res).then();
|
||||||
|
skuFormApi.setValues(res).then();
|
||||||
|
deliveryFormApi.setValues(res).then();
|
||||||
|
descriptionFormApi.setValues(res).then();
|
||||||
|
otherFormApi.setValues(res).then();
|
||||||
|
// 将 SKU 的属性,整理成 PropertyAndValues 数组
|
||||||
|
propertyList.value = getPropertyList(formData.value);
|
||||||
|
} finally {
|
||||||
|
formLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========== sku form 逻辑 ===========
|
||||||
|
|
||||||
|
/** 打开属性添加表单 */
|
||||||
|
function openPropertyAddForm() {
|
||||||
|
productPropertyAddFormApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用 SkuList generateTableData 方法*/
|
||||||
|
function generateSkus(propertyList: PropertyAndValues[]) {
|
||||||
|
skuListRef.value.generateTableData(propertyList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分销类型 */
|
||||||
|
function handleChangeSubCommissionType() {
|
||||||
|
// 默认为零,类型切换后也要重置为零
|
||||||
|
for (const item of formData.value.skus!) {
|
||||||
|
item.firstBrokeragePrice = 0;
|
||||||
|
item.secondBrokeragePrice = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择规格 */
|
||||||
|
function handleChangeSpec() {
|
||||||
|
// 重置商品属性列表
|
||||||
|
propertyList.value = [];
|
||||||
|
// 重置 sku 列表
|
||||||
|
formData.value.skus = [
|
||||||
|
{
|
||||||
|
price: 0,
|
||||||
|
marketPrice: 0,
|
||||||
|
costPrice: 0,
|
||||||
|
barCode: '',
|
||||||
|
picUrl: '',
|
||||||
|
stock: 0,
|
||||||
|
weight: 0,
|
||||||
|
volume: 0,
|
||||||
|
firstBrokeragePrice: 0,
|
||||||
|
secondBrokeragePrice: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 监听 sku form schema 变化,更新表单 */
|
||||||
|
watch(
|
||||||
|
propertyList,
|
||||||
|
() => {
|
||||||
|
skuFormApi.updateSchema(
|
||||||
|
useSkuFormSchema(propertyList.value, isDetail.value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 初始化 */
|
||||||
|
onMounted(async () => {
|
||||||
|
spuId.value = params.id as unknown as number;
|
||||||
|
if (!spuId.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await getDetail();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ProductPropertyAddFormModal :property-list="propertyList" />
|
||||||
|
|
||||||
|
<Page auto-content-height>
|
||||||
|
<ElCard class="spu-form-card h-full w-full" v-loading="formLoading">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<ElTabs
|
||||||
|
v-model="activeTabName"
|
||||||
|
@tab-click="(tab: any) => handleTabChange(tab.paneName)"
|
||||||
|
>
|
||||||
|
<ElTabPane label="基础设置" name="info" />
|
||||||
|
<ElTabPane label="价格库存" name="sku" />
|
||||||
|
<ElTabPane label="物流设置" name="delivery" />
|
||||||
|
<ElTabPane label="商品详情" name="description" />
|
||||||
|
<ElTabPane label="其它设置" name="other" />
|
||||||
|
</ElTabs>
|
||||||
|
<div>
|
||||||
|
<ElButton v-if="!isDetail" type="primary" @click="handleSubmit">
|
||||||
|
保存
|
||||||
|
</ElButton>
|
||||||
|
<ElButton v-else @click="() => closeCurrentTab()">
|
||||||
|
返回列表
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
<InfoForm class="w-3/5" v-show="activeTabName === 'info'" />
|
||||||
|
<SkuForm class="w-full" v-show="activeTabName === 'sku'">
|
||||||
|
<template #singleSkuList>
|
||||||
|
<SkuList
|
||||||
|
ref="skuListRef"
|
||||||
|
class="w-full"
|
||||||
|
:is-detail="isDetail"
|
||||||
|
:prop-form-data="formData"
|
||||||
|
:property-list="propertyList"
|
||||||
|
:rule-config="ruleConfig"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #productAttributes>
|
||||||
|
<div>
|
||||||
|
<ElButton class="mb-10px mr-15px" @click="openPropertyAddForm">
|
||||||
|
添加属性
|
||||||
|
</ElButton>
|
||||||
|
<ProductAttributes
|
||||||
|
:is-detail="isDetail"
|
||||||
|
:property-list="propertyList"
|
||||||
|
@success="generateSkus"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #batchSkuList>
|
||||||
|
<SkuList
|
||||||
|
:is-batch="true"
|
||||||
|
:is-detail="isDetail"
|
||||||
|
:prop-form-data="formData"
|
||||||
|
:property-list="propertyList"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #multiSkuList>
|
||||||
|
<SkuList
|
||||||
|
ref="skuListRef"
|
||||||
|
:is-detail="isDetail"
|
||||||
|
:prop-form-data="formData"
|
||||||
|
:property-list="propertyList"
|
||||||
|
:rule-config="ruleConfig"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</SkuForm>
|
||||||
|
<DeliveryForm class="w-3/5" v-show="activeTabName === 'delivery'" />
|
||||||
|
<DescriptionForm
|
||||||
|
class="w-3/5"
|
||||||
|
v-show="activeTabName === 'description'"
|
||||||
|
/>
|
||||||
|
<OtherForm class="w-3/5" v-show="activeTabName === 'other'" />
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</Page>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.spu-form-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spu-form-card :deep(.el-card__body) {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<!-- 商品发布 - 库存价格 - 属性列表 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { MallPropertyApi } from '#/api/mall/product/property';
|
||||||
|
import type { PropertyAndValues } from '#/views/mall/product/spu/components';
|
||||||
|
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { IconifyIcon } from '@vben/icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ElCol,
|
||||||
|
ElDivider,
|
||||||
|
ElMessage,
|
||||||
|
ElOption,
|
||||||
|
ElSelect,
|
||||||
|
ElTag,
|
||||||
|
} from 'element-plus';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPropertyValue,
|
||||||
|
getPropertyValueSimpleList,
|
||||||
|
} from '#/api/mall/product/property';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProductAttributes' });
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
propertyList: () => [],
|
||||||
|
isDetail: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['success']);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
propertyList?: PropertyAndValues[];
|
||||||
|
isDetail?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputValue = ref<string[]>([]); // 输入框值(tags 模式使用数组)
|
||||||
|
const attributeIndex = ref<null | number>(null); // 获取焦点时记录当前属性项的 index
|
||||||
|
const inputVisible = computed(() => (index: number) => {
|
||||||
|
if (attributeIndex.value === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (attributeIndex.value === index) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}); // 输入框显隐控制
|
||||||
|
|
||||||
|
interface InputRefItem {
|
||||||
|
inputRef?: {
|
||||||
|
attributes: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputRef = ref<InputRefItem[]>([]); // 标签输入框 Ref
|
||||||
|
const attributeList = ref<PropertyAndValues[]>([]); // 商品属性列表
|
||||||
|
const attributeOptions = ref<MallPropertyApi.PropertyValue[]>([]); // 商品属性值下拉框
|
||||||
|
|
||||||
|
/** 解决 ref 在 v-for 中的获取问题*/
|
||||||
|
function setInputRef(el: any) {
|
||||||
|
if (el === null || el === undefined) return;
|
||||||
|
// 如果不存在 id 相同的元素才添加
|
||||||
|
if (
|
||||||
|
!inputRef.value.some(
|
||||||
|
(item) => item.inputRef?.attributes.id === el.inputRef?.attributes.id,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
inputRef.value.push(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.propertyList,
|
||||||
|
(data) => {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attributeList.value = data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 删除属性值 */
|
||||||
|
function handleCloseValue(index: number, value: PropertyAndValues) {
|
||||||
|
if (attributeList.value[index]?.values) {
|
||||||
|
attributeList.value[index].values = attributeList.value[
|
||||||
|
index
|
||||||
|
].values?.filter((item) => item.id !== value.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除属性 */
|
||||||
|
function handleCloseProperty(item: PropertyAndValues) {
|
||||||
|
attributeList.value = attributeList.value.filter(
|
||||||
|
(attribute) => attribute.id !== item.id,
|
||||||
|
);
|
||||||
|
emit('success', attributeList.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显示输入框并获取焦点 */
|
||||||
|
async function showInput(index: number) {
|
||||||
|
attributeIndex.value = index;
|
||||||
|
inputRef.value?.[index]?.focus();
|
||||||
|
// 获取属性下拉选项
|
||||||
|
await getAttributeOptions(attributeList.value?.[index]?.id!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 定义 success 事件,用于操作成功后的回调 */
|
||||||
|
async function handleInputConfirm(index: number, propertyId: number) {
|
||||||
|
// 从数组中取最后一个输入的值(tags 模式下 inputValue 是数组)
|
||||||
|
const currentValue = inputValue.value?.[inputValue.value.length - 1]?.trim();
|
||||||
|
|
||||||
|
if (currentValue) {
|
||||||
|
// 1. 重复添加校验
|
||||||
|
if (
|
||||||
|
attributeList.value?.[index]?.values?.find(
|
||||||
|
(item) => item.name === currentValue,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
ElMessage.warning('已存在相同属性值,请重试');
|
||||||
|
attributeIndex.value = null;
|
||||||
|
inputValue.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.1 情况一:属性值已存在,则直接使用并结束
|
||||||
|
const existValue = attributeOptions.value.find(
|
||||||
|
(item) => item.name === currentValue,
|
||||||
|
);
|
||||||
|
if (existValue) {
|
||||||
|
attributeIndex.value = null;
|
||||||
|
inputValue.value = [];
|
||||||
|
attributeList.value?.[index]?.values?.push({
|
||||||
|
id: existValue.id!,
|
||||||
|
name: existValue.name,
|
||||||
|
});
|
||||||
|
emit('success', attributeList.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.2 情况二:新属性值,则进行保存
|
||||||
|
try {
|
||||||
|
const id = await createPropertyValue({
|
||||||
|
propertyId,
|
||||||
|
name: currentValue,
|
||||||
|
});
|
||||||
|
attributeList.value?.[index]?.values?.push({
|
||||||
|
id,
|
||||||
|
name: currentValue,
|
||||||
|
});
|
||||||
|
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||||
|
emit('success', attributeList.value);
|
||||||
|
} catch {
|
||||||
|
ElMessage.error($t('ui.actionMessage.operationFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
attributeIndex.value = null;
|
||||||
|
inputValue.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取商品属性下拉选项 */
|
||||||
|
async function getAttributeOptions(propertyId: number) {
|
||||||
|
attributeOptions.value = await getPropertyValueSimpleList(propertyId);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ElCol v-for="(attribute, index) in attributeList" :key="index">
|
||||||
|
<ElDivider class="my-3" />
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="mx-1">属性名:</span>
|
||||||
|
<ElTag
|
||||||
|
:closable="!isDetail"
|
||||||
|
class="mx-1"
|
||||||
|
type="success"
|
||||||
|
@close="handleCloseProperty(attribute)"
|
||||||
|
>
|
||||||
|
{{ attribute.name }}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="mx-1">属性值:</span>
|
||||||
|
<ElTag
|
||||||
|
v-for="(value, valueIndex) in attribute.values"
|
||||||
|
:key="valueIndex"
|
||||||
|
:closable="!isDetail"
|
||||||
|
class="mx-1"
|
||||||
|
@close="handleCloseValue(index, value)"
|
||||||
|
>
|
||||||
|
{{ value?.name }}
|
||||||
|
</ElTag>
|
||||||
|
<ElSelect
|
||||||
|
v-show="inputVisible(index)"
|
||||||
|
:id="`input${index}`"
|
||||||
|
:ref="setInputRef"
|
||||||
|
v-model="inputValue"
|
||||||
|
clearable
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
:max-collapse-tags="1"
|
||||||
|
size="small"
|
||||||
|
style="width: 100px"
|
||||||
|
@blur="handleInputConfirm(index, attribute.id)"
|
||||||
|
@change="handleInputConfirm(index, attribute.id)"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="item2 in attributeOptions"
|
||||||
|
:key="item2.id"
|
||||||
|
:value="item2.name"
|
||||||
|
:label="item2.name"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
<ElTag
|
||||||
|
v-show="!inputVisible(index)"
|
||||||
|
@click="showInput(index)"
|
||||||
|
class="mx-1 cursor-pointer"
|
||||||
|
effect="plain"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<IconifyIcon class="mr-2" icon="ep:plus" />
|
||||||
|
添加
|
||||||
|
</div>
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElCol>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<!-- 商品发布 - 库存价格 - 添加属性 -->
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import type { VbenFormSchema } from '#/adapter/form';
|
||||||
|
import type { MallPropertyApi } from '#/api/mall/product/property';
|
||||||
|
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
import {
|
||||||
|
createProperty,
|
||||||
|
getPropertySimpleList,
|
||||||
|
} from '#/api/mall/product/property';
|
||||||
|
import { $t } from '#/locales';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProductPropertyAddForm' });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
propertyList: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
success: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const attributeList = ref<any[]>([]); // 商品属性列表
|
||||||
|
const attributeOptions = ref<MallPropertyApi.Property[]>([]); // 商品属性名称下拉框
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.propertyList,
|
||||||
|
(data) => {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attributeList.value = data as any[];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const formSchema: VbenFormSchema[] = [
|
||||||
|
{
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '属性名称',
|
||||||
|
component: 'ApiSelect',
|
||||||
|
componentProps: {
|
||||||
|
api: async () => {
|
||||||
|
const data = await getPropertySimpleList();
|
||||||
|
attributeOptions.value = data;
|
||||||
|
return data.map((item: MallPropertyApi.Property) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.name,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
filterable: true,
|
||||||
|
placeholder: '请选择属性名称。如果不存在,可手动输入选择',
|
||||||
|
multiple: true,
|
||||||
|
allowCreate: true,
|
||||||
|
clearable: true,
|
||||||
|
},
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm({
|
||||||
|
commonConfig: {
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
formItemClass: 'col-span-2',
|
||||||
|
labelWidth: 80,
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: formSchema,
|
||||||
|
showDefaultActions: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
destroyOnClose: true,
|
||||||
|
async onConfirm() {
|
||||||
|
const { valid } = await formApi.validate();
|
||||||
|
if (!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.lock();
|
||||||
|
const values = await formApi.getValues();
|
||||||
|
// name 为数组,遍历数组,进行重复添加校验
|
||||||
|
const names = values.name;
|
||||||
|
for (const name of names) {
|
||||||
|
// 重复添加校验
|
||||||
|
for (const attrItem of attributeList.value) {
|
||||||
|
if (attrItem.name === name) {
|
||||||
|
ElMessage.error('该属性已存在,请勿重复添加');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of names) {
|
||||||
|
const existProperty = attributeOptions.value.find(
|
||||||
|
(item: MallPropertyApi.Property) => item.name === name,
|
||||||
|
);
|
||||||
|
if (existProperty) {
|
||||||
|
// 情况一:如果属性已存在,则直接使用并结束
|
||||||
|
attributeList.value.push({
|
||||||
|
id: existProperty.id,
|
||||||
|
name,
|
||||||
|
values: [],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 情况二:如果是不存在的属性,则需要执行新增
|
||||||
|
const propertyId = await createProperty({ name });
|
||||||
|
attributeList.value.push({
|
||||||
|
id: propertyId,
|
||||||
|
name,
|
||||||
|
values: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElMessage.success($t('ui.actionMessage.operationSuccess'));
|
||||||
|
modalApi.unlock();
|
||||||
|
await modalApi.close();
|
||||||
|
emit('success');
|
||||||
|
},
|
||||||
|
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await formApi.resetForm();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal title="添加商品属性">
|
||||||
|
<Form />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -7,17 +7,11 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
|
|
||||||
import { confirm, DocAlert, Page } from '@vben/common-ui';
|
import { confirm, DocAlert, Page } from '@vben/common-ui';
|
||||||
import { ProductSpuStatusEnum } from '@vben/constants';
|
import { ProductSpuStatusEnum } from '@vben/constants';
|
||||||
import {
|
import { downloadFileFromBlobPart } from '@vben/utils';
|
||||||
downloadFileFromBlobPart,
|
|
||||||
fenToYuan,
|
|
||||||
handleTree,
|
|
||||||
treeToString,
|
|
||||||
} from '@vben/utils';
|
|
||||||
|
|
||||||
import { ElDescriptions, ElLoading, ElMessage, ElTabs } from 'element-plus';
|
import { ElLoading, ElMessage, ElTabs } from 'element-plus';
|
||||||
|
|
||||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import { getCategoryList } from '#/api/mall/product/category';
|
|
||||||
import {
|
import {
|
||||||
deleteSpu,
|
deleteSpu,
|
||||||
exportSpu,
|
exportSpu,
|
||||||
@@ -30,11 +24,8 @@ import { $t } from '#/locales';
|
|||||||
import { useGridColumns, useGridFormSchema } from './data';
|
import { useGridColumns, useGridFormSchema } from './data';
|
||||||
|
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
const tabType = ref(0);
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const categoryList = ref();
|
const tabType = ref('0');
|
||||||
|
|
||||||
// tabs 数据
|
|
||||||
const tabsData = ref([
|
const tabsData = ref([
|
||||||
{
|
{
|
||||||
name: '出售中',
|
name: '出售中',
|
||||||
@@ -69,13 +60,19 @@ async function handleRefresh() {
|
|||||||
await getTabCount();
|
await getTabCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 导出表格 */
|
||||||
|
async function handleExport() {
|
||||||
|
const data = await exportSpu(await gridApi.formApi.getValues());
|
||||||
|
downloadFileFromBlobPart({ fileName: '商品.xls', source: data });
|
||||||
|
}
|
||||||
|
|
||||||
/** 获得每个 Tab 的数量 */
|
/** 获得每个 Tab 的数量 */
|
||||||
async function getTabCount() {
|
async function getTabCount() {
|
||||||
const res = await getTabsCount();
|
const res = await getTabsCount();
|
||||||
for (const objName in res) {
|
for (const objName in res) {
|
||||||
const index = Number(objName);
|
const index = Number(objName);
|
||||||
if (tabsData.value[index]) {
|
if (tabsData.value[index]) {
|
||||||
tabsData.value[index].count = res[objName] as number;
|
tabsData.value[index].count = res[objName]!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,12 +82,6 @@ function handleCreate() {
|
|||||||
push({ name: 'ProductSpuAdd' });
|
push({ name: 'ProductSpuAdd' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导出表格 */
|
|
||||||
async function handleExport() {
|
|
||||||
const data = await exportSpu(await gridApi.formApi.getValues());
|
|
||||||
downloadFileFromBlobPart({ fileName: '商品.xls', source: data });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 编辑商品 */
|
/** 编辑商品 */
|
||||||
function handleEdit(row: MallSpuApi.Spu) {
|
function handleEdit(row: MallSpuApi.Spu) {
|
||||||
push({ name: 'ProductSpuEdit', params: { id: row.id } });
|
push({ name: 'ProductSpuEdit', params: { id: row.id } });
|
||||||
@@ -102,7 +93,7 @@ async function handleDelete(row: MallSpuApi.Spu) {
|
|||||||
text: $t('ui.actionMessage.deleting', [row.name]),
|
text: $t('ui.actionMessage.deleting', [row.name]),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await deleteSpu(row.id as number);
|
await deleteSpu(row.id!);
|
||||||
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
ElMessage.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||||
await handleRefresh();
|
await handleRefresh();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -110,19 +101,6 @@ async function handleDelete(row: MallSpuApi.Spu) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 添加到仓库 / 回收站的状态 */
|
|
||||||
async function handleStatus02Change(row: MallSpuApi.Spu, newStatus: number) {
|
|
||||||
// 二次确认
|
|
||||||
const text =
|
|
||||||
newStatus === ProductSpuStatusEnum.RECYCLE.status
|
|
||||||
? '加入到回收站'
|
|
||||||
: '恢复到仓库';
|
|
||||||
await confirm(`确认要"${row.name}"${text}吗?`);
|
|
||||||
await updateStatus({ id: row.id as number, status: newStatus });
|
|
||||||
ElMessage.success(`${text}成功`);
|
|
||||||
await handleRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 更新状态 */
|
/** 更新状态 */
|
||||||
async function handleStatusChange(
|
async function handleStatusChange(
|
||||||
newStatus: number,
|
newStatus: number,
|
||||||
@@ -130,14 +108,14 @@ async function handleStatusChange(
|
|||||||
): Promise<boolean | undefined> {
|
): Promise<boolean | undefined> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 二次确认
|
// 二次确认
|
||||||
const text = row.status ? '上架' : '下架';
|
const text = newStatus ? '上架' : '下架';
|
||||||
confirm({
|
confirm({
|
||||||
content: `确认要${text + row.name}吗?`,
|
content: `确认要${text + row.name}吗?`,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
// 更新状态
|
// 更新状态
|
||||||
await updateStatus({
|
await updateStatus({
|
||||||
id: row.id as number,
|
id: row.id!,
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
});
|
});
|
||||||
// 提示并返回成功
|
// 提示并返回成功
|
||||||
@@ -150,6 +128,27 @@ async function handleStatusChange(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 添加到仓库 / 回收站的状态 */
|
||||||
|
async function handleStatus02Change(row: MallSpuApi.Spu, newStatus: number) {
|
||||||
|
const text =
|
||||||
|
newStatus === ProductSpuStatusEnum.RECYCLE.status
|
||||||
|
? '加入到回收站'
|
||||||
|
: '恢复到仓库';
|
||||||
|
await confirm({
|
||||||
|
content: `确认要"${row.name}"${text}吗?`,
|
||||||
|
});
|
||||||
|
const loadingInstance = ElLoading.service({
|
||||||
|
text: `正在${text}中...`,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await updateStatus({ id: row.id!, status: newStatus });
|
||||||
|
ElMessage.success(`${text}成功`);
|
||||||
|
await handleRefresh();
|
||||||
|
} finally {
|
||||||
|
loadingInstance.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 查看商品详情 */
|
/** 查看商品详情 */
|
||||||
function handleDetail(row: MallSpuApi.Spu) {
|
function handleDetail(row: MallSpuApi.Spu) {
|
||||||
push({ name: 'ProductSpuDetail', params: { id: row.id } });
|
push({ name: 'ProductSpuDetail', params: { id: row.id } });
|
||||||
@@ -162,12 +161,6 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
gridOptions: {
|
gridOptions: {
|
||||||
columns: useGridColumns(handleStatusChange),
|
columns: useGridColumns(handleStatusChange),
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
cellConfig: {
|
|
||||||
height: 80,
|
|
||||||
},
|
|
||||||
expandConfig: {
|
|
||||||
height: 100,
|
|
||||||
},
|
|
||||||
keepSource: true,
|
keepSource: true,
|
||||||
proxyConfig: {
|
proxyConfig: {
|
||||||
ajax: {
|
ajax: {
|
||||||
@@ -175,7 +168,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
return await getSpuPage({
|
return await getSpuPage({
|
||||||
pageNo: page.currentPage,
|
pageNo: page.currentPage,
|
||||||
pageSize: page.pageSize,
|
pageSize: page.pageSize,
|
||||||
tabType: tabType.value,
|
tabType: Number(tabType.value),
|
||||||
...formValues,
|
...formValues,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -183,7 +176,7 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
},
|
},
|
||||||
rowConfig: {
|
rowConfig: {
|
||||||
keyField: 'id',
|
keyField: 'id',
|
||||||
resizable: true,
|
isHover: true,
|
||||||
},
|
},
|
||||||
toolbarConfig: {
|
toolbarConfig: {
|
||||||
refresh: true,
|
refresh: true,
|
||||||
@@ -192,8 +185,8 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
|||||||
} as VxeTableGridOptions<MallSpuApi.Spu>,
|
} as VxeTableGridOptions<MallSpuApi.Spu>,
|
||||||
});
|
});
|
||||||
|
|
||||||
function onChangeTab(key: any) {
|
function onChangeTab(key: number | string) {
|
||||||
tabType.value = Number(key);
|
tabType.value = key.toString();
|
||||||
gridApi.query();
|
gridApi.query();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,9 +197,8 @@ onMounted(async () => {
|
|||||||
categoryId: Number(route.query.categoryId),
|
categoryId: Number(route.query.categoryId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 获得每个 Tab 的数量
|
||||||
await getTabCount();
|
await getTabCount();
|
||||||
const categoryRes = await getCategoryList({});
|
|
||||||
categoryList.value = handleTree(categoryRes, 'id', 'parentId', 'children');
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -221,12 +213,12 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<template #toolbar-actions>
|
<template #toolbar-actions>
|
||||||
<ElTabs class="w-full" @tab-change="onChangeTab" :stretch="true">
|
<ElTabs v-model="tabType" class="w-full" @tab-change="onChangeTab">
|
||||||
<ElTabs.TabPane
|
<ElTabs.TabPane
|
||||||
v-for="item in tabsData"
|
v-for="item in tabsData"
|
||||||
:key="item.type"
|
:key="item.type"
|
||||||
:label="`${item.name} (${item.count})`"
|
:label="`${item.name} (${item.count})`"
|
||||||
:name="item.type"
|
:name="String(item.type)"
|
||||||
/>
|
/>
|
||||||
</ElTabs>
|
</ElTabs>
|
||||||
</template>
|
</template>
|
||||||
@@ -250,39 +242,6 @@ onMounted(async () => {
|
|||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<!-- TODO @霖:展开的样子,不展示信息 -->
|
|
||||||
<template #expand_content="{ row }">
|
|
||||||
<ElDescriptions
|
|
||||||
:column="4"
|
|
||||||
class="mt-4"
|
|
||||||
:label-style="{
|
|
||||||
width: '100px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
fontSize: '14px',
|
|
||||||
}"
|
|
||||||
:content-style="{ width: '100px', fontSize: '14px' }"
|
|
||||||
>
|
|
||||||
<ElDescriptions.Item label="商品分类">
|
|
||||||
{{ treeToString(categoryList, row.categoryId || 0) }}
|
|
||||||
</ElDescriptions.Item>
|
|
||||||
<ElDescriptions.Item label="商品名称">
|
|
||||||
{{ row.name }}
|
|
||||||
</ElDescriptions.Item>
|
|
||||||
|
|
||||||
<ElDescriptions.Item label="市场价">
|
|
||||||
{{ fenToYuan(row.marketPrice || 0) }} 元
|
|
||||||
</ElDescriptions.Item>
|
|
||||||
<ElDescriptions.Item label="成本价">
|
|
||||||
{{ fenToYuan(row.costPrice || 0) }} 元
|
|
||||||
</ElDescriptions.Item>
|
|
||||||
<ElDescriptions.Item label="浏览量">
|
|
||||||
{{ row.browseCount || 0 }}
|
|
||||||
</ElDescriptions.Item>
|
|
||||||
<ElDescriptions.Item label="虚拟销量">
|
|
||||||
{{ row.virtualSalesCount || 0 }}
|
|
||||||
</ElDescriptions.Item>
|
|
||||||
</ElDescriptions>
|
|
||||||
</template>
|
|
||||||
<template #actions="{ row }">
|
<template #actions="{ row }">
|
||||||
<TableAction
|
<TableAction
|
||||||
:actions="[
|
:actions="[
|
||||||
@@ -307,7 +266,7 @@ onMounted(async () => {
|
|||||||
link: true,
|
link: true,
|
||||||
icon: ACTION_ICON.DELETE,
|
icon: ACTION_ICON.DELETE,
|
||||||
auth: ['product:spu:delete'],
|
auth: ['product:spu:delete'],
|
||||||
ifShow: () => tabType === 4,
|
ifShow: () => tabType === '4',
|
||||||
popConfirm: {
|
popConfirm: {
|
||||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||||
confirm: handleDelete.bind(null, row),
|
confirm: handleDelete.bind(null, row),
|
||||||
@@ -319,7 +278,6 @@ onMounted(async () => {
|
|||||||
link: true,
|
link: true,
|
||||||
icon: ACTION_ICON.EDIT,
|
icon: ACTION_ICON.EDIT,
|
||||||
auth: ['product:spu:update'],
|
auth: ['product:spu:update'],
|
||||||
ifShow: () => tabType === 4,
|
|
||||||
onClick: handleStatus02Change.bind(
|
onClick: handleStatus02Change.bind(
|
||||||
null,
|
null,
|
||||||
row,
|
row,
|
||||||
@@ -332,7 +290,6 @@ onMounted(async () => {
|
|||||||
link: true,
|
link: true,
|
||||||
icon: ACTION_ICON.EDIT,
|
icon: ACTION_ICON.EDIT,
|
||||||
auth: ['product:spu:update'],
|
auth: ['product:spu:update'],
|
||||||
ifShow: () => tabType !== 4,
|
|
||||||
onClick: handleStatus02Change.bind(
|
onClick: handleStatus02Change.bind(
|
||||||
null,
|
null,
|
||||||
row,
|
row,
|
||||||
|
|||||||
@@ -1,504 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import type { DictDataType } from '@vben/hooks';
|
|
||||||
|
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
import { Page } from '@vben/common-ui';
|
|
||||||
import { DICT_TYPE } from '@vben/constants';
|
|
||||||
import { getDictOptions } from '@vben/hooks';
|
|
||||||
import { IconifyIcon } from '@vben/icons';
|
|
||||||
import { floatToFixed2 } from '@vben/utils';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ElButton,
|
|
||||||
ElCard,
|
|
||||||
ElCarousel,
|
|
||||||
ElCarouselItem,
|
|
||||||
ElDescriptions,
|
|
||||||
ElDescriptionsItem,
|
|
||||||
ElEmpty,
|
|
||||||
ElImage,
|
|
||||||
ElTabPane,
|
|
||||||
ElTabs,
|
|
||||||
ElTag,
|
|
||||||
} from 'element-plus';
|
|
||||||
|
|
||||||
import * as ProductBrandApi from '#/api/mall/product/brand';
|
|
||||||
import * as ProductCategoryApi from '#/api/mall/product/category';
|
|
||||||
import * as ProductSpuApi from '#/api/mall/product/spu';
|
|
||||||
|
|
||||||
interface Category {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
children?: Category[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Brand {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { push } = useRouter();
|
|
||||||
const { params } = useRoute();
|
|
||||||
|
|
||||||
const formLoading = ref(false); // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
|
|
||||||
const activeTab = ref('basic'); // 当前激活的标签页
|
|
||||||
const categoryList = ref<Category[]>([]); // 商品分类列表
|
|
||||||
const brandList = ref<Brand[]>([]); // 商品品牌列表
|
|
||||||
const deliveryTypeDict = ref<DictDataType[]>([]); // 配送方式字典
|
|
||||||
|
|
||||||
// SPU 表单数据
|
|
||||||
const formData = ref<MallSpuApi.Spu>({
|
|
||||||
name: '', // 商品名称
|
|
||||||
categoryId: undefined, // 商品分类
|
|
||||||
keyword: '', // 关键字
|
|
||||||
picUrl: '', // 商品封面图
|
|
||||||
sliderPicUrls: [], // 商品轮播图
|
|
||||||
introduction: '', // 商品简介
|
|
||||||
deliveryTypes: [], // 配送方式数组
|
|
||||||
deliveryTemplateId: undefined, // 运费模版
|
|
||||||
brandId: undefined, // 商品品牌
|
|
||||||
specType: false, // 商品规格
|
|
||||||
subCommissionType: false, // 分销类型
|
|
||||||
skus: [
|
|
||||||
{
|
|
||||||
price: 0, // 商品价格
|
|
||||||
marketPrice: 0, // 市场价
|
|
||||||
costPrice: 0, // 成本价
|
|
||||||
barCode: '', // 商品条码
|
|
||||||
picUrl: '', // 图片地址
|
|
||||||
stock: 0, // 库存
|
|
||||||
weight: 0, // 商品重量
|
|
||||||
volume: 0, // 商品体积
|
|
||||||
firstBrokeragePrice: 0, // 一级分销的佣金
|
|
||||||
secondBrokeragePrice: 0, // 二级分销的佣金
|
|
||||||
},
|
|
||||||
],
|
|
||||||
description: '', // 商品详情
|
|
||||||
sort: 0, // 商品排序
|
|
||||||
giveIntegral: 0, // 赠送积分
|
|
||||||
virtualSalesCount: 0, // 虚拟销量
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 获取配送方式字典 */
|
|
||||||
const getDeliveryTypeDict = async () => {
|
|
||||||
try {
|
|
||||||
deliveryTypeDict.value = await getDictOptions(
|
|
||||||
DICT_TYPE.TRADE_DELIVERY_TYPE,
|
|
||||||
'number',
|
|
||||||
);
|
|
||||||
deliveryTypeDict.value = await getDictOptions(
|
|
||||||
DICT_TYPE.TRADE_DELIVERY_TYPE,
|
|
||||||
'number',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取配送方式字典失败', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 获取商品分类列表 */
|
|
||||||
const getCategoryList = async () => {
|
|
||||||
try {
|
|
||||||
const data = await ProductCategoryApi.getCategorySimpleList();
|
|
||||||
categoryList.value = data as Category[];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取商品分类失败', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 获取商品品牌列表 */
|
|
||||||
const getBrandList = async () => {
|
|
||||||
try {
|
|
||||||
const data = await ProductBrandApi.getSimpleBrandList();
|
|
||||||
brandList.value = data as Brand[];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取商品品牌失败', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 根据ID获取分类名称 */
|
|
||||||
const getCategoryNameById = (id: number | undefined) => {
|
|
||||||
if (!id || !categoryList.value || categoryList.value.length === 0)
|
|
||||||
return '未知分类';
|
|
||||||
const category = categoryList.value.find((item) => item.id === id);
|
|
||||||
return category ? category.name : '未知分类';
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 根据ID获取品牌名称 */
|
|
||||||
const getBrandNameById = (id: number | undefined) => {
|
|
||||||
if (!id || !brandList.value || brandList.value.length === 0)
|
|
||||||
return '未知品牌';
|
|
||||||
const brand = brandList.value.find((item) => item.id === id);
|
|
||||||
return brand ? brand.name : '未知品牌';
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 根据值获取配送方式名称 */
|
|
||||||
const getDeliveryTypeName = (value: number) => {
|
|
||||||
if (!deliveryTypeDict.value || deliveryTypeDict.value.length === 0)
|
|
||||||
return `${value}`;
|
|
||||||
const dict = deliveryTypeDict.value.find((item) => item.value === value);
|
|
||||||
return dict ? dict.label : `${value}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 获得详情 */
|
|
||||||
const getDetail = async () => {
|
|
||||||
const id = params.id as unknown as number;
|
|
||||||
if (id) {
|
|
||||||
formLoading.value = true;
|
|
||||||
try {
|
|
||||||
const res = (await ProductSpuApi.getSpu(id)) as MallSpuApi.Spu;
|
|
||||||
res.skus?.forEach((item: MallSpuApi.Sku) => {
|
|
||||||
item.price = floatToFixed2(item.price);
|
|
||||||
item.marketPrice = floatToFixed2(item.marketPrice);
|
|
||||||
item.costPrice = floatToFixed2(item.costPrice);
|
|
||||||
item.firstBrokeragePrice = floatToFixed2(item.firstBrokeragePrice);
|
|
||||||
item.secondBrokeragePrice = floatToFixed2(item.secondBrokeragePrice);
|
|
||||||
});
|
|
||||||
formData.value = res;
|
|
||||||
} finally {
|
|
||||||
formLoading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 返回列表 */
|
|
||||||
const back = () => {
|
|
||||||
push({ name: 'ProductSpu' });
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 编辑商品 */
|
|
||||||
const editProduct = () => {
|
|
||||||
push({ name: 'ProductSpuForm', params: { id: params.id } });
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 初始化 */
|
|
||||||
onMounted(async () => {
|
|
||||||
await Promise.all([getCategoryList(), getBrandList(), getDeliveryTypeDict()]);
|
|
||||||
await getDetail();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Page auto-content-height :loading="formLoading">
|
|
||||||
<template #title>
|
|
||||||
<span class="text-lg font-bold">商品详情</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #extra>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<ElButton type="primary" @click="editProduct">
|
|
||||||
<IconifyIcon icon="ep:edit" class="mr-1" />
|
|
||||||
编辑商品
|
|
||||||
</ElButton>
|
|
||||||
<ElButton @click="back">
|
|
||||||
<IconifyIcon icon="ep:back" class="mr-1" />
|
|
||||||
返回列表
|
|
||||||
</ElButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<ElCard shadow="hover" class="mb-4">
|
|
||||||
<div class="mb-4 flex flex-col gap-4 md:flex-row md:items-center">
|
|
||||||
<ElImage
|
|
||||||
:src="formData.picUrl"
|
|
||||||
fit="contain"
|
|
||||||
style="width: 120px; height: 120px"
|
|
||||||
class="rounded border"
|
|
||||||
/>
|
|
||||||
<div class="flex-grow">
|
|
||||||
<h1 class="mb-2 text-xl font-bold">{{ formData.name }}</h1>
|
|
||||||
<div class="mb-2 text-gray-500">
|
|
||||||
{{ formData.introduction || '暂无简介' }}
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<ElTag v-if="formData.specType" type="success">多规格</ElTag>
|
|
||||||
<ElTag v-else type="info">单规格</ElTag>
|
|
||||||
<ElTag v-if="formData.subCommissionType" type="warning">分销</ElTag>
|
|
||||||
<ElTag type="danger">
|
|
||||||
库存:
|
|
||||||
{{
|
|
||||||
formData.skus?.reduce(
|
|
||||||
(sum, sku) => sum + (sku.stock || 0),
|
|
||||||
0,
|
|
||||||
) || 0
|
|
||||||
}}
|
|
||||||
</ElTag>
|
|
||||||
<ElTag type="info">
|
|
||||||
分类: {{ getCategoryNameById(formData.categoryId) }}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ElTabs v-model="activeTab" type="border-card">
|
|
||||||
<ElTabPane name="basic" label="基本信息">
|
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<!-- 基本信息 -->
|
|
||||||
<ElCard shadow="never" header="商品信息" class="h-full">
|
|
||||||
<ElDescriptions :column="1" border>
|
|
||||||
<ElDescriptionsItem label="商品名称">
|
|
||||||
{{ formData.name }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem label="商品分类">
|
|
||||||
<ElTag type="success">
|
|
||||||
{{ getCategoryNameById(formData.categoryId) }}
|
|
||||||
</ElTag>
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem label="商品品牌">
|
|
||||||
<ElTag type="primary">
|
|
||||||
{{ getBrandNameById(formData.brandId) }}
|
|
||||||
</ElTag>
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem label="关键字">
|
|
||||||
<ElTag type="danger" />{{ formData.keyword || '无' }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem label="赠送积分">
|
|
||||||
{{ formData.giveIntegral }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem label="虚拟销量">
|
|
||||||
{{ formData.virtualSalesCount }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem label="排序">
|
|
||||||
{{ formData.sort }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem label="规格类型">
|
|
||||||
<ElTag :type="formData.specType ? 'success' : 'info'">
|
|
||||||
{{ formData.specType ? '多规格' : '单规格' }}
|
|
||||||
</ElTag>
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem label="分销类型">
|
|
||||||
<ElTag
|
|
||||||
:type="formData.subCommissionType ? 'warning' : 'info'"
|
|
||||||
>
|
|
||||||
{{ formData.subCommissionType ? '单独设置' : '默认设置' }}
|
|
||||||
</ElTag>
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
</ElDescriptions>
|
|
||||||
</ElCard>
|
|
||||||
|
|
||||||
<!-- 配送信息 -->
|
|
||||||
<ElCard shadow="never" header="配送信息" class="h-full">
|
|
||||||
<ElDescriptions :column="1" border>
|
|
||||||
<ElDescriptionsItem label="配送方式">
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<ElTag
|
|
||||||
v-for="(type, index) in formData.deliveryTypes"
|
|
||||||
:key="index"
|
|
||||||
:type="
|
|
||||||
(deliveryTypeDict.find((dict) => dict.value === type)
|
|
||||||
?.colorType as
|
|
||||||
| 'success'
|
|
||||||
| 'warning'
|
|
||||||
| 'info'
|
|
||||||
| 'danger'
|
|
||||||
| 'primary'
|
|
||||||
| undefined) || undefined
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ getDeliveryTypeName(type) }}
|
|
||||||
</ElTag>
|
|
||||||
<span
|
|
||||||
v-if="
|
|
||||||
!formData.deliveryTypes ||
|
|
||||||
formData.deliveryTypes.length === 0
|
|
||||||
"
|
|
||||||
class="text-gray-400"
|
|
||||||
>
|
|
||||||
暂无配送方式
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
<ElDescriptionsItem label="运费模板">
|
|
||||||
{{ formData.deliveryTemplateId || '未设置' }}
|
|
||||||
</ElDescriptionsItem>
|
|
||||||
</ElDescriptions>
|
|
||||||
</ElCard>
|
|
||||||
</div>
|
|
||||||
</ElTabPane>
|
|
||||||
|
|
||||||
<ElTabPane name="images" label="商品图片">
|
|
||||||
<ElCard shadow="never" header="商品轮播图">
|
|
||||||
<div
|
|
||||||
v-if="formData.sliderPicUrls && formData.sliderPicUrls.length > 0"
|
|
||||||
>
|
|
||||||
<ElCarousel
|
|
||||||
height="400px"
|
|
||||||
:interval="4000"
|
|
||||||
indicator-position="outside"
|
|
||||||
arrow="always"
|
|
||||||
>
|
|
||||||
<ElCarouselItem
|
|
||||||
v-for="(item, index) in formData.sliderPicUrls"
|
|
||||||
:key="index"
|
|
||||||
>
|
|
||||||
<div class="flex h-full items-center justify-center">
|
|
||||||
<ElImage
|
|
||||||
:src="item"
|
|
||||||
fit="contain"
|
|
||||||
class="max-h-full"
|
|
||||||
:preview-src-list="formData.sliderPicUrls"
|
|
||||||
:initial-index="index"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ElCarouselItem>
|
|
||||||
</ElCarousel>
|
|
||||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in formData.sliderPicUrls"
|
|
||||||
:key="index"
|
|
||||||
class="cursor-pointer rounded border p-1"
|
|
||||||
>
|
|
||||||
<ElImage
|
|
||||||
:src="item"
|
|
||||||
fit="cover"
|
|
||||||
style="width: 80px; height: 80px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ElEmpty v-else description="暂无轮播图" />
|
|
||||||
</ElCard>
|
|
||||||
</ElTabPane>
|
|
||||||
|
|
||||||
<ElTabPane name="sku" label="SKU信息">
|
|
||||||
<div v-if="formData.skus && formData.skus.length > 0">
|
|
||||||
<div
|
|
||||||
v-for="(sku, index) in formData.skus"
|
|
||||||
:key="index"
|
|
||||||
class="mb-6"
|
|
||||||
>
|
|
||||||
<ElCard
|
|
||||||
shadow="hover"
|
|
||||||
:header="`规格 ${index + 1}${sku.properties && sku.properties.length > 0 ? ` - ${sku.properties.map((p) => p.valueName).join('/')}` : ''}`"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-4 md:flex-row">
|
|
||||||
<ElImage
|
|
||||||
:src="sku.picUrl || formData.picUrl"
|
|
||||||
fit="contain"
|
|
||||||
style="width: 120px; height: 120px"
|
|
||||||
class="flex-shrink-0 rounded border"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="grid flex-grow grid-cols-1 gap-6 md:grid-cols-3">
|
|
||||||
<!-- 价格信息 -->
|
|
||||||
<div class="rounded bg-gray-50 p-4">
|
|
||||||
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
|
|
||||||
价格信息
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<div class="text-gray-500">销售价:</div>
|
|
||||||
<div class="font-bold text-red-500">
|
|
||||||
¥{{ sku.price }}
|
|
||||||
</div>
|
|
||||||
<div class="text-gray-500">市场价:</div>
|
|
||||||
<div>¥{{ sku.marketPrice }}</div>
|
|
||||||
<div class="text-gray-500">成本价:</div>
|
|
||||||
<div>¥{{ sku.costPrice }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 库存信息 -->
|
|
||||||
<div class="rounded bg-gray-50 p-4">
|
|
||||||
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
|
|
||||||
库存信息
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<div class="text-gray-500">库存:</div>
|
|
||||||
<div class="font-bold">{{ sku.stock }} 件</div>
|
|
||||||
<div class="text-gray-500">条码:</div>
|
|
||||||
<div>{{ sku.barCode || '未设置' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 物流信息 -->
|
|
||||||
<div class="rounded bg-gray-50 p-4">
|
|
||||||
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
|
|
||||||
物流信息
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<div class="text-gray-500">重量:</div>
|
|
||||||
<div>{{ sku.weight }} kg</div>
|
|
||||||
<div class="text-gray-500">体积:</div>
|
|
||||||
<div>{{ sku.volume }} m³</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分销佣金 -->
|
|
||||||
<div
|
|
||||||
v-if="formData.subCommissionType"
|
|
||||||
class="mt-4 rounded bg-yellow-50 p-4"
|
|
||||||
>
|
|
||||||
<h3 class="mb-2 border-b pb-2 font-bold text-gray-700">
|
|
||||||
分销佣金
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
|
||||||
<div class="text-gray-500">一级佣金:</div>
|
|
||||||
<div class="font-bold">¥{{ sku.firstBrokeragePrice }}</div>
|
|
||||||
<div class="text-gray-500">二级佣金:</div>
|
|
||||||
<div class="font-bold">¥{{ sku.secondBrokeragePrice }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 规格属性 -->
|
|
||||||
<div
|
|
||||||
v-if="sku.properties && sku.properties.length > 0"
|
|
||||||
class="mt-4"
|
|
||||||
>
|
|
||||||
<h3 class="mb-2 font-bold text-gray-700">规格属性</h3>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<ElTag
|
|
||||||
v-for="(prop, propIndex) in sku.properties"
|
|
||||||
:key="propIndex"
|
|
||||||
effect="dark"
|
|
||||||
class="text-sm"
|
|
||||||
>
|
|
||||||
{{ prop.propertyName }}: {{ prop.valueName }}
|
|
||||||
</ElTag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ElCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ElEmpty v-else description="暂无SKU信息" />
|
|
||||||
</ElTabPane>
|
|
||||||
|
|
||||||
<ElTabPane name="detail" label="商品详情">
|
|
||||||
<ElCard shadow="never" body-style="padding: 0;">
|
|
||||||
<div v-if="formData.description" class="product-description">
|
|
||||||
<div v-html="formData.description"></div>
|
|
||||||
</div>
|
|
||||||
<ElEmpty v-else description="暂无商品详情" />
|
|
||||||
</ElCard>
|
|
||||||
</ElTabPane>
|
|
||||||
</ElTabs>
|
|
||||||
</ElCard>
|
|
||||||
</Page>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.product-description {
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-description :deep(img) {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-description :deep(table) {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-description :deep(table td) {
|
|
||||||
padding: 8px;
|
|
||||||
border: 1px solid #eee;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import type { MallSpuApi } from '#/api/mall/product/spu';
|
|
||||||
|
|
||||||
import { onMounted, ref } from 'vue';
|
|
||||||
import { useRoute } from 'vue-router';
|
|
||||||
|
|
||||||
import { ContentWrap, Page } from '@vben/common-ui';
|
|
||||||
import { convertToInteger, formatToFraction } from '@vben/utils';
|
|
||||||
|
|
||||||
import { ElButton, ElTabs } from 'element-plus';
|
|
||||||
|
|
||||||
import { useVbenForm } from '#/adapter/form';
|
|
||||||
import { createSpu, getSpu, updateSpu } from '#/api/mall/product/spu';
|
|
||||||
|
|
||||||
import {
|
|
||||||
useDeliveryFormSchema,
|
|
||||||
useDescriptionFormSchema,
|
|
||||||
useInfoFormSchema,
|
|
||||||
useOtherFormSchema,
|
|
||||||
useSkuFormSchema,
|
|
||||||
} from './form-data';
|
|
||||||
|
|
||||||
const spuId = ref<number>();
|
|
||||||
const { params } = useRoute();
|
|
||||||
|
|
||||||
const activeTab = ref('info');
|
|
||||||
|
|
||||||
const [InfoForm, infoFormApi] = useVbenForm({
|
|
||||||
commonConfig: {
|
|
||||||
componentProps: {
|
|
||||||
class: 'w-full',
|
|
||||||
},
|
|
||||||
formItemClass: 'col-span-2',
|
|
||||||
labelWidth: 120,
|
|
||||||
},
|
|
||||||
layout: 'horizontal',
|
|
||||||
schema: useInfoFormSchema(),
|
|
||||||
showDefaultActions: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [SkuForm, skuFormApi] = useVbenForm({
|
|
||||||
commonConfig: {
|
|
||||||
componentProps: {
|
|
||||||
class: 'w-full',
|
|
||||||
},
|
|
||||||
formItemClass: 'col-span-2',
|
|
||||||
labelWidth: 120,
|
|
||||||
},
|
|
||||||
layout: 'horizontal',
|
|
||||||
schema: useSkuFormSchema(),
|
|
||||||
showDefaultActions: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [DeliveryForm, deliveryFormApi] = useVbenForm({
|
|
||||||
commonConfig: {
|
|
||||||
componentProps: {
|
|
||||||
class: 'w-full',
|
|
||||||
},
|
|
||||||
formItemClass: 'col-span-2',
|
|
||||||
labelWidth: 120,
|
|
||||||
},
|
|
||||||
layout: 'horizontal',
|
|
||||||
schema: useDeliveryFormSchema(),
|
|
||||||
showDefaultActions: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [DescriptionForm, descriptionFormApi] = useVbenForm({
|
|
||||||
commonConfig: {
|
|
||||||
componentProps: {
|
|
||||||
class: 'w-full',
|
|
||||||
},
|
|
||||||
formItemClass: 'col-span-2',
|
|
||||||
labelWidth: 120,
|
|
||||||
},
|
|
||||||
layout: 'vertical',
|
|
||||||
schema: useDescriptionFormSchema(),
|
|
||||||
showDefaultActions: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [OtherForm, otherFormApi] = useVbenForm({
|
|
||||||
commonConfig: {
|
|
||||||
componentProps: {
|
|
||||||
class: 'w-full',
|
|
||||||
},
|
|
||||||
formItemClass: 'col-span-2',
|
|
||||||
labelWidth: 120,
|
|
||||||
},
|
|
||||||
layout: 'horizontal',
|
|
||||||
schema: useOtherFormSchema(),
|
|
||||||
showDefaultActions: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onSubmit() {
|
|
||||||
const values: MallSpuApi.Spu = await infoFormApi
|
|
||||||
.merge(skuFormApi)
|
|
||||||
.merge(deliveryFormApi)
|
|
||||||
.merge(descriptionFormApi)
|
|
||||||
.merge(otherFormApi)
|
|
||||||
.submitAllForm(true);
|
|
||||||
|
|
||||||
if (values.skus) {
|
|
||||||
values.skus.forEach((item) => {
|
|
||||||
// sku相关价格元转分
|
|
||||||
item.price = convertToInteger(item.price);
|
|
||||||
item.marketPrice = convertToInteger(item.marketPrice);
|
|
||||||
item.costPrice = convertToInteger(item.costPrice);
|
|
||||||
item.firstBrokeragePrice = convertToInteger(item.firstBrokeragePrice);
|
|
||||||
item.secondBrokeragePrice = convertToInteger(item.secondBrokeragePrice);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 处理轮播图列表
|
|
||||||
const newSliderPicUrls: any[] = [];
|
|
||||||
values.sliderPicUrls!.forEach((item: any) => {
|
|
||||||
// 如果是前端选的图
|
|
||||||
typeof item === 'object'
|
|
||||||
? newSliderPicUrls.push(item.url)
|
|
||||||
: newSliderPicUrls.push(item);
|
|
||||||
});
|
|
||||||
values.sliderPicUrls = newSliderPicUrls;
|
|
||||||
|
|
||||||
await (spuId.value ? updateSpu(values) : createSpu(values));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initDate() {
|
|
||||||
spuId.value = params.id as unknown as number;
|
|
||||||
if (!spuId.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await getSpu(spuId.value);
|
|
||||||
if (res.skus) {
|
|
||||||
res.skus.forEach((item) => {
|
|
||||||
// 回显价格分转元
|
|
||||||
item.price = formatToFraction(item.price);
|
|
||||||
item.marketPrice = formatToFraction(item.marketPrice);
|
|
||||||
item.costPrice = formatToFraction(item.costPrice);
|
|
||||||
item.firstBrokeragePrice = formatToFraction(item.firstBrokeragePrice);
|
|
||||||
item.secondBrokeragePrice = formatToFraction(item.secondBrokeragePrice);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
infoFormApi.setValues(res);
|
|
||||||
skuFormApi.setValues(res);
|
|
||||||
deliveryFormApi.setValues(res);
|
|
||||||
descriptionFormApi.setValues(res);
|
|
||||||
otherFormApi.setValues(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await initDate();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Page auto-content-height>
|
|
||||||
<ContentWrap class="h-full w-full pb-8">
|
|
||||||
<template #extra>
|
|
||||||
<ElButton type="primary" @click="onSubmit"> 保存 </ElButton>
|
|
||||||
</template>
|
|
||||||
<ElTabs v-model="activeTab">
|
|
||||||
<ElTabs.TabPane label="基础设置" name="info">
|
|
||||||
<InfoForm class="w-3/5" />
|
|
||||||
</ElTabs.TabPane>
|
|
||||||
<ElTabs.TabPane label="价格库存" name="sku">
|
|
||||||
<SkuForm class="w-3/5" />
|
|
||||||
</ElTabs.TabPane>
|
|
||||||
<ElTabs.TabPane label="物流设置" name="delivery">
|
|
||||||
<DeliveryForm class="w-3/5" />
|
|
||||||
</ElTabs.TabPane>
|
|
||||||
<ElTabs.TabPane label="商品详情" name="description">
|
|
||||||
<DescriptionForm class="w-3/5" />
|
|
||||||
</ElTabs.TabPane>
|
|
||||||
<ElTabs.TabPane label="其它设置" name="other">
|
|
||||||
<OtherForm class="w-3/5" />
|
|
||||||
</ElTabs.TabPane>
|
|
||||||
</ElTabs>
|
|
||||||
</ContentWrap>
|
|
||||||
</Page>
|
|
||||||
</template>
|
|
||||||
Reference in New Issue
Block a user