!277 feat:【antd】【mall】spu 优化

Merge pull request !277 from puhui999/dev-mall
This commit is contained in:
芋道源码
2025-11-26 01:58:40 +00:00
committed by Gitee
22 changed files with 1038 additions and 312 deletions

View File

@@ -62,13 +62,6 @@ export namespace MallSpuApi {
valueName?: string; // 属性值名称
}
// TODO @puhui999这个还要么
/** 优惠券模板 */
export interface GiveCouponTemplate {
id?: number; // 优惠券编号
name?: string; // 优惠券名称
}
/** 商品状态更新请求 */
export interface SpuStatusUpdateReqVO {
id: number; // 商品编号

View File

@@ -1,7 +1,5 @@
import type { PageParam, PageResult } from '@vben/request';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { requestClient } from '#/api/request';
export namespace MallBargainActivityApi {
@@ -32,17 +30,6 @@ export namespace MallBargainActivityApi {
bargainMinPrice: number; // 砍价底价
stock: number; // 活动库存
}
// TODO @puhui999要不要删除
/** 扩展 SKU 配置 */
export type SkuExtension = {
productConfig: BargainProduct; // 砍价活动配置
} & MallSpuApi.Sku;
/** 扩展 SPU 配置 */
export interface SpuExtension extends MallSpuApi.Spu {
skus: SkuExtension[]; // SKU 列表
}
}
/** 查询砍价活动列表 */

View File

@@ -1,7 +1,5 @@
import type { PageParam, PageResult } from '@vben/request';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { requestClient } from '#/api/request';
export namespace MallCombinationActivityApi {
@@ -25,23 +23,12 @@ export namespace MallCombinationActivityApi {
products: CombinationProduct[]; // 商品列表
}
// TODO @puhui999要不要删除
/** 拼团活动所需属性 */
export interface CombinationProduct {
spuId: number; // 商品 SPU 编号
skuId: number; // 商品 SKU 编号
combinationPrice: number; // 拼团价格
}
/** 扩展 SKU 配置 */
export type SkuExtension = {
productConfig: CombinationProduct; // 拼团活动配置
} & MallSpuApi.Sku;
/** 扩展 SPU 配置 */
export interface SpuExtension extends MallSpuApi.Spu {
skus: SkuExtension[]; // SKU 列表
}
}
/** 查询拼团活动列表 */

View File

@@ -1,7 +1,5 @@
import type { PageParam, PageResult } from '@vben/request';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { requestClient } from '#/api/request';
export namespace MallDiscountActivityApi {
@@ -25,17 +23,6 @@ export namespace MallDiscountActivityApi {
endTime?: Date; // 结束时间
products?: DiscountProduct[]; // 商品列表
}
// TODO @puhui999要不要删除
/** 扩展 SKU 配置 */
export type SkuExtension = {
productConfig: DiscountProduct; // 限时折扣配置
} & MallSpuApi.Sku;
/** 扩展 SPU 配置 */
export interface SpuExtension extends MallSpuApi.Spu {
skus: SkuExtension[]; // SKU 列表
}
}
/** 查询限时折扣活动列表 */

View File

@@ -36,17 +36,6 @@ export namespace MallPointActivityApi {
price: number; // 兑换金额,单位:分
}
// TODO @puhui999这些还需要么
/** 扩展 SKU 配置 */
export type SkuExtension = {
productConfig: PointProduct; // 积分商城商品配置
} & MallSpuApi.Sku;
/** 扩展 SPU 配置 */
export interface SpuExtension extends MallSpuApi.Spu {
skus: SkuExtension[]; // SKU 列表
}
/** 扩展 SPU 配置(带积分信息) */
export interface SpuExtensionWithPoint extends MallSpuApi.Spu {
pointStock: number; // 积分商城活动库存

View File

@@ -1,7 +1,5 @@
import type { PageParam, PageResult } from '@vben/request';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { requestClient } from '#/api/request';
export namespace MallSeckillActivityApi {
@@ -34,17 +32,6 @@ export namespace MallSeckillActivityApi {
seckillPrice?: number; // 秒杀价格
products?: SeckillProduct[]; // 秒杀商品列表
}
// TODO @puhui999这些还需要么
/** 扩展 SKU 配置 */
export type SkuExtension = {
productConfig: SeckillProduct; // 秒杀商品配置
} & MallSpuApi.Sku;
/** 扩展 SPU 配置 */
export interface SpuExtension extends MallSpuApi.Spu {
skus: SkuExtension[]; // SKU 列表
}
}
/** 查询秒杀活动列表 */

View File

@@ -3,7 +3,6 @@ import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallCommentApi } from '#/api/mall/product/comment';
import { z } from '#/adapter/form';
import { getSpuSimpleList } from '#/api/mall/product/spu';
import { getRangePickerDefaultProps } from '#/utils';
/** 新增/修改的表单 */
@@ -21,13 +20,13 @@ export function useFormSchema(): VbenFormSchema[] {
{
fieldName: 'spuId',
label: '商品',
component: 'ApiSelect',
component: 'Input',
componentProps: {
api: getSpuSimpleList,
labelField: 'name',
valueField: 'id',
placeholder: '请选择商品',
},
renderComponentContent: () => ({
default: () => null,
}),
rules: 'required',
},
{
@@ -41,6 +40,9 @@ export function useFormSchema(): VbenFormSchema[] {
triggerFields: ['spuId'],
show: (values) => !!values.spuId,
},
renderComponentContent: () => ({
default: () => null,
}),
rules: 'required',
},
{

View File

@@ -1,21 +1,31 @@
<script lang="ts" setup>
import type { MallCommentApi } from '#/api/mall/product/comment';
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 { Button, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createComment, getComment } from '#/api/mall/product/comment';
import { getSpu } from '#/api/mall/product/spu';
import { $t } from '#/locales';
import {
SkuTableSelect,
SpuShowcase,
} from '#/views/mall/product/spu/components';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formData = ref<MallCommentApi.Comment>();
// 初始化 formData确保始终有值
const formData = ref<Partial<MallCommentApi.Comment>>({
descriptionScores: 5,
benefitScores: 5,
});
const getTitle = computed(() => {
return formData.value?.id
? $t('ui.actionTitle.edit', ['虚拟评论'])
@@ -35,6 +45,37 @@ const [Form, formApi] = useVbenForm({
showDefaultActions: false,
});
const skuTableSelectRef = ref<InstanceType<typeof SkuTableSelect>>();
const selectedSku = ref<MallSpuApi.Sku>();
async function handleSpuChange(spu?: MallSpuApi.Spu | null) {
// 处理商品选择:如果 spu 为 null 或 id 为 0表示清空选择
const spuId = spu?.id && spu.id ? spu.id : undefined;
formData.value.spuId = spuId;
await formApi.setFieldValue('spuId', spuId);
// 清空已选规格
selectedSku.value = undefined;
formData.value.skuId = undefined;
await formApi.setFieldValue('skuId', undefined);
}
async function openSkuSelect() {
const currentValues =
(await formApi.getValues()) as Partial<MallCommentApi.Comment>;
const currentSpuId = currentValues.spuId ?? formData.value?.spuId;
if (!currentSpuId) {
message.warning('请先选择商品');
return;
}
skuTableSelectRef.value?.open({ spuId: currentSpuId });
}
async function handleSkuSelected(sku: MallSpuApi.Sku) {
selectedSku.value = sku;
formData.value.skuId = sku.id;
await formApi.setFieldValue('skuId', sku.id);
}
const [Modal, modalApi] = useVbenModal({
async onConfirm() {
const { valid } = await formApi.validate();
@@ -56,19 +97,42 @@ const [Modal, modalApi] = useVbenModal({
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
// 重置表单数据
formData.value = {
descriptionScores: 5,
benefitScores: 5,
} as Partial<MallCommentApi.Comment>;
selectedSku.value = undefined;
return;
}
// 加载数据
const data = modalApi.getData<MallCommentApi.Comment>();
if (!data || !data.id) {
// 新建模式:重置表单
formData.value = {
descriptionScores: 5,
benefitScores: 5,
} as Partial<MallCommentApi.Comment>;
selectedSku.value = undefined;
await formApi.setValues({ spuId: undefined, skuId: undefined });
return;
}
// 编辑模式:加载数据
modalApi.lock();
try {
formData.value = await getComment(data.id);
// 设置到 values
await formApi.setValues(formData.value);
// 回显已选规格
if (formData.value?.spuId && formData.value?.skuId) {
const spu = await getSpu(formData.value.spuId);
const sku = spu.skus?.find((item) => item.id === formData.value!.skuId);
if (sku) {
selectedSku.value = sku;
}
} else {
selectedSku.value = undefined;
}
} finally {
modalApi.unlock();
}
@@ -78,6 +142,38 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal class="w-2/5" :title="getTitle">
<Form class="mx-4" />
<Form class="mx-4">
<template #spuId>
<SpuShowcase
v-model="(formData as any).spuId"
:limit="1"
@change="handleSpuChange"
/>
</template>
<template #skuId>
<div class="flex items-center gap-2">
<Button
type="primary"
:disabled="!formData?.spuId"
@click="openSkuSelect"
>
选择规格
</Button>
<span
v-if="
selectedSku &&
selectedSku.properties &&
selectedSku.properties.length > 0
"
>
已选:{{
selectedSku.properties.map((p: any) => p.valueName).join('/')
}}
</span>
<span v-else-if="selectedSku">已选:{{ selectedSku.id }}</span>
</div>
</template>
</Form>
<SkuTableSelect ref="skuTableSelectRef" @change="handleSkuSelected" />
</Modal>
</template>

View File

@@ -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 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 SpuTableSelect } from './spu-table-select.vue';
export * from './type';

View File

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

View File

@@ -1,9 +1,11 @@
<script lang="ts" setup>
import type { Ref } from 'vue';
import type { PropertyAndValues, RuleConfig } from '../index';
import type { MallSpuApi } from '#/api/mall/product/spu';
import type {
PropertyAndValues,
RuleConfig,
} from '#/views/mall/product/spu/components';
import { ref, watch } from 'vue';
@@ -463,7 +465,7 @@ defineExpose({
@checkbox-change="handleSelectionChange"
@checkbox-all="handleSelectionChange"
>
<VxeColumn v-if="isComponent" type="checkbox" width="45" />
<VxeColumn v-if="isComponent" type="checkbox" width="45" fixed="left" />
<VxeColumn align="center" title="图片" max-width="140" fixed="left">
<template #default="{ row }">
<Image

View File

@@ -3,11 +3,12 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { computed, ref } from 'vue';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { fenToYuan } from '@vben/utils';
import { Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getSpu } from '#/api/mall/product/spu';
@@ -19,10 +20,11 @@ const emit = defineEmits<{
change: [sku: MallSpuApi.Sku];
}>();
const visible = ref(false);
const spuId = ref<number>();
/** 表格列配置 */
const gridColumns = computed<VxeGridProps['columns']>(() => [
const gridColumns: VxeGridProps['columns'] = [
{
type: 'radio',
width: 55,
@@ -57,27 +59,34 @@ const gridColumns = computed<VxeGridProps['columns']>(() => [
return fenToYuan(cellValue);
},
},
]);
];
// TODO @芋艿:要不要直接非 pager
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: gridColumns.value,
columns: gridColumns,
height: 400,
border: true,
showOverflow: true,
radioConfig: {
reserve: true,
},
rowConfig: {
keyField: 'id',
isHover: true,
},
pagerConfig: {
enabled: false,
},
proxyConfig: {
// autoLoad: false, // 禁用自动加载,手动触发查询
ajax: {
query: async () => {
if (!spuId.value) {
return { items: [], total: 0 };
return { list: [], total: 0 };
}
const spu = await getSpu(spuId.value);
return {
items: spu.skus || [],
list: spu.skus || [],
total: spu.skus?.length || 0,
};
},
@@ -85,39 +94,55 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
},
gridEvents: {
radioChange: handleRadioChange,
radioChange: () => {
const selectedRow = gridApi.grid.getRadioRecord() as MallSpuApi.Sku;
if (selectedRow) {
emit('change', selectedRow);
// 关闭弹窗
visible.value = false;
gridApi.grid.clearRadioRow();
spuId.value = undefined;
}
},
},
});
/** 处理选中 */
function handleRadioChange() {
const selectedRow = gridApi.grid.getRadioRecord() as MallSpuApi.Sku;
if (selectedRow) {
emit('change', selectedRow);
modalApi.close();
}
/** 关闭弹窗 */
function closeModal() {
visible.value = false;
gridApi.grid.clearRadioRow();
spuId.value = undefined;
}
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
onOpenChange: async (isOpen: boolean) => {
if (!isOpen) {
gridApi.grid.clearRadioRow();
spuId.value = undefined;
return;
}
const data = modalApi.getData<SpuData>();
if (!data?.spuId) {
return;
}
spuId.value = data.spuId;
await gridApi.query();
},
/** 打开弹窗 */
async function openModal(data?: SpuData) {
if (!data?.spuId) {
return;
}
spuId.value = data.spuId;
visible.value = true;
// // 等待弹窗和 Grid 组件完全渲染后再查询数据
// await nextTick();
// if (gridApi.grid) {
// await gridApi.query();
// }
}
/** 对外暴露的方法 */
defineExpose({
open: openModal,
});
</script>
<template>
<Modal class="w-[700px]" title="选择规格">
<Modal
v-model:open="visible"
title="选择规格"
width="700px"
:destroy-on-close="true"
:footer="null"
@cancel="closeModal"
>
<Grid />
</Modal>
</template>

View File

@@ -0,0 +1,177 @@
<script generic="T extends MallSpuApi.Spu" lang="ts" setup>
import type { RuleConfig, SpuProperty } from './type';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { ref, watch } from 'vue';
import { confirm } from '@vben/common-ui';
import { formatToFraction } from '@vben/utils';
import { Button, Image } from 'ant-design-vue';
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) {
try {
skuListRef.value.validateSku();
} catch (error) {
// 验证失败时抛出错误
throw error;
}
}
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 }">
<Image
v-if="row.picUrl"
:src="row.picUrl"
class="h-[30px] w-[30px] cursor-pointer"
:preview="true"
/>
</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 }">
<Button type="link" danger @click="deleteSpu(row.id)"> 删除</Button>
</template>
</VxeColumn>
</VxeTable>
</template>

View File

@@ -0,0 +1,129 @@
import type { Ref } from 'vue';
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { MallCategoryApi } from '#/api/mall/product/category';
import { computed } from 'vue';
import { formatToFraction } from '@vben/utils';
import { getRangePickerDefaultProps } from '#/utils';
/**
* @description: 列表的搜索表单
*/
export function useGridFormSchema(
categoryTreeList: Ref<MallCategoryApi.Category[] | unknown[]>,
): VbenFormSchema[] {
return [
{
fieldName: 'name',
label: '商品名称',
component: 'Input',
componentProps: {
placeholder: '请输入商品名称',
allowClear: true,
},
},
{
fieldName: 'categoryId',
label: '商品分类',
component: 'TreeSelect',
componentProps: {
treeData: computed(() => categoryTreeList.value),
fieldNames: {
label: 'name',
value: 'id',
},
treeCheckStrictly: true,
placeholder: '请选择商品分类',
allowClear: true,
showSearch: true,
treeNodeFilterProp: 'name',
},
},
{
fieldName: 'createTime',
label: '创建时间',
component: 'RangePicker',
componentProps: {
...getRangePickerDefaultProps(),
allowClear: true,
},
},
];
}
/**
* @description: 列表的字段
*/
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: ({ cellValue }) => {
// 格式化价格显示(价格以分为单位存储)
return formatToFraction(cellValue);
},
},
{
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'];
}

View File

@@ -0,0 +1,325 @@
<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 { message, Modal } from 'ant-design-vue';
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) {
message.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) {
message.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;
// 注意API 返回的价格应该已经是分为单位,无需转换
// 如果 API 返回的是元,则需要转换为分:
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;
}
/** 搜索表单 Schema */
const formSchema = computed(() => useGridFormSchema(categoryTreeList));
/** 表格列配置 */
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: props.isSelectSku
? {
trigger: 'row',
reserve: true,
}
: undefined,
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) {
message.warning('没有选择任何商品');
return;
}
if (props.isSelectSku && selectedSkuIds.value.length === 0) {
message.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>
<Modal
v-model:open="visible"
title="商品选择"
width="70%"
:destroy-on-close="true"
@ok="handleConfirm"
@cancel="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>
</Modal>
</template>

View File

@@ -5,11 +5,12 @@ import type { VxeGridProps } from '#/adapter/vxe-table';
import type { MallCategoryApi } from '#/api/mall/product/category';
import type { MallSpuApi } from '#/api/mall/product/spu';
import { computed, onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { handleTree } from '@vben/utils';
import { Modal } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { getCategoryList } from '#/api/mall/product/category';
import { getSpuPage } from '#/api/mall/product/spu';
@@ -30,12 +31,16 @@ const emit = defineEmits<{
const categoryList = ref<MallCategoryApi.Category[]>([]); // 分类列表
const categoryTreeList = ref<any[]>([]); // 分类树
/** 弹窗显示状态 */
const visible = ref(false);
const initData = ref<MallSpuApi.Spu | MallSpuApi.Spu[]>();
/** 单选:处理选中变化 */
function handleRadioChange() {
const selectedRow = gridApi.grid.getRadioRecord() as MallSpuApi.Spu;
if (selectedRow) {
emit('change', selectedRow);
modalApi.close();
closeModal();
}
}
@@ -159,25 +164,16 @@ const [Grid, gridApi] = useVbenVxeGrid({
},
});
const [Modal, modalApi] = useVbenModal({
destroyOnClose: true,
showConfirmButton: props.multiple, // 特殊radio 单选情况下,走 handleRadioChange 处理。
onConfirm: () => {
const selectedRows = gridApi.grid.getCheckboxRecords() as MallSpuApi.Spu[];
emit('change', selectedRows);
modalApi.close();
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
await gridApi.grid.clearCheckboxRow();
await gridApi.grid.clearRadioRow();
return;
}
/** 打开弹窗 */
async function openModal(data?: MallSpuApi.Spu | MallSpuApi.Spu[]) {
initData.value = data;
visible.value = true;
// 等待 Grid 组件完全初始化后再查询数据
await nextTick();
if (gridApi.grid) {
// 1. 先查询数据
await gridApi.query();
// 2. 设置已选中行
const data = modalApi.getData<MallSpuApi.Spu | MallSpuApi.Spu[]>();
if (props.multiple && Array.isArray(data) && data.length > 0) {
setTimeout(() => {
const tableData = gridApi.grid.getTableData().fullData;
@@ -201,14 +197,27 @@ const [Modal, modalApi] = useVbenModal({
}
}, 300);
}
},
});
}
}
/** 关闭弹窗 */
async function closeModal() {
visible.value = false;
await gridApi.grid.clearCheckboxRow();
await gridApi.grid.clearRadioRow();
initData.value = undefined;
}
/** 确认选择(多选模式) */
function handleConfirm() {
const selectedRows = gridApi.grid.getCheckboxRecords() as MallSpuApi.Spu[];
emit('change', selectedRows);
closeModal();
}
/** 对外暴露的方法 */
defineExpose({
open: (data?: MallSpuApi.Spu | MallSpuApi.Spu[]) => {
modalApi.setData(data).open();
},
open: openModal,
});
/** 初始化分类数据 */
@@ -219,7 +228,15 @@ onMounted(async () => {
</script>
<template>
<Modal title="选择商品" class="w-[950px]">
<Modal
v-model:open="visible"
title="选择商品"
width="950px"
:destroy-on-close="true"
:footer="props.multiple ? undefined : null"
@ok="handleConfirm"
@cancel="closeModal"
>
<Grid />
</Modal>
</template>

View File

@@ -0,0 +1,28 @@
/** 商品属性及其值的树形结构(用于前端展示和操作) */
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;
}

View File

@@ -1,63 +0,0 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { MallSpuApi } from '#/api/mall/product/spu';
// TODO @puhui999这个是不是 api 后端有定义类似的?如果是,是不是放到 api 哈?
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;
}
// TODO @puhui999这个是只有 index.ts 在用么?还是别的模块也会用
/** 获得商品的规格列表 - 商品相关的公共函数 */
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 };
// 导出组件
// TODO @puhui999如果 sku-list.vue 要对外,可以考虑在 spu 下面,搞个 components 模块目前看别的模块应该会用到哈。modules 是当前模块用到的components 是跨模块要用到的。
export { default as SkuList } from './modules/sku-list.vue';

View File

@@ -1,7 +1,9 @@
<script lang="ts" setup>
import type { PropertyAndValues, RuleConfig } from './index';
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';
@@ -14,6 +16,7 @@ import { Button, Card, message } from 'ant-design-vue';
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,
@@ -22,10 +25,8 @@ import {
useOtherFormSchema,
useSkuFormSchema,
} from './data';
import { getPropertyList } from './index';
import ProductAttributes from './modules/product-attributes.vue';
import ProductPropertyAddForm from './modules/product-property-add-form.vue';
import SkuList from './modules/sku-list.vue';
const spuId = ref<number>();
const { params, name } = useRoute();

View File

@@ -1,8 +1,7 @@
<!-- 商品发布 - 库存价格 - 属性列表 -->
<script lang="ts" setup>
import type { PropertyAndValues } from '../index';
import type { MallPropertyApi } from '#/api/mall/product/property';
import type { PropertyAndValues } from '#/views/mall/product/spu/components';
import { computed, ref, watch } from 'vue';
@@ -83,10 +82,10 @@ watch(
/** 删除属性值 */
function handleCloseValue(index: number, value: PropertyAndValues) {
if (attributeList.value[index]) {
attributeList.value[index].values = attributeList.value?.[
if (attributeList.value[index]?.values) {
attributeList.value[index].values = attributeList.value[
index
]?.values?.filter((item) => item.id !== value.id);
].values?.filter((item) => item.id !== value.id);
}
}
@@ -167,9 +166,8 @@ async function getAttributeOptions(propertyId: number) {
<template>
<Col v-for="(attribute, index) in attributeList" :key="index">
<Divider class="my-4" />
<!-- TODO @puhui9991间隙可以看看2)vue3 + element-plus 添加属性这个按钮是和属性名在一排感觉更好看点 -->
<div class="mt-1">
<Divider class="my-3" />
<div class="mt-2 flex flex-wrap items-center gap-2">
<span class="mx-1">属性名</span>
<Tag
:closable="!isDetail"
@@ -180,7 +178,7 @@ async function getAttributeOptions(propertyId: number) {
{{ attribute.name }}
</Tag>
</div>
<div class="mt-2">
<div class="mt-2 flex flex-wrap items-center gap-2">
<span class="mx-1">属性值</span>
<Tag
v-for="(value, valueIndex) in attribute.values"
@@ -189,8 +187,7 @@ async function getAttributeOptions(propertyId: number) {
class="mx-1"
@close="handleCloseValue(index, value)"
>
<!-- TODO @puhui999这里貌似爆红idea -->
{{ value.name }}
{{ value?.name }}
</Tag>
<Select
v-show="inputVisible(index)"

View File

@@ -113,6 +113,7 @@ export function useFormSchema(): VbenFormSchema[] {
placeholder: '请输入排序',
class: '!w-full',
},
defaultValue: 0,
rules: 'required',
},
{

View File

@@ -1,13 +1,15 @@
<script lang="ts" setup>
import type { MallSpuApi } from '#/api/mall/product/spu';
import type { MallPointActivityApi } from '#/api/mall/promotion/point';
import type { RuleConfig } from '#/views/mall/product/spu/form';
// TODO @puhui999有问题
// import type { SpuProperty } from '#/views/mall/promotion/components/types';
import type {
RuleConfig,
SpuProperty,
} from '#/views/mall/product/spu/components';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { convertToInteger, formatToFraction } from '@vben/utils';
import { cloneDeep, convertToInteger, formatToFraction } from '@vben/utils';
import { Button, InputNumber, message } from 'ant-design-vue';
@@ -20,10 +22,12 @@ import {
updatePointActivity,
} from '#/api/mall/promotion/point';
import { $t } from '#/locales';
import { getPropertyList } from '#/views/mall/product/spu/form';
import {
getPropertyList,
SpuAndSkuList,
SpuSkuSelect,
} from '#/views/mall/product/spu/components';
// TODO @puhui999有问题
// import { SpuAndSkuList, SpuSkuSelect } from '../../../components';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
@@ -70,15 +74,12 @@ const ruleConfig: RuleConfig[] = [
},
]; // SKU 规则配置
const spuList = ref<any[]>([]); // 选择的 SPU 列表
// TODO @puhui999有问题
// const spuPropertyList = ref<SpuProperty<any>[]>([]); // SPU 属性列表
const spuPropertyList = ref<any[]>([]); // SPU 属性列表
const spuList = ref<MallSpuApi.Spu[]>([]); // 选择的 SPU 列表
const spuPropertyList = ref<SpuProperty<MallSpuApi.Spu>[]>([]); // SPU 属性列表
/** 打开商品选择器 */
// TODO @puhui999spuSkuSelectRef.value.open is not a function
function openSpuSelect() {
spuSkuSelectRef.value.open();
spuSkuSelectRef.value?.open();
}
/** 选择商品后的回调 */
@@ -106,7 +107,7 @@ async function getSpuDetails(
? res.skus
: res.skus?.filter((sku) => skuIds.includes(sku.id!));
// 为每个 SKU 配置积分商城相关的配置
selectSkus?.forEach((sku: any) => {
selectSkus?.forEach((sku) => {
let config: MallPointActivityApi.PointProduct = {
skuId: sku.id!,
stock: 0,
@@ -122,22 +123,26 @@ async function getSpuDetails(
}
config = product || config;
}
sku.productConfig = config;
// 动态添加 productConfig 属性到 SKU
(
sku as MallSpuApi.Sku & {
productConfig: MallPointActivityApi.PointProduct;
}
).productConfig = config;
});
res.skus = selectSkus;
// TODO @puhui999有问题
// const spuProperties: SpuProperty[] = [];
const spuProperties: any[] = [];
spuProperties.push({
spuId: res.id!,
spuDetail: res,
propertyList: getPropertyList(res),
});
// 构建 SPU 属性列表
const spuProperties: SpuProperty<MallSpuApi.Spu>[] = [
{
spuId: res.id!,
spuDetail: res,
propertyList: getPropertyList(res),
},
];
// TODO @puhui999貌似直接 = 下面的,不用 push
spuList.value.push(res);
// TODO @puhui999貌似直接 = 下面的,不用 push
// 直接赋值,因为每次只选择一个 SPU
spuList.value = [res];
spuPropertyList.value = spuProperties;
}
@@ -151,9 +156,10 @@ const [Modal, modalApi] = useVbenModal({
}
modalApi.lock();
try {
// 获取积分商城商品配置
const products: MallPointActivityApi.PointProduct[] =
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [];
// 获取积分商城商品配置(深拷贝避免直接修改原对象)
const products: MallPointActivityApi.PointProduct[] = cloneDeep(
spuAndSkuListRef.value?.getSkuConfigs('productConfig') || [],
);
// 价格需要转为分
products.forEach((item) => {
item.price = convertToInteger(item.price);
@@ -180,11 +186,22 @@ const [Modal, modalApi] = useVbenModal({
spuPropertyList.value = [];
return;
}
// 加载数据
// 重置表单数据(新增和编辑模式都需要)
formData.value = undefined;
spuList.value = [];
spuPropertyList.value = [];
// 加载数据(仅编辑模式)
const data = modalApi.getData<MallPointActivityApi.PointActivity>();
if (!data || !data.id) {
// 新增模式:重置表单字段
await formApi.setValues({
sort: 0,
remark: '',
spuId: undefined,
});
return;
}
// 编辑模式:加载数据
modalApi.lock();
try {
formData.value = await getPointActivity(data.id);
@@ -203,76 +220,77 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal :title="getTitle" class="w-[70%]">
<Form class="mx-4">
<!-- 商品选择 -->
<template #spuId>
<div class="w-full">
<Button v-if="!formData?.id" type="primary" @click="openSpuSelect">
选择商品
</Button>
<div>
<Modal :title="getTitle" class="w-[70%]">
<Form class="mx-4">
<!-- 商品选择 -->
<template #spuId>
<div class="w-full">
<Button v-if="!formData?.id" type="primary" @click="openSpuSelect">
选择商品
</Button>
<!-- SPU SKU 列表展示 -->
<SpuAndSkuList
v-if="spuList.length > 0"
ref="spuAndSkuListRef"
:rule-config="ruleConfig"
:spu-list="spuList"
:spu-property-list="spuPropertyList"
class="mt-4"
>
<!-- 扩展列积分商城特有配置 -->
<template #default>
<VxeColumn align="center" min-width="168" title="可兑换库存">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.stock"
:max="sku.stock"
:min="0"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" min-width="168" title="可兑换次数">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.count"
:min="0"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" min-width="168" title="所需积分">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.point"
:min="0"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" min-width="168" title="所需金额(元)">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.price"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
</template>
</SpuAndSkuList>
</div>
</template>
</Form>
<!-- SPU SKU 列表展示 -->
<SpuAndSkuList
ref="spuAndSkuListRef"
:rule-config="ruleConfig"
:spu-list="spuList"
:spu-property-list-p="spuPropertyList"
class="mt-4"
>
<!-- 扩展列积分商城特有配置 -->
<template #default>
<VxeColumn align="center" min-width="168" title="可兑换库存">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.stock"
:max="sku.stock"
:min="0"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" min-width="168" title="可兑换次数">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.count"
:min="0"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" min-width="168" title="所需积分">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.point"
:min="0"
class="w-full"
/>
</template>
</VxeColumn>
<VxeColumn align="center" min-width="168" title="所需金额(元)">
<template #default="{ row: sku }">
<InputNumber
v-model:value="sku.productConfig.price"
:min="0"
:precision="2"
:step="0.1"
class="w-full"
/>
</template>
</VxeColumn>
</template>
</SpuAndSkuList>
</div>
</template>
</Form>
</Modal>
<!-- 商品选择器弹窗 -->
<SpuSkuSelect
ref="spuSkuSelectRef"
:is-select-sku="true"
@confirm="handleSpuSelected"
@select="handleSpuSelected"
/>
</Modal>
</div>
</template>