!235 feat:【mall 商城】商品发布 - 库存价格【antd】100%: 迁移完成

Merge pull request !235 from puhui999/dev-spu
This commit is contained in:
xingyu
2025-10-21 09:31:07 +00:00
committed by Gitee
9 changed files with 2357 additions and 901 deletions

View File

@@ -104,7 +104,10 @@ export function useInfoFormSchema(): VbenFormSchema[] {
}
/** 价格库存的表单 */
export function useSkuFormSchema(): VbenFormSchema[] {
export function useSkuFormSchema(
propertyList: any[] = [],
isDetail: boolean = false,
): VbenFormSchema[] {
return [
{
fieldName: 'id',
@@ -152,7 +155,55 @@ export function useSkuFormSchema(): VbenFormSchema[] {
},
rules: 'required',
},
// TODO @xingyu待补充商品属性
// 单规格时显示的 SkuList
{
fieldName: 'singleSkuList',
label: '',
component: 'Input',
componentProps: {},
dependencies: {
triggerFields: ['specType'],
// 当 specType 为 false单规格时显示
show: (values) => values.specType === false,
},
},
// 多规格时显示的商品属性(占位,实际通过插槽渲染)
{
fieldName: 'productAttributes',
label: '商品属性',
component: 'Input',
componentProps: {},
dependencies: {
triggerFields: ['specType'],
// 当 specType 为 true多规格时显示
show: (values) => values.specType === true,
},
},
// 多规格 - 批量设置
{
fieldName: 'batchSkuList',
label: '批量设置',
component: 'Input',
componentProps: {},
dependencies: {
triggerFields: ['specType'],
// 当 specType 为 true多规格且 propertyList 有数据时显示,且非详情模式
show: (values) =>
values.specType === true && propertyList.length > 0 && !isDetail,
},
},
// 多规格 - 规格列表
{
fieldName: 'multiSkuList',
label: '规格列表',
component: 'Input',
componentProps: {},
dependencies: {
triggerFields: ['specType'],
// 当 specType 为 true多规格且 propertyList 有数据时显示
show: (values) => values.specType === true && propertyList.length > 0,
},
},
];
}

View File

@@ -1,13 +1,15 @@
<script lang="ts" setup>
import type { PropertyAndValues, RuleConfig } from './index';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { onMounted, ref } from 'vue';
import { onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { ContentWrap, Page } from '@vben/common-ui';
import { convertToInteger, formatToFraction } from '@vben/utils';
import { ContentWrap, Page, useVbenModal } from '@vben/common-ui';
import { convertToInteger, floatToFixed2, formatToFraction } from '@vben/utils';
import { Button, Tabs } from 'ant-design-vue';
import { Button, message, Tabs } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createSpu, getSpu, updateSpu } from '#/api/mall/product/spu';
@@ -19,11 +21,74 @@ import {
useOtherFormSchema,
useSkuFormSchema,
} from './form-data';
import { getPropertyList } from './index';
import ProductAttributes from './product-attributes.vue';
import ProductPropertyAddForm from './product-property-add-form.vue';
import SkuList from './sku-list.vue';
const spuId = ref<number>();
const { params } = useRoute();
const { params, name } = useRoute();
const activeTabName = ref('info');
// 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 propertyList = ref<PropertyAndValues[]>([]); // 商品属性列表
const formLoading = ref(false); // 表单的加载中1修改时的数据加载2提交的按钮禁用
const isDetail = ref(false); // 是否查看详情
const skuListRef = ref(); // 商品属性列表 Ref
// 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 元!!!',
},
];
const [InfoForm, infoFormApi] = useVbenForm({
commonConfig: {
@@ -47,8 +112,23 @@ const [SkuForm, skuFormApi] = useVbenForm({
labelWidth: 120,
},
layout: 'horizontal',
schema: useSkuFormSchema(),
schema: useSkuFormSchema(propertyList.value, isDetail.value),
showDefaultActions: false,
handleValuesChange: (values, fieldsChanged) => {
if (fieldsChanged.includes('subCommissionType')) {
formData.value.subCommissionType = values.subCommissionType;
changeSubCommissionType();
}
if (fieldsChanged.includes('specType')) {
formData.value.specType = values.specType;
onChangeSpec();
}
},
});
const [ProductPropertyAddFormModal, productPropertyAddFormApi] = useVbenModal({
connectedComponent: ProductPropertyAddForm,
destroyOnClose: true,
});
const [DeliveryForm, deliveryFormApi] = useVbenForm({
@@ -97,8 +177,15 @@ async function onSubmit() {
.merge(descriptionFormApi)
.merge(otherFormApi)
.submitAllForm(true);
values.skus = formData.value.skus;
if (values.skus) {
try {
// 校验 sku
skuListRef.value.validateSku();
} catch {
message.error('【库存价格】不完善,请填写相关信息');
return;
}
values.skus.forEach((item) => {
// sku相关价格元转分
item.price = convertToInteger(item.price);
@@ -121,35 +208,113 @@ async function onSubmit() {
await (spuId.value ? updateSpu(values) : createSpu(values));
}
async function initDate() {
/** 获得详情 */
const getDetail = async () => {
if (name === 'ProductSpuDetail') {
isDetail.value = true;
}
const id = params.id as unknown as number;
if (id) {
formLoading.value = true;
try {
const res = await getSpu(spuId.value!);
res.skus?.forEach((item) => {
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;
// 初始化各表单值(异步)
infoFormApi.setValues(res);
skuFormApi.setValues(res);
deliveryFormApi.setValues(res);
descriptionFormApi.setValues(res);
otherFormApi.setValues(res);
} finally {
formLoading.value = false;
}
}
// 将 SKU 的属性,整理成 PropertyAndValues 数组
propertyList.value = getPropertyList(formData.value);
};
// =========== sku form 逻辑 ===========
function openPropertyAddForm() {
productPropertyAddFormApi.open();
}
/** 调用 SkuList generateTableData 方法*/
const generateSkus = (propertyList: any[]) => {
skuListRef.value.generateTableData(propertyList);
};
/** 分销类型 */
const changeSubCommissionType = () => {
// 默认为零,类型切换后也要重置为零
for (const item of formData.value.skus!) {
item.firstBrokeragePrice = 0;
item.secondBrokeragePrice = 0;
}
};
/** 选择规格 */
const onChangeSpec = () => {
// 重置商品属性列表
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;
}
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();
await getDetail();
});
</script>
<template>
<ProductPropertyAddFormModal :property-list="propertyList" />
<Page auto-content-height>
<ContentWrap class="h-full w-full pb-8">
<template #extra>
@@ -160,7 +325,44 @@ onMounted(async () => {
<InfoForm class="w-3/5" />
</Tabs.TabPane>
<Tabs.TabPane tab="价格库存" key="sku">
<SkuForm class="w-3/5" />
<SkuForm class="w-full">
<template #singleSkuList>
<SkuList
ref="skuListRef"
:prop-form-data="formData"
:property-list="propertyList"
:rule-config="ruleConfig"
/>
</template>
<template #productAttributes>
<div>
<Button class="mb-10px mr-15px" @click="openPropertyAddForm">
添加属性
</Button>
<ProductAttributes
:is-detail="isDetail"
:property-list="propertyList"
@success="generateSkus"
/>
</div>
</template>
<template #batchSkuList>
<SkuList
:is-batch="true"
: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>
</Tabs.TabPane>
<Tabs.TabPane tab="物流设置" key="delivery">
<DeliveryForm class="w-3/5" />

View File

@@ -0,0 +1,67 @@
import type { MallSpuApi } from '#/api/mall/product/spu';
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;
}
/**
* 获得商品的规格列表 - 商品相关的公共函数
*
* @param spu
* @return PropertyAndValues 规格列表
*/
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({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: propertyId!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
name: propertyName!,
values: [],
});
}
// 添加属性值
const index = properties?.findIndex((item) => item.id === propertyId);
if (
!properties[index]?.values?.some((value) => value.id === valueId)
) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
properties[index]?.values?.push({ id: valueId!, name: valueName! });
}
},
);
});
}
return properties;
};
export { getPropertyList };
// 导出组件
export { default as SkuList } from './sku-list.vue';

View File

@@ -0,0 +1,213 @@
<!-- 商品发布 - 库存价格 - 属性列表 -->
<script lang="ts" setup>
import type { PropertyAndValues } from './index';
import type { MallPropertyApi } from '#/api/mall/product/property';
import { computed, ref, watch } from 'vue';
import { Button, Col, Divider, message, Select, Tag } from 'ant-design-vue';
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
/** 解决 ref 在 v-for 中的获取问题*/
const 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);
}
};
const attributeList = ref<PropertyAndValues[]>([]); // 商品属性列表
const attributeOptions = ref<MallPropertyApi.PropertyValue[]>([]); // 商品属性值下拉框
watch(
() => props.propertyList,
(data) => {
if (!data) return;
attributeList.value = data;
},
{
deep: true,
immediate: true,
},
);
/** 删除属性值*/
const handleCloseValue = (index: number, valueIndex: number) => {
attributeList.value?.[index]?.values?.splice(valueIndex, 1);
};
/** 删除属性*/
const handleCloseProperty = (index: number) => {
attributeList.value?.splice(index, 1);
emit('success', attributeList.value);
};
/** 显示输入框并获取焦点 */
const showInput = async (index: number) => {
attributeIndex.value = index;
inputRef.value?.[index]?.focus();
// 获取属性下拉选项
await getAttributeOptions(attributeList.value?.[index]?.id!);
};
// 定义 success 事件,用于操作成功后的回调
const handleInputConfirm = async (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,
)
) {
message.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,
});
message.success($t('common.createSuccess'));
emit('success', attributeList.value);
} catch {
message.error('添加失败,请重试');
}
}
attributeIndex.value = null;
inputValue.value = [];
};
/** 获取商品属性下拉选项 */
const getAttributeOptions = async (propertyId: number) => {
attributeOptions.value = await getPropertyValueSimpleList(propertyId);
};
</script>
<template>
<Col v-for="(item, index) in attributeList" :key="index">
<div>
<span class="mx-1">属性名</span>
<Tag
:closable="!isDetail"
class="mx-1"
color="success"
@close="handleCloseProperty(index)"
>
{{ item.name }}
</Tag>
</div>
<div>
<span class="mx-1">属性值</span>
<Tag
v-for="(value, valueIndex) in item.values"
:key="value.id"
:closable="!isDetail"
class="mx-1"
@close="handleCloseValue(index, valueIndex)"
>
{{ value.name }}
</Tag>
<Select
v-show="inputVisible(index)"
:id="`input${index}`"
:ref="setInputRef"
v-model:value="inputValue"
allow-clear
mode="tags"
:max-tag-count="1"
:filter-option="true"
size="small"
style="width: 100px"
@blur="handleInputConfirm(index, item.id)"
@change="handleInputConfirm(index, item.id)"
@keyup.enter="handleInputConfirm(index, item.id)"
>
<Select.Option
v-for="item2 in attributeOptions"
:key="item2.id"
:value="item2.name"
>
{{ item2.name }}
</Select.Option>
</Select>
<Button
v-show="!inputVisible(index)"
class="button-new-tag ml-1"
size="small"
@click="showInput(index)"
>
+ 添加
</Button>
</div>
<Divider class="my-10px" />
</Col>
</template>

View File

@@ -0,0 +1,153 @@
<!-- 商品发布 - 库存价格 - 添加属性 -->
<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 { message } from 'ant-design-vue';
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,
}));
},
showSearch: true,
filterOption: true,
placeholder: '请选择属性名称。如果不存在,可手动输入选择',
// 支持手动输入新选项
mode: 'tags',
maxTagCount: 1,
allowClear: true,
},
rules: 'required',
},
];
// 初始化表单
const [Form, formApi] = useVbenForm({
commonConfig: {
componentProps: {
class: 'w-full',
},
formItemClass: 'col-span-2',
labelWidth: 120,
},
layout: 'horizontal',
schema: formSchema,
showDefaultActions: false,
});
// 初始化弹窗
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
async onConfirm() {
const { valid } = await formApi.validate();
if (!valid) return;
const values = await formApi.getValues();
const name = Array.isArray(values.name) ? values.name[0] : values.name;
// 重复添加校验
for (const attrItem of attributeList.value) {
if (attrItem.name === name) {
message.error('该属性已存在,请勿重复添加');
return;
}
}
// 情况一:属性名已存在,则直接使用
const existProperty = attributeOptions.value.find(
(item: MallPropertyApi.Property) => item.name === name,
);
if (existProperty) {
attributeList.value.push({
id: existProperty.id,
name,
values: [],
});
await modalApi.close();
emit('success');
return;
}
// 情况二:如果是不存在的属性,则需要执行新增
try {
const data = { name } as MallPropertyApi.Property;
const propertyId = await createProperty(data);
// 添加到属性列表
attributeList.value.push({
id: propertyId,
name,
values: [],
});
message.success($t('common.createSuccess'));
await modalApi.close();
emit('success');
} catch (error) {
// 发生错误时不关闭弹窗
console.error('添加属性失败:', error);
}
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
return;
}
// 重置表单
await formApi.resetForm();
},
});
</script>
<template>
<Modal title="添加商品属性">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,604 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import type { PropertyAndValues, RuleConfig } from './index';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { ref, watch } from 'vue';
import { copyValueToTarget, formatToFraction, isEmpty } from '@vben/utils';
import { Button, Image, Input, InputNumber, message } from 'ant-design-vue';
import { VxeColumn, VxeTable } from '#/adapter/vxe-table';
import ImageUpload from '#/components/upload/image-upload.vue';
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 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 as PropertyAndValues[]) {
if (!item.values || isEmpty(item.values)) {
message.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<{ label: string; prop: string }[]>([]); // 多属性表头
/**
* 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
*/
const validateSku = () => {
validateProperty();
let warningInfo = '请检查商品各行相关属性配置,';
let validate = true; // 默认通过
for (const sku of formData.value!.skus!) {
// 作为活动组件的校验
for (const rule of props?.ruleConfig as RuleConfig[]) {
const arg = getValue(sku, rule.name);
if (!rule.rule(arg)) {
validate = false; // 只要有一个不通过则直接不通过
warningInfo += rule.message;
break;
}
}
// 只要有一个不通过则结束后续的校验
if (!validate) {
message.warning(warningInfo);
throw new Error(warningInfo);
}
}
};
const getValue = (obj: any, arg: string): unknown => {
const keys = arg.split('.');
let value: any = obj;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
} else {
value = undefined;
break;
}
}
return value;
};
/**
* 选择时触发
* @param records 传递过来的选中的 sku 是一个数组
*/
const handleSelectionChange = ({ records }: { records: MallSpuApi.Sku[] }) => {
emit('selectionChange', records);
};
/**
* 将传进来的值赋值给 skuList
*/
watch(
() => props.propFormData,
(data) => {
if (!data) return;
formData.value = data;
},
{
deep: true,
immediate: true,
},
);
/** 生成表数据 */
const 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)) {
// 如果不一致则重置表数据,默认添加新的属性重新生成 sku 列表
formData.value!.skus = [];
}
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: 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;
};
/** 构建所有排列组合 */
const 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 = [
{
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();
const getSkuTableRef = () => {
return activitySkuListRef.value;
};
// 暴露出生成 sku 方法,给添加属性成功时调用
defineExpose({ generateTableData, validateSku, getSkuTableRef });
</script>
<template>
<!-- 情况一添加/修改 -->
<VxeTable
v-if="!isDetail && !isActivityComponent"
:data="isBatch ? skuList : formData?.skus || []"
border
max-height="500"
size="small"
class="w-full"
>
<VxeColumn align="center" title="图片" min-width="80">
<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"
min-width="120"
>
<template #default="{ row }">
<span class="font-bold text-[#40aaff]">
{{ row.properties?.[index]?.valueName }}
</span>
</template>
</VxeColumn>
</template>
<VxeColumn align="center" title="商品条码" min-width="168">
<template #default="{ row }">
<Input v-model:value="row.barCode" class="w-full" />
</template>
</VxeColumn>
<VxeColumn align="center" title="销售价" min-width="168">
<template #default="{ row }">
<InputNumber
v-model:value="row.price"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" title="市场价" min-width="168">
<template #default="{ row }">
<InputNumber
v-model:value="row.marketPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" title="成本价" min-width="168">
<template #default="{ row }">
<InputNumber
v-model:value="row.costPrice"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" title="库存" min-width="168">
<template #default="{ row }">
<InputNumber v-model:value="row.stock" :min="0" class="w-full" />
</template>
</VxeColumn>
<VxeColumn align="center" title="重量(kg)" min-width="168">
<template #default="{ row }">
<InputNumber
v-model:value="row.weight"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" title="体积(m^3)" min-width="168">
<template #default="{ row }">
<InputNumber
v-model:value="row.volume"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
<template v-if="formData?.subCommissionType">
<VxeColumn align="center" title="一级返佣(元)" min-width="168">
<template #default="{ row }">
<InputNumber
v-model:value="row.firstBrokeragePrice"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" title="二级返佣(元)" min-width="168">
<template #default="{ row }">
<InputNumber
v-model:value="row.secondBrokeragePrice"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
</template>
<VxeColumn
v-if="formData?.specType"
align="center"
fixed="right"
title="操作"
width="100"
>
<template #default="{ row }">
<Button v-if="isBatch" type="link" size="small" @click="batchAdd">
批量添加
</Button>
<Button v-else type="link" size="small" danger @click="deleteSku(row)">
删除
</Button>
</template>
</VxeColumn>
</VxeTable>
<!-- 情况二详情 -->
<VxeTable
v-if="isDetail"
ref="activitySkuListRef"
:data="formData?.skus || []"
border
max-height="500"
size="small"
class="w-full"
:checkbox-config="isComponent ? { reserve: true } : undefined"
@checkbox-change="handleSelectionChange"
@checkbox-all="handleSelectionChange"
>
<VxeColumn v-if="isComponent" type="checkbox" width="45" />
<VxeColumn align="center" title="图片" min-width="80">
<template #default="{ row }">
<Image
v-if="row.picUrl"
:src="row.picUrl"
class="h-[50px] w-[50px] cursor-pointer"
:preview="true"
/>
</template>
</VxeColumn>
<template v-if="formData?.specType && !isBatch">
<!-- 根据商品属性动态添加 -->
<VxeColumn
v-for="(item, index) in tableHeaders"
:key="index"
:title="item.label"
align="center"
min-width="80"
>
<template #default="{ row }">
<span class="font-bold text-[#40aaff]">
{{ row.properties?.[index]?.valueName }}
</span>
</template>
</VxeColumn>
</template>
<VxeColumn align="center" title="商品条码" min-width="100">
<template #default="{ row }">
{{ row.barCode }}
</template>
</VxeColumn>
<VxeColumn align="center" title="销售价(元)" min-width="80">
<template #default="{ row }">
{{ row.price }}
</template>
</VxeColumn>
<VxeColumn align="center" title="市场价(元)" min-width="80">
<template #default="{ row }">
{{ row.marketPrice }}
</template>
</VxeColumn>
<VxeColumn align="center" title="成本价(元)" min-width="80">
<template #default="{ row }">
{{ row.costPrice }}
</template>
</VxeColumn>
<VxeColumn align="center" title="库存" min-width="80">
<template #default="{ row }">
{{ row.stock }}
</template>
</VxeColumn>
<VxeColumn align="center" title="重量(kg)" min-width="80">
<template #default="{ row }">
{{ row.weight }}
</template>
</VxeColumn>
<VxeColumn align="center" title="体积(m^3)" min-width="80">
<template #default="{ row }">
{{ row.volume }}
</template>
</VxeColumn>
<template v-if="formData?.subCommissionType">
<VxeColumn align="center" title="一级返佣(元)" min-width="80">
<template #default="{ row }">
{{ row.firstBrokeragePrice }}
</template>
</VxeColumn>
<VxeColumn align="center" title="二级返佣(元)" min-width="80">
<template #default="{ row }">
{{ row.secondBrokeragePrice }}
</template>
</VxeColumn>
</template>
</VxeTable>
<!-- 情况三作为活动组件 -->
<VxeTable
v-if="isActivityComponent"
:data="formData?.skus || []"
border
max-height="500"
size="small"
class="w-full"
>
<VxeColumn v-if="isComponent" type="checkbox" width="45" />
<VxeColumn align="center" title="图片" min-width="80">
<template #default="{ row }">
<Image
:src="row.picUrl"
class="h-[60px] w-[60px] cursor-pointer"
:preview="true"
/>
</template>
</VxeColumn>
<template v-if="formData?.specType">
<!-- 根据商品属性动态添加 -->
<VxeColumn
v-for="(item, index) in tableHeaders"
:key="index"
:title="item.label"
align="center"
min-width="80"
>
<template #default="{ row }">
<span class="font-bold text-[#40aaff]">
{{ row.properties?.[index]?.valueName }}
</span>
</template>
</VxeColumn>
</template>
<VxeColumn align="center" title="商品条码" min-width="100">
<template #default="{ row }">
{{ row.barCode }}
</template>
</VxeColumn>
<VxeColumn align="center" title="销售价(元)" min-width="80">
<template #default="{ row }">
{{ formatToFraction(row.price) }}
</template>
</VxeColumn>
<VxeColumn align="center" title="市场价(元)" min-width="80">
<template #default="{ row }">
{{ formatToFraction(row.marketPrice) }}
</template>
</VxeColumn>
<VxeColumn align="center" title="成本价(元)" min-width="80">
<template #default="{ row }">
{{ formatToFraction(row.costPrice) }}
</template>
</VxeColumn>
<VxeColumn align="center" title="库存" min-width="80">
<template #default="{ row }">
{{ row.stock }}
</template>
</VxeColumn>
<!-- 方便扩展每个活动配置的属性不一样 -->
<slot name="extension"></slot>
</VxeTable>
</template>

View File

@@ -0,0 +1,146 @@
<!-- SKU 选择弹窗组件 -->
<script lang="ts" setup>
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSpu } from '#/api/mall/product/spu';
interface SpuData {
spuId: number;
}
const emit = defineEmits<{
change: [sku: MallSpuApi.Sku];
}>();
const selectedSkuId = ref<number>();
const spuId = ref<number>();
// 价格格式化:分转元
const fenToYuan = (price?: number | string) => {
const numPrice =
typeof price === 'string' ? Number.parseFloat(price) : price || 0;
return (numPrice / 100).toFixed(2);
};
// 配置列
const gridColumns = computed<VxeGridProps['columns']>(() => [
{
field: 'id',
title: '#',
width: 60,
align: 'center',
slots: { default: 'radio-column' },
},
{
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);
},
},
]);
// 初始化表格
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: gridColumns.value,
height: 400,
border: true,
showOverflow: true,
proxyConfig: {
ajax: {
query: async () => {
if (!spuId.value) {
return { items: [], total: 0 };
}
try {
const spu = await getSpu(spuId.value);
return {
items: spu.skus || [],
total: spu.skus?.length || 0,
};
} catch (error) {
message.error('加载 SKU 数据失败');
console.error(error);
return { items: [], total: 0 };
}
},
},
},
},
});
// 处理选中
const handleSelected = (row: MallSpuApi.Sku) => {
emit('change', row);
modalApi.close();
selectedSkuId.value = undefined;
};
// 初始化弹窗
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
onOpenChange: async (isOpen: boolean) => {
if (!isOpen) {
selectedSkuId.value = undefined;
spuId.value = undefined;
return;
}
const data = modalApi.getData<SpuData>();
if (data?.spuId) {
spuId.value = data.spuId;
// 触发数据查询
await gridApi.query();
}
},
});
</script>
<template>
<Modal class="w-[700px]" title="选择规格">
<Grid>
<!-- 单选列 -->
<template #radio-column="{ row }">
<input
v-model="selectedSkuId"
:value="row.id"
class="cursor-pointer"
type="radio"
@change="handleSelected(row)"
/>
</template>
</Grid>
</Modal>
</template>

View File

@@ -67,4 +67,23 @@ export function getUrlValue(
if (!urlStr || !key) return '';
const url = new URL(decodeURIComponent(urlStr));
return url.searchParams.get(key) ?? '';
};
/**
* 将值复制到目标对象且以目标对象属性为准target: {a:1} source:{a:2,b:3} 结果为:{a:2}
* @param target 目标对象
* @param source 源对象
*/
export function copyValueToTarget(target: any, source: any) {
const newObj = Object.assign({}, target, source);
// 删除多余属性
Object.keys(newObj).forEach((key) => {
// 如果不是target中的属性则删除
if (!Object.keys(target).includes(key)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete newObj[key];
}
});
// 更新目标对象值
Object.assign(target, newObj);
}

1739
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff