feat: 新增商品管理模块,包含商品分类、品牌、SPU管理及相关表单组件

This commit is contained in:
吃货
2025-07-06 21:27:44 +08:00
parent 4cc5d8bf92
commit f0516fa857
21 changed files with 2465 additions and 17 deletions

View File

@@ -103,6 +103,9 @@ export function useGridColumns(): VxeGridPropTypes.Columns {
title: '品牌图片',
cellRender: {
name: 'CellImage',
props: {
class: 'w-10 h-10',
},
},
},
{

View File

@@ -0,0 +1,84 @@
<script lang="ts" setup>
import { useVbenForm } from '#/adapter/form';
import * as ExpressTemplateApi from '#/api/mall/trade/delivery/expressTemplate';
import { watch } from 'vue';
import { ElMessage } from 'element-plus';
import { DICT_TYPE, getIntDictOptions, DeliveryTypeEnum } from '#/utils';
const props = defineProps<{
propFormData: Object;
}>();
/** 将传进来的值赋值给 formData */
watch(
() => props.propFormData,
(data) => {
if (!data) {
return;
}
formApi.setValues(data);
},
);
const emit = defineEmits(['update:activeName']);
const validate = async () => {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
try {
// 校验通过更新数据
Object.assign(props.propFormData, formApi.getValues());
} catch (e) {
ElMessage.error('【物流设置】不完善,请填写相关信息');
emit('update:activeName', 'delivery');
throw e; // 目的截断之后的校验
}
};
defineExpose({ validate });
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: '!w-1/6',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: [
{
fieldName: 'deliveryTypes',
label: '配送方式',
component: 'CheckboxGroup',
componentProps: {
options: getIntDictOptions(DICT_TYPE.TRADE_DELIVERY_TYPE),
},
rules: 'required',
},
{
fieldName: 'deliveryTemplateId',
label: '运费模板',
component: 'ApiSelect',
componentProps: {
api: ExpressTemplateApi.getSimpleTemplateList,
props: {
label: 'name',
value: 'id',
children: 'children',
},
},
rules: 'required',
dependencies: {
triggerFields: ['deliveryTypes'],
show: (values) =>
values.deliveryTypes.includes(DeliveryTypeEnum.EXPRESS.type),
},
},
],
showDefaultActions: false,
});
</script>
<template>
<Form />
</template>

View File

@@ -0,0 +1,66 @@
<script lang="ts" setup>
import { useVbenForm } from '#/adapter/form';
import * as ExpressTemplateApi from '#/api/mall/trade/delivery/expressTemplate';
import { watch } from 'vue';
import { ElMessage } from 'element-plus';
import { DICT_TYPE, getIntDictOptions, DeliveryTypeEnum } from '#/utils';
const props = defineProps<{
propFormData: Object;
}>();
/** 将传进来的值赋值给 formData */
watch(
() => props.propFormData,
(data) => {
if (!data) {
return;
}
formApi.setValues(data);
},
);
const emit = defineEmits(['update:activeName']);
const validate = async () => {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
try {
// 校验通过更新数据
Object.assign(props.propFormData, formApi.getValues());
} catch (e) {
ElMessage.error('【商品详情】不完善,请填写相关信息');
emit('update:activeName', 'description');
throw e; // 目的截断之后的校验
}
};
defineExpose({ validate });
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: '!w-1/6',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: [
{
fieldName: 'description',
label: '商品详情',
component: 'RichTextarea',
componentProps: {
placeholder: '请输入商品详情',
height: 1000,
},
rules: 'required',
},
],
showDefaultActions: false,
});
</script>
<template>
<Form />
</template>

View File

@@ -0,0 +1,138 @@
<script lang="ts" setup>
import { useVbenForm } from '#/adapter/form';
import { handleTree } from '#/utils';
import * as ProductCategoryApi from '#/api/mall/product/category';
import * as ProductBrandApi from '#/api/mall/product/brand';
import { watch } from 'vue';
import { ElMessage } from 'element-plus';
const getCategoryList = async () => {
const data = await ProductCategoryApi.getCategorySimpleList();
return handleTree(data, 'id');
};
const props = defineProps<{
propFormData: Object;
}>();
/** 将传进来的值赋值给 formData */
watch(
() => props.propFormData,
(data) => {
if (!data) {
return;
}
formApi.setValues(data);
},
);
const emit = defineEmits(['update:activeName']);
const validate = async () => {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
try {
// 校验通过更新数据
Object.assign(props.propFormData, formApi.getValues());
} catch (e) {
ElMessage.error('【基础设置】不完善,请填写相关信息');
emit('update:activeName', 'info');
throw e; // 目的截断之后的校验
}
};
defineExpose({ validate });
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: '!w-1/6',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: [
{
fieldName: 'name',
label: '商品名称',
component: 'Input',
componentProps: {
placeholder: '请输入商品名称',
},
rules: 'required',
},
{
fieldName: 'categoryId',
label: '商品分类',
component: 'ApiCascader',
componentProps: {
api: getCategoryList,
props: {
label: 'name',
value: 'id',
children: 'children',
},
},
rules: 'required',
},
{
fieldName: 'brandId',
label: '商品品牌',
component: 'ApiSelect',
componentProps: {
api: ProductBrandApi.getSimpleBrandList,
labelField: 'name',
valueField: 'id',
},
},
{
fieldName: 'keyword',
label: '商品关键字',
component: 'Input',
componentProps: {
placeholder: '请输入商品关键字',
},
rules: 'required',
},
{
fieldName: 'introduction',
label: '商品简介',
component: 'Input',
componentProps: {
type: 'textarea',
placeholder: '请输入商品简介',
maxlength: 128,
showWordLimit: true,
autosize: {
minRows: 4,
maxRows: 4,
},
},
rules: 'required',
},
{
fieldName: 'picUrl',
label: '商品封面图',
component: 'ImageUpload',
componentProps: {
max: 1,
class: 'w-full',
},
},
{
fieldName: 'sliderPicUrls',
label: '商品轮播图',
component: 'ImageUpload',
componentProps: {
max: 10,
class: 'w-full',
},
},
],
showDefaultActions: false,
});
</script>
<template>
<Form />
</template>

View File

@@ -0,0 +1,62 @@
import SkuList from './SkuList.vue';
import { Spu } from '@/api/mall/product/spu';
interface PropertyAndValues {
id: number;
name: string;
values?: PropertyAndValues[];
}
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;
}
/**
* 获得商品的规格列表 - 商品相关的公共函数
*
* @param spu
* @return PropertyAndValues 规格列表
*/
const getPropertyList = (spu: 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 { SkuList, PropertyAndValues, RuleConfig, getPropertyList };

View File

@@ -0,0 +1,86 @@
<script lang="ts" setup>
import { useVbenForm } from '#/adapter/form';
import * as ExpressTemplateApi from '#/api/mall/trade/delivery/expressTemplate';
import { watch } from 'vue';
import { ElMessage } from 'element-plus';
import { DICT_TYPE, getIntDictOptions, DeliveryTypeEnum } from '#/utils';
const props = defineProps<{
propFormData: Object;
}>();
/** 将传进来的值赋值给 formData */
watch(
() => props.propFormData,
(data) => {
if (!data) {
return;
}
formApi.setValues(data);
},
);
const emit = defineEmits(['update:activeName']);
const validate = async () => {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
try {
// 校验通过更新数据
Object.assign(props.propFormData, formApi.getValues());
} catch (e) {
ElMessage.error('【其它设置】不完善,请填写相关信息');
emit('update:activeName', 'other');
throw e; // 目的截断之后的校验
}
};
defineExpose({ validate });
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: '!w-1/6',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: [
{
fieldName: 'sort',
label: '商品排序',
component: 'InputNumber',
componentProps: {
min: 0,
step: 1,
},
rules: 'required',
},
{
fieldName: 'giveIntegral',
label: '赠送积分',
component: 'InputNumber',
componentProps: {
min: 0,
step: 1,
},
rules: 'required',
},
{
fieldName: 'virtualSalesCount',
label: '虚拟销量',
component: 'InputNumber',
componentProps: {
min: 0,
step: 1,
},
rules: 'required',
},
],
showDefaultActions: false,
});
</script>
<template>
<Form />
</template>

View File

@@ -0,0 +1,195 @@
<!-- 商品发布 - 库存价格 - 属性列表 -->
<template>
<el-col v-for="(item, index) in attributeList" :key="index">
<div>
<el-text class="mx-1">属性名</el-text>
<el-tag class="mx-1" type="success" @close="handleCloseProperty(index)">
{{ item.name }}
</el-tag>
</div>
<div>
<el-text class="mx-1">属性值</el-text>
<el-tag
v-for="(value, valueIndex) in item.values"
:key="value.id"
class="mx-1"
@close="handleCloseValue(index, valueIndex)"
>
{{ value.name }}
</el-tag>
<el-select
v-show="inputVisible(index)"
:id="`input${index}`"
:ref="setInputRef"
v-model="inputValue"
:reserve-keyword="false"
allow-create
class="!w-30"
default-first-option
filterable
size="small"
@blur="handleInputConfirm(index, item.id)"
@change="handleInputConfirm(index, item.id)"
@keyup.enter="handleInputConfirm(index, item.id)"
>
<el-option
v-for="item2 in attributeOptions"
:key="item2.id"
:label="item2.name"
:value="item2.name"
/>
</el-select>
<el-button
v-show="!inputVisible(index)"
class="button-new-tag ml-1"
size="small"
@click="showInput(index)"
>
+ 添加
</el-button>
</div>
<el-divider class="my-10px" />
</el-col>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue';
import type { PropType } from 'vue';
import * as PropertyApi from '#/api/mall/product/property';
import type { MallPropertyApi } from '#/api/mall/product/property';
import { ElMessage } from 'element-plus';
import { $t } from '#/locales';
// 定义PropertyAndValues接口
interface PropertyAndValues {
id: number;
name: string;
values: Array<{
id: number;
name: string;
}>;
}
defineOptions({ name: 'ProductAttributes' });
const inputValue = ref(''); // 输入框值
const attributeIndex = ref<number | null>(null); // 获取焦点时记录当前属性项的index
// 输入框显隐控制
const inputVisible = computed(() => (index: number) => {
if (attributeIndex.value === null) return false;
if (attributeIndex.value === index) return true;
});
const inputRef = ref<any[]>([]); //标签输入框Ref
/** 解决 ref 在 v-for 中的获取问题*/
const setInputRef = (el: any) => {
if (el === null || typeof el === 'undefined') return;
// 如果不存在 id 相同的元素才添加
if (
!inputRef.value.some(
(item) => item.inputRef?.attributes.id === el.inputRef?.attributes.id,
)
) {
inputRef.value.push(el);
}
};
const attributeList = ref<PropertyAndValues[]>([]); // 商品属性列表
const attributeOptions = ref([] as MallPropertyApi.PropertyValue[]); // 商品属性名称下拉框
const props = defineProps({
propertyList: {
type: Array as PropType<PropertyAndValues[]>,
default: () => [],
},
});
watch(
() => props.propertyList,
(data) => {
if (!data) return;
attributeList.value = data as any;
},
{
deep: true,
immediate: true,
},
);
/** 删除属性值*/
const handleCloseValue = (index: number, valueIndex: number) => {
if (index < attributeList.value.length) {
attributeList.value[index]!.values.splice(valueIndex, 1);
}
};
/** 删除属性*/
const handleCloseProperty = (index: number) => {
if (index < attributeList.value.length) {
attributeList.value.splice(index, 1);
emit('success', attributeList.value);
}
};
/** 显示输入框并获取焦点 */
const showInput = async (index: number) => {
if (index < attributeList.value.length) {
attributeIndex.value = index;
inputRef.value[index].focus();
// 获取属性下拉选项
await getAttributeOptions(attributeList.value[index]!.id);
}
};
/** 输入框失去焦点或点击回车时触发 */
const emit = defineEmits(['success']); // 定义 success 事件,用于操作成功后的回调
const handleInputConfirm = async (index: number, propertyId: number) => {
if (inputValue.value && index < attributeList.value.length) {
// 1. 重复添加校验
if (
attributeList.value[index]!.values.find(
(item) => item.name === inputValue.value,
)
) {
ElMessage.warning('已存在相同属性值,请重试');
attributeIndex.value = null;
inputValue.value = '';
return;
}
// 2.1 情况一:属性值已存在,则直接使用并结束
const existValue = attributeOptions.value.find(
(item) => item.name === inputValue.value,
);
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 PropertyApi.createPropertyValue({
propertyId,
name: inputValue.value,
});
attributeList.value[index]!.values.push({ id, name: inputValue.value });
ElMessage.success($t('common.createSuccess'));
emit('success', attributeList.value);
} catch {
ElMessage.error('添加失败,请重试');
}
}
attributeIndex.value = null;
inputValue.value = '';
};
/** 获取商品属性下拉选项 */
const getAttributeOptions = async (propertyId: number) => {
attributeOptions.value =
await PropertyApi.getPropertyValueSimpleList(propertyId);
};
</script>

View File

@@ -0,0 +1,130 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import type { PropType } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { ElMessage } from 'element-plus';
import { useVbenForm } from '#/adapter/form';
import { $t } from '#/locales';
import { getPropertySimpleList } from '#/api/mall/product/property';
import * as PropertyApi from '#/api/mall/product/property';
import type { MallPropertyApi } from '#/api/mall/product/property';
// 扩展Property接口添加values属性
interface ExtendedProperty extends MallPropertyApi.Property {
values?: any[];
}
const emit = defineEmits(['success']);
const attributeList = ref<ExtendedProperty[]>([]); // 商品属性列表
const attributeOptions = ref([] as MallPropertyApi.Property[]); // 商品属性名称下拉框
const props = defineProps({
propertyList: {
type: Array as PropType<ExtendedProperty[]>,
default: () => [],
},
});
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: [
{
fieldName: 'name',
label: '属性名称',
component: 'ApiSelect',
componentProps: {
api: getPropertySimpleList,
labelField: 'name',
valueField: 'id',
defaultFirstOption: true,
filterable: true,
allowCreate: true,
placeholder: '请选择属性名称。如果不存在,可手动输入选择',
},
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
modalApi.lock();
const { name } = await formApi.getValues();
// 1.1 重复添加校验
for (const attrItem of attributeList.value) {
if (attrItem.name === name) {
return ElMessage.error('该属性已存在,请勿重复添加');
}
}
// 1.2 校验表单
const { valid } = await formApi.validate();
if (!valid) {
return;
}
// 2.1 情况一:属性名已存在,则直接使用并结束
const existProperty = attributeOptions.value.find(
(item) => item.name === name,
);
if (existProperty) {
// 添加到属性列表
attributeList.value.push({
id: existProperty.id,
...(await formApi.getValues()),
values: [],
});
// 关闭弹窗
modalApi.close();
return;
}
// 2.2 情况二:如果是不存在的属性,则需要执行新增
// 提交请求
modalApi.lock();
try {
const data = (await formApi.getValues()) as MallPropertyApi.Property;
const propertyId = await PropertyApi.createProperty(data);
// 添加到属性列表
attributeList.value.push({
id: propertyId,
...(await formApi.getValues()),
values: [],
});
// 关闭弹窗
ElMessage.success($t('common.createSuccess'));
modalApi.close();
} finally {
modalApi.unlock();
}
},
});
watch(
() => props.propertyList, // 解决 props 无法直接修改父组件的问题
(data) => {
if (!data) return;
attributeList.value = data;
},
{
deep: true,
immediate: true,
},
);
</script>
<template>
<Modal class="w-2/5" title="添加商品属性">
<Form class="mx-4" />
</Modal>
</template>

View File

@@ -0,0 +1,192 @@
<script lang="ts" setup>
import { useVbenForm } from '#/adapter/form';
import { watch, ref } from 'vue';
import { ElMessage } from 'element-plus';
import SkuList from './sku-list.vue';
import { Page, useVbenModal } from '@vben/common-ui';
import ProductPropertyAddForm from './product-property-add-form.vue';
const props = defineProps<{
propFormData: Object;
}>();
interface PropertyAndValues {
id: number;
name: string;
values?: PropertyAndValues[];
}
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;
}
const propertyList = ref<PropertyAndValues[]>([]); // 商品属性列表
// sku 相关属性校验规则
const ruleConfig: RuleConfig[] = [
{
name: 'stock',
rule: (arg) => arg >= 0,
message: '商品库存必须大于等于 1 ',
},
{
name: 'price',
rule: (arg) => arg >= 0.01,
message: '商品销售价格必须大于等于 0.01 元!!!',
},
{
name: 'marketPrice',
rule: (arg) => arg >= 0.01,
message: '商品市场价格必须大于等于 0.01 元!!!',
},
{
name: 'costPrice',
rule: (arg) => arg >= 0.01,
message: '商品成本价格必须大于等于 0.00 元!!!',
},
];
/** 将传进来的值赋值给 formData */
watch(
() => props.propFormData,
(data) => {
if (!data) {
return;
}
formApi.setValues(data);
},
);
const emit = defineEmits(['update:activeName']);
const validate = async () => {
const { valid } = await formApi.validate();
if (!valid) {
return;
}
try {
// 校验通过更新数据
Object.assign(props.propFormData, formApi.getValues());
} catch (e) {
ElMessage.error('【库存价格】不完善,请填写相关信息');
emit('update:activeName', 'sku');
throw e; // 目的截断之后的校验
}
};
defineExpose({ validate });
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: '!w-1/6',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: [
{
fieldName: 'subCommissionType',
label: '分销类型',
component: 'RadioGroup',
componentProps: {
options: [
{
label: '默认设置',
value: false,
},
{
label: '单独设置',
value: true,
},
],
},
defaultValue: false,
rules: 'required',
},
{
fieldName: 'specType',
label: '商品规格',
component: 'RadioGroup',
componentProps: {
options: [
{
label: '单规格',
value: false,
},
{
label: '多规格',
value: true,
},
],
},
defaultValue: false,
rules: 'required',
},
{
fieldName: 'skuList',
component: 'Input',
dependencies: {
triggerFields: ['specType'],
show: (values) => !values.specType,
},
},
{
fieldName: 'specTypeItem',
label: '商品属性',
component: 'Input',
dependencies: {
triggerFields: ['specType'],
show: (values) => values.specType,
},
},
],
showDefaultActions: false,
});
const [ProductPropertyAddFormModal, productPropertyAddFormApi] = useVbenModal({
connectedComponent: ProductPropertyAddForm,
destroyOnClose: true,
});
/** 调用 SkuList generateTableData 方法*/
const skuListRef = ref();
const generateSkus = (propertyList: any[]) => {
skuListRef.value.generateTableData(propertyList);
};
</script>
<template>
<Page :auto-content-height="true">
<Form>
<template #skuList>
<SkuList
ref="skuListRef"
:prop-form-data="props.propFormData"
:property-list="propertyList"
:rule-config="ruleConfig"
/>
</template>
<template #specTypeItem>
<ElButton type="primary" @click="productPropertyAddFormApi.open()"
>添加属性</ElButton
>
<ProductAttributes
:property-list="propertyList"
@success="generateSkus"
/>
</template>
</Form>
<ProductPropertyAddFormModal :propertyList="propertyList" />
</Page>
</template>

View File

@@ -0,0 +1,613 @@
<template>
<!-- 情况一添加/修改 -->
<el-table
v-if="!isDetail && !isActivityComponent"
:data="isBatch ? skuList : formData!.skus!"
border
class="tabNumWidth"
max-height="500"
size="small"
>
<el-table-column align="center" label="图片" min-width="120">
<template #default="{ row }">
<UploadImg
v-model="row.picUrl"
height="50px"
width="50px"
:show-description="false"
/>
</template>
</el-table-column>
<template v-if="formData!.specType && !isBatch">
<!-- 根据商品属性动态添加 -->
<el-table-column
v-for="(item, index) in tableHeaders"
:key="index"
:label="item.label"
align="center"
min-width="120"
>
<template #default="{ row }">
<span style="font-weight: bold; color: #40aaff">
{{ row.properties?.[index]?.valueName }}
</span>
</template>
</el-table-column>
</template>
<el-table-column align="center" label="商品条码" min-width="168">
<template #default="{ row }">
<el-input v-model="row.barCode" class="w-100%" />
</template>
</el-table-column>
<el-table-column align="center" label="销售价" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.price"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column align="center" label="市场价" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.marketPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column align="center" label="成本价" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.costPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column align="center" label="库存" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.stock"
:min="0"
class="w-100%"
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column align="center" label="重量(kg)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.weight"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column align="center" label="体积(m^3)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.volume"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
controls-position="right"
/>
</template>
</el-table-column>
<template v-if="formData!.subCommissionType">
<el-table-column align="center" label="一级返佣(元)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.firstBrokeragePrice"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column align="center" label="二级返佣(元)" min-width="168">
<template #default="{ row }">
<el-input-number
v-model="row.secondBrokeragePrice"
:min="0"
:precision="2"
:step="0.1"
class="w-100%"
controls-position="right"
/>
</template>
</el-table-column>
</template>
<el-table-column
v-if="formData?.specType"
align="center"
fixed="right"
label="操作"
width="80"
>
<template #default="{ row }">
<el-button
v-if="isBatch"
link
size="small"
type="primary"
@click="batchAdd"
>
批量添加
</el-button>
<el-button
v-else
link
size="small"
type="primary"
@click="deleteSku(row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
<!-- 情况二详情 -->
<el-table
v-if="isDetail"
ref="activitySkuListRef"
:data="formData!.skus!"
border
max-height="500"
size="small"
style="width: 99%"
@selection-change="handleSelectionChange"
>
<el-table-column v-if="isComponent" type="selection" width="45" />
<el-table-column align="center" label="图片" min-width="80">
<template #default="{ row }">
<el-image v-if="row.picUrl" :src="row.picUrl" class="h-50px w-50px" />
</template>
</el-table-column>
<template v-if="formData!.specType && !isBatch">
<!-- 根据商品属性动态添加 -->
<el-table-column
v-for="(item, index) in tableHeaders"
:key="index"
:label="item.label"
align="center"
min-width="80"
>
<template #default="{ row }">
<span style="font-weight: bold; color: #40aaff">
{{ row.properties?.[index]?.valueName }}
</span>
</template>
</el-table-column>
</template>
<el-table-column align="center" label="商品条码" min-width="100">
<template #default="{ row }">
{{ row.barCode }}
</template>
</el-table-column>
<el-table-column align="center" label="销售价(元)" min-width="80">
<template #default="{ row }">
{{ row.price }}
</template>
</el-table-column>
<el-table-column align="center" label="市场价(元)" min-width="80">
<template #default="{ row }">
{{ row.marketPrice }}
</template>
</el-table-column>
<el-table-column align="center" label="成本价(元)" min-width="80">
<template #default="{ row }">
{{ row.costPrice }}
</template>
</el-table-column>
<el-table-column align="center" label="库存" min-width="80">
<template #default="{ row }">
{{ row.stock }}
</template>
</el-table-column>
<el-table-column align="center" label="重量(kg)" min-width="80">
<template #default="{ row }">
{{ row.weight }}
</template>
</el-table-column>
<el-table-column align="center" label="体积(m^3)" min-width="80">
<template #default="{ row }">
{{ row.volume }}
</template>
</el-table-column>
<template v-if="formData!.subCommissionType">
<el-table-column align="center" label="一级返佣(元)" min-width="80">
<template #default="{ row }">
{{ row.firstBrokeragePrice }}
</template>
</el-table-column>
<el-table-column align="center" label="二级返佣(元)" min-width="80">
<template #default="{ row }">
{{ row.secondBrokeragePrice }}
</template>
</el-table-column>
</template>
</el-table>
<!-- 情况三作为活动组件 -->
<el-table
v-if="isActivityComponent"
:data="formData!.skus!"
border
max-height="500"
size="small"
style="width: 99%"
>
<el-table-column v-if="isComponent" type="selection" width="45" />
<el-table-column align="center" label="图片" min-width="80">
<template #default="{ row }">
<el-image :src="row.picUrl" class="h-60px w-60px" />
</template>
</el-table-column>
<template v-if="formData!.specType">
<!-- 根据商品属性动态添加 -->
<el-table-column
v-for="(item, index) in tableHeaders"
:key="index"
:label="item.label"
align="center"
min-width="80"
>
<template #default="{ row }">
<span style="font-weight: bold; color: #40aaff">
{{ row.properties?.[index]?.valueName }}
</span>
</template>
</el-table-column>
</template>
<el-table-column align="center" label="商品条码" min-width="100">
<template #default="{ row }">
{{ row.barCode }}
</template>
</el-table-column>
<el-table-column align="center" label="销售价(元)" min-width="80">
<template #default="{ row }">
{{ formatToFraction(row.price) }}
</template>
</el-table-column>
<el-table-column align="center" label="市场价(元)" min-width="80">
<template #default="{ row }">
{{ formatToFraction(row.marketPrice) }}
</template>
</el-table-column>
<el-table-column align="center" label="成本价(元)" min-width="80">
<template #default="{ row }">
{{ formatToFraction(row.costPrice) }}
</template>
</el-table-column>
<el-table-column align="center" label="库存" min-width="80">
<template #default="{ row }">
{{ row.stock }}
</template>
</el-table-column>
<!-- 方便扩展每个活动配置的属性不一样 -->
<slot name="extension"></slot>
</el-table>
</template>
<script lang="ts" setup>
import { copyValueToTarget, formatToFraction } from '#/utils';
import type { PropertyAndValues, RuleConfig } from './model';
import UploadImg from '#/components/upload/image-upload.vue';
import { ElTable, ElInput, ElMessage } from 'element-plus';
import { isEmpty } from '#/utils/is';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { ref, watch } from 'vue';
import type { PropType } from 'vue';
defineOptions({ name: 'SkuList' });
const props = defineProps({
propFormData: {
type: Object as PropType<MallSpuApi.Spu>,
default: () => ({}),
},
propertyList: {
type: Array as PropType<PropertyAndValues[]>,
default: () => [],
},
ruleConfig: {
type: Array as PropType<RuleConfig[]>,
default: () => [],
},
isBatch: {
type: Boolean,
default: false,
}, // 是否作为批量操作组件
isDetail: {
type: Boolean,
default: false,
}, // 是否作为 sku 详情组件
isComponent: {
type: Boolean,
default: false,
}, // 是否作为组件
isActivityComponent: {
type: Boolean,
default: false,
}, // 是否作为活动组件
});
const formData = ref<MallSpuApi.Spu>(); // 表单数据
const skuList = ref<MallSpuApi.Sku[]>([
{
price: 0, // 商品价格
marketPrice: 0, // 市场价
costPrice: 0, // 成本价
barCode: '', // 商品条码
picUrl: '', // 图片地址
stock: 0, // 库存
weight: 0, // 商品重量
volume: 0, // 商品体积
firstBrokeragePrice: 0, // 一级分销的佣金
secondBrokeragePrice: 0, // 二级分销的佣金
},
]); // 批量添加时的临时数据
/** 批量添加 */
const batchAdd = () => {
validateProperty();
formData.value!.skus!.forEach((item: MallSpuApi.Sku) => {
copyValueToTarget(item, skuList.value[0]);
});
};
/** 校验商品属性属性值 */
const validateProperty = () => {
// 校验商品属性属性值是否为空,有一个为空都不给过
const warningInfo = '存在属性属性值为空,请先检查完善属性值后重试!!!';
for (const item of props.propertyList) {
if (!item.values || isEmpty(item.values)) {
ElMessage.warning(warningInfo);
throw new Error(warningInfo);
}
}
};
/** 删除 sku */
const deleteSku = (row: MallSpuApi.Sku) => {
const index = formData.value!.skus!.findIndex(
// 直接把列表转成字符串比较
(sku: MallSpuApi.Sku) =>
JSON.stringify(sku.properties) === JSON.stringify(row.properties),
);
formData.value!.skus!.splice(index, 1);
};
const tableHeaders = ref<{ prop: string; label: string }[]>([]); // 多属性表头
/**
* 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
*/
const validateSku = () => {
validateProperty();
let warningInfo = '请检查商品各行相关属性配置,';
let validate = true; // 默认通过
for (const sku of formData.value!.skus!) {
// 作为活动组件的校验
for (const rule of props?.ruleConfig) {
const arg = getValue(sku, rule.name);
if (!rule.rule(arg)) {
validate = false; // 只要有一个不通过则直接不通过
warningInfo += rule.message;
break;
}
}
// 只要有一个不通过则结束后续的校验
if (!validate) {
ElMessage.warning(warningInfo);
throw new Error(warningInfo);
}
}
};
const getValue = (obj: any, arg: string) => {
const keys = arg.split('.');
let value = obj;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
} else {
value = undefined;
break;
}
}
return value;
};
const emit = defineEmits<{
(e: 'selectionChange', value: MallSpuApi.Sku[]): void;
}>();
/**
* 选择时触发
* @param Sku 传递过来的选中的 sku 是一个数组
*/
const handleSelectionChange = (val: MallSpuApi.Sku[]) => {
emit('selectionChange', val);
};
/**
* 将传进来的值赋值给 skuList
*/
watch(
() => props.propFormData,
(data) => {
if (!data) return;
formData.value = data;
},
{
deep: true,
immediate: true,
},
);
/** 生成表数据 */
const generateTableData = (propertyList: any[]) => {
// 构建数据结构
const propertyValues = propertyList.map((item) =>
item.values.map((v: any) => ({
propertyId: item.id,
propertyName: item.name,
valueId: v.id,
valueName: v.name,
})),
);
const buildSkuList = build(propertyValues);
// 如果回显的 sku 属性和添加的属性不一致则重置 skus 列表
if (!validateData(propertyList)) {
// 如果不一致则重置表数据,默认添加新的属性重新生成 sku 列表
formData.value!.skus = [];
}
if (buildSkuList && buildSkuList.length > 0) {
for (const item of buildSkuList) {
const row = {
properties: Array.isArray(item) ? item : [item], // 如果只有一个属性的话返回的是一个 property 对象
price: 0,
marketPrice: 0,
costPrice: 0,
barCode: '',
picUrl: '',
stock: 0,
weight: 0,
volume: 0,
firstBrokeragePrice: 0,
secondBrokeragePrice: 0,
};
// 如果存在属性相同的 sku 则不做处理
const index = formData.value!.skus!.findIndex(
(sku: MallSpuApi.Sku) =>
JSON.stringify(sku.properties) === JSON.stringify(row.properties),
);
if (index !== -1) {
continue;
}
formData.value!.skus!.push(row);
}
}
};
/**
* 生成 skus 前置校验
*/
const validateData = (propertyList: any[]) => {
const skuPropertyIds: number[] = [];
formData.value!.skus!.forEach((sku: MallSpuApi.Sku) =>
sku.properties
?.map((property: any) => property.propertyId)
?.forEach((propertyId: number) => {
if (skuPropertyIds.indexOf(propertyId!) === -1) {
skuPropertyIds.push(propertyId!);
}
}),
);
const propertyIds = propertyList.map((item) => item.id);
return skuPropertyIds.length === propertyIds.length;
};
/** 构建所有排列组合 */
const build = (
propertyValuesList: MallSpuApi.Property[][],
): MallSpuApi.Property[] | MallSpuApi.Property[][] => {
if (!propertyValuesList || propertyValuesList.length === 0) {
return [];
} else if (propertyValuesList.length === 1) {
return propertyValuesList[0] || [];
} else {
const result: MallSpuApi.Property[][] = [];
const rest = build(propertyValuesList.slice(1));
if (propertyValuesList[0] && Array.isArray(rest)) {
for (let i = 0; i < propertyValuesList[0].length; i++) {
for (let j = 0; j < rest.length; j++) {
const currentItem = propertyValuesList[0][i];
const restItem = rest[j];
// 第一次不是数组结构,后面的都是数组结构
if (Array.isArray(restItem)) {
result.push([currentItem!, ...restItem]);
} else if (restItem) {
// 确保restItem不是undefined并进行类型断言
result.push([currentItem!, restItem as MallSpuApi.Property]);
}
}
}
}
return result;
}
};
/** 监听属性列表,生成相关参数和表头 */
watch(
() => props.propertyList,
(propertyList: PropertyAndValues[]) => {
// 如果不是多规格则结束
if (!formData.value!.specType) {
return;
}
// 如果当前组件作为批量添加数据使用,则重置表数据
if (props.isBatch) {
skuList.value = [
{
price: 0,
marketPrice: 0,
costPrice: 0,
barCode: '',
picUrl: '',
stock: 0,
weight: 0,
volume: 0,
firstBrokeragePrice: 0,
secondBrokeragePrice: 0,
},
];
}
// 判断代理对象是否为空
if (JSON.stringify(propertyList) === '[]') {
return;
}
// 重置表头
tableHeaders.value = [];
// 生成表头
propertyList.forEach((item, index) => {
// name加属性项index区分属性值
tableHeaders.value.push({ 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<InstanceType<typeof ElTable>>();
const getSkuTableRef = () => {
return activitySkuListRef.value;
};
// 暴露出生成 sku 方法,给添加属性成功时调用
defineExpose({ generateTableData, validateSku, getSkuTableRef });
</script>

View File

@@ -1,3 +1,127 @@
<script lang="ts" setup></script>
<script lang="ts" setup>
import { Page } from '@vben/common-ui';
import { onMounted, ref } from 'vue';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { useRouter, useRoute } from 'vue-router';
import { floatToFixed2, formatToFraction } from '#/utils';
import * as ProductSpuApi from '#/api/mall/product/spu';
<template>form</template>
import InfoForm from '../components/info-form.vue';
import DeliveryForm from '../components/delivery-form.vue';
import DescriptionForm from '../components/description-form.vue';
import OtherForm from '../components/other-form.vue';
import SkuForm from '../components/sku-form.vue';
const activeTab = ref('info');
const activeName = ref('info'); // Tag 激活的窗口
// 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 formLoading = ref(false); // 表单的加载中1修改时的数据加载2提交的按钮禁用
const isDetail = ref(false); // 是否查看详情
const { push, currentRoute } = useRouter(); // 路由
const { params, name } = useRoute(); // 查询参数
/** 获得详情 */
const getDetail = async () => {
if ('ProductSpuDetail' === name) {
isDetail.value = true;
}
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) => {
if (isDetail.value) {
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);
} else {
// 回显价格分转元
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;
} finally {
formLoading.value = false;
}
}
};
/** 关闭按钮 */
const close = () => {
push({ name: 'ProductSpu' });
};
/** 初始化 */
onMounted(async () => {
await getDetail();
});
</script>
<template>
<Page :auto-content-height="true">
<ElTabs v-model="activeTab">
<ElTabPane label="基础设置" name="info">
<InfoForm :propFormData="formData" v-model:activeName="activeName" />
</ElTabPane>
<ElTabPane label="价格库存" name="sku">
<SkuForm :propFormData="formData" v-model:activeName="activeName" />
</ElTabPane>
<ElTabPane label="物流设置" name="delivery">
<DeliveryForm
:propFormData="formData"
v-model:activeName="activeName"
/>
</ElTabPane>
<ElTabPane label="商品详情" name="description">
<DescriptionForm
:propFormData="formData"
v-model:activeName="activeName"
/>
</ElTabPane>
<ElTabPane label="其它设置" name="other">
<OtherForm :propFormData="formData" v-model:activeName="activeName" />
</ElTabPane>
</ElTabs>
</Page>
</template>