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

Merge pull request !277 from puhui999/dev-mall
pull/278/MERGE
芋道源码 2025-11-26 01:58:40 +00:00 committed by Gitee
commit 58e8a71936
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
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>