diff --git a/src/api/mall/product/spu.ts b/src/api/mall/product/spu.ts index d7ccfc5c..0ea324b8 100644 --- a/src/api/mall/product/spu.ts +++ b/src/api/mall/product/spu.ts @@ -86,6 +86,11 @@ export const getSpu = (id: number) => { return request.get({ url: `/product/spu/get-detail?id=${id}` }) } +// 获得商品 Spu 详情列表 +export const getSpuDetailList = (ids: number[]) => { + return request.get({ url: `/product/spu/list?spuIds=${ids}` }) +} + // 删除商品 Spu export const deleteSpu = (id: number) => { return request.delete({ url: `/product/spu/delete?id=${id}` }) diff --git a/src/api/mall/promotion/combination/combinationactivity.ts b/src/api/mall/promotion/combination/combinationactivity.ts new file mode 100644 index 00000000..1e211c86 --- /dev/null +++ b/src/api/mall/promotion/combination/combinationactivity.ts @@ -0,0 +1,61 @@ +import request from '@/config/axios' +import { Sku, Spu } from '@/api/mall/product/spu' + +export interface CombinationActivityVO { + id?: number + name?: string + spuId?: number + totalLimitCount?: number + singleLimitCount?: number + startTime?: Date + endTime?: Date + userSize?: number + totalNum?: number + successNum?: number + orderUserCount?: number + virtualGroup?: number + status?: number + limitDuration?: number + products: CombinationProductVO[] +} + +// 拼团活动所需属性 +export interface CombinationProductVO { + spuId: number + skuId: number + activePrice: number // 拼团价格 +} + +// 扩展 Sku 配置 +export type SkuExtension = Sku & { + productConfig: CombinationProductVO +} + +export interface SpuExtension extends Spu { + skus: SkuExtension[] // 重写类型 +} + +// 查询拼团活动列表 +export const getCombinationActivityPage = async (params) => { + return await request.get({ url: '/promotion/combination-activity/page', params }) +} + +// 查询拼团活动详情 +export const getCombinationActivity = async (id: number) => { + return await request.get({ url: '/promotion/combination-activity/get?id=' + id }) +} + +// 新增拼团活动 +export const createCombinationActivity = async (data: CombinationActivityVO) => { + return await request.post({ url: '/promotion/combination-activity/create', data }) +} + +// 修改拼团活动 +export const updateCombinationActivity = async (data: CombinationActivityVO) => { + return await request.put({ url: '/promotion/combination-activity/update', data }) +} + +// 删除拼团活动 +export const deleteCombinationActivity = async (id: number) => { + return await request.delete({ url: '/promotion/combination-activity/delete?id=' + id }) +} diff --git a/src/api/mall/promotion/seckill/seckillActivity.ts b/src/api/mall/promotion/seckill/seckillActivity.ts index 0f1f08d1..42c1c31c 100644 --- a/src/api/mall/promotion/seckill/seckillActivity.ts +++ b/src/api/mall/promotion/seckill/seckillActivity.ts @@ -2,35 +2,34 @@ import request from '@/config/axios' import { Sku, Spu } from '@/api/mall/product/spu' export interface SeckillActivityVO { - id: number - spuIds: number[] - name: string - status: number - remark: string - startTime: Date - endTime: Date - sort: number - configIds: string - orderCount: number - userCount: number - totalPrice: number - totalLimitCount: number - singleLimitCount: number - stock: number - totalStock: number - products: SeckillProductVO[] + id?: number + spuId?: number + name?: string + status?: number + remark?: string + startTime?: Date + endTime?: Date + sort?: number + configIds?: string + orderCount?: number + userCount?: number + totalPrice?: number + totalLimitCount?: number + singleLimitCount?: number + stock?: number + totalStock?: number + products?: SeckillProductVO[] } // 秒杀活动所需属性 export interface SeckillProductVO { - spuId: number skuId: number seckillPrice: number stock: number } // 扩展 Sku 配置 -type SkuExtension = Sku & { +export type SkuExtension = Sku & { productConfig: SeckillProductVO } diff --git a/src/api/mall/promotion/seckill/seckillConfig.ts b/src/api/mall/promotion/seckill/seckillConfig.ts index 07b7d55c..eee82115 100644 --- a/src/api/mall/promotion/seckill/seckillConfig.ts +++ b/src/api/mall/promotion/seckill/seckillConfig.ts @@ -5,7 +5,7 @@ export interface SeckillConfigVO { name: string startTime: string endTime: string - picUrl: string + sliderPicUrls: string[] status: number } diff --git a/src/utils/formatTime.ts b/src/utils/formatTime.ts index c9d80618..5e5c854d 100644 --- a/src/utils/formatTime.ts +++ b/src/utils/formatTime.ts @@ -23,6 +23,13 @@ export function formatDate(date: Date, format?: string): string { return dayjs(date).format(format) } +/** + * 获取当前的日期+时间 + */ +export function getNowDateTime() { + return dayjs() +} + /** * 获取当前日期是第几周 * @param dateTime 当前传入的日期值 diff --git a/src/views/mall/product/spu/components/SkuList.vue b/src/views/mall/product/spu/components/SkuList.vue index e033b233..ed1a356d 100644 --- a/src/views/mall/product/spu/components/SkuList.vue +++ b/src/views/mall/product/spu/components/SkuList.vue @@ -2,7 +2,7 @@ <!-- 情况一:添加/修改 --> <el-table v-if="!isDetail && !isActivityComponent" - :data="isBatch ? skuList : formData!.skus" + :data="isBatch ? skuList : formData!.skus!" border class="tabNumWidth" max-height="500" @@ -113,7 +113,8 @@ <!-- 情况二:详情 --> <el-table v-if="isDetail" - :data="formData!.skus" + ref="activitySkuListRef" + :data="formData!.skus!" border max-height="500" size="small" @@ -194,7 +195,7 @@ <!-- 情况三:作为活动组件 --> <el-table v-if="isActivityComponent" - :data="formData!.skus" + :data="formData!.skus!" border max-height="500" size="small" @@ -259,7 +260,8 @@ import { UploadImg } from '@/components/UploadFile' import type { Property, Sku, Spu } from '@/api/mall/product/spu' import { createImageViewer } from '@/components/ImageViewer' import { RuleConfig } from '@/views/mall/product/spu/components/index' -import { Properties } from './index' +import { PropertyAndValues } from './index' +import { ElTable } from 'element-plus' defineOptions({ name: 'SkuList' }) const message = useMessage() // 消息弹窗 @@ -270,7 +272,7 @@ const props = defineProps({ default: () => {} }, propertyList: { - type: Array as PropType<Properties[]>, + type: Array as PropType<PropertyAndValues[]>, default: () => [] }, ruleConfig: { @@ -480,7 +482,7 @@ const build = (propertyValuesList: Property[][]) => { /** 监听属性列表,生成相关参数和表头 */ watch( () => props.propertyList, - (propertyList: Properties[]) => { + (propertyList: PropertyAndValues[]) => { // 如果不是多规格则结束 if (!formData.value!.specType) { return @@ -514,7 +516,6 @@ watch( // name加属性项index区分属性值 tableHeaders.value.push({ prop: `name${index}`, label: item.name }) }) - // 如果回显的 sku 属性和添加的属性一致则不处理 if (validateData(propertyList)) { return @@ -531,6 +532,10 @@ watch( immediate: true } ) +const activitySkuListRef = ref<InstanceType<typeof ElTable>>() +const clearSelection = () => { + activitySkuListRef.value.clearSelection() +} // 暴露出生成 sku 方法,给添加属性成功时调用 -defineExpose({ generateTableData, validateSku }) +defineExpose({ generateTableData, validateSku, clearSelection }) </script> diff --git a/src/views/mall/product/spu/components/index.ts b/src/views/mall/product/spu/components/index.ts index ca61ff6b..1160dbd1 100644 --- a/src/views/mall/product/spu/components/index.ts +++ b/src/views/mall/product/spu/components/index.ts @@ -7,11 +7,11 @@ import SkuList from './SkuList.vue' import { Spu } from '@/api/mall/product/spu' -// TODO @puhui999:Properties 改成 Property 更合适? -interface Properties { +// TODO @puhui999:Properties 改成 Property 更合适?Property 在 Spu 中已存在避免冲突 PropertyAndValues +interface PropertyAndValues { id: number name: string - values?: Properties[] + values?: PropertyAndValues[] } interface RuleConfig { @@ -23,7 +23,7 @@ interface RuleConfig { // 例:需要校验价格必须大于0.01 // { // name:'price', - // rule:(arg) => arg > 0.01 + // rule:(arg: number) => arg > 0.01 // } rule: (arg: any) => boolean // 校验不通过时的消息提示 @@ -34,11 +34,11 @@ interface RuleConfig { * 获得商品的规格列表 * * @param spu - * @return Property 规格列表 + * @return PropertyAndValues 规格列表 */ -const getPropertyList = (spu: Spu): Properties[] => { +const getPropertyList = (spu: Spu): PropertyAndValues[] => { // 直接拿返回的 skus 属性逆向生成出 propertyList - const properties: Properties[] = [] + const properties: PropertyAndValues[] = [] // 只有是多规格才处理 if (spu.specType) { spu.skus?.forEach((sku) => { @@ -66,6 +66,6 @@ export { ProductPropertyAddForm, SkuList, getPropertyList, - Properties, + PropertyAndValues, RuleConfig } diff --git a/src/views/mall/promotion/combination/activity/CombinationActivityForm.vue b/src/views/mall/promotion/combination/activity/CombinationActivityForm.vue new file mode 100644 index 00000000..36ede848 --- /dev/null +++ b/src/views/mall/promotion/combination/activity/CombinationActivityForm.vue @@ -0,0 +1,189 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%"> + <Form + ref="formRef" + v-loading="formLoading" + :is-col="true" + :rules="rules" + :schema="allSchemas.formSchema" + > + <template #spuId> + <el-button @click="spuSelectRef.open()">选择商品</el-button> + <SpuAndSkuList + ref="spuAndSkuListRef" + :rule-config="ruleConfig" + :spu-list="spuList" + :spu-property-list-p="spuPropertyList" + > + <el-table-column align="center" label="拼团价格(元)" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number + v-model="sku.productConfig.activePrice" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + /> + </template> + </el-table-column> + </SpuAndSkuList> + </template> + </Form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + <SpuSelect ref="spuSelectRef" :isSelectSku="true" @confirm="selectSpu" /> +</template> +<script lang="ts" setup> +import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationactivity' +import { CombinationProductVO } from '@/api/mall/promotion/combination/combinationactivity' +import { allSchemas, rules } from './combinationActivity.data' +import { SpuAndSkuList, SpuProperty, SpuSelect } from '@/views/mall/promotion/components' +import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { convertToInteger, formatToFraction } from '@/utils' + +defineOptions({ name: 'PromotionCombinationActivityForm' }) + +const { t } = useI18n() // 国际化 +const message = useMessage() // 消息弹窗 + +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 +const formType = ref('') // 表单的类型:create - 新增;update - 修改 +const formRef = ref() // 表单 Ref + +// ================= 商品选择相关 ================= + +const spuSelectRef = ref() // 商品和属性选择 Ref +const spuAndSkuListRef = ref() // sku 秒杀配置组件Ref +const spuList = ref<CombinationActivityApi.SpuExtension[]>([]) // 选择的 spu +const spuPropertyList = ref<SpuProperty<CombinationActivityApi.SpuExtension>[]>([]) +const ruleConfig: RuleConfig[] = [ + { + name: 'productConfig.activePrice', + rule: (arg) => arg > 0.01, + message: '商品拼团价格不能小于0.01 !!!' + } +] +const selectSpu = (spuId: number, skuIds: number[]) => { + formRef.value.setValues({ spuId }) + getSpuDetails(spuId, skuIds) +} +/** + * 获取 SPU 详情 + */ +const getSpuDetails = async ( + spuId: number, + skuIds: number[] | undefined, + products?: CombinationProductVO[] +) => { + const spuProperties: SpuProperty<CombinationActivityApi.SpuExtension>[] = [] + const res = (await ProductSpuApi.getSpuDetailList([ + spuId + ])) as CombinationActivityApi.SpuExtension[] + if (res.length == 0) { + return + } + spuList.value = [] + // 因为只能选择一个 + const spu = res[0] + const selectSkus = + typeof skuIds === 'undefined' ? spu?.skus : spu?.skus?.filter((sku) => skuIds.includes(sku.id!)) + selectSkus?.forEach((sku) => { + let config: CombinationProductVO = { + spuId: spu.id!, + skuId: sku.id!, + activePrice: 0 + } + if (typeof products !== 'undefined') { + const product = products.find((item) => item.skuId === sku.id) + if (product) { + // 分转元 + product.activePrice = formatToFraction(product.activePrice) + } + config = product || config + } + sku.productConfig = config + }) + spu.skus = selectSkus as CombinationActivityApi.SkuExtension[] + spuProperties.push({ + spuId: spu.id!, + spuDetail: spu, + propertyList: getPropertyList(spu) + }) + spuList.value.push(spu) + spuPropertyList.value = spuProperties +} + +// ================= end ================= + +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + await resetForm() + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = (await CombinationActivityApi.getCombinationActivity( + id + )) as CombinationActivityApi.CombinationActivityVO + await getSpuDetails( + data.spuId!, + data.products?.map((sku) => sku.skuId), + data.products + ) + formRef.value.setValues(data) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 重置表单 */ +const resetForm = async () => { + spuList.value = [] + spuPropertyList.value = [] + await nextTick() + formRef.value.getElFormRef().resetFields() +} + +/** 提交表单 */ +const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调 +const submitForm = async () => { + // 校验表单 + if (!formRef) return + const valid = await formRef.value.getElFormRef().validate() + if (!valid) return + // 提交请求 + formLoading.value = true + try { + const data = formRef.value.formModel as CombinationActivityApi.CombinationActivityVO + const products = spuAndSkuListRef.value.getSkuConfigs('productConfig') + products.forEach((item: CombinationProductVO) => { + // 拼团价格元转分 + item.activePrice = convertToInteger(item.activePrice) + }) + data.products = products + if (formType.value === 'create') { + await CombinationActivityApi.createCombinationActivity(data) + message.success(t('common.createSuccess')) + } else { + await CombinationActivityApi.updateCombinationActivity(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} +</script> diff --git a/src/views/mall/promotion/combination/activity/combinationActivity.data.ts b/src/views/mall/promotion/combination/activity/combinationActivity.data.ts new file mode 100644 index 00000000..2ddb8d19 --- /dev/null +++ b/src/views/mall/promotion/combination/activity/combinationActivity.data.ts @@ -0,0 +1,151 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter, getNowDateTime } from '@/utils/formatTime' + +// 表单校验 +export const rules = reactive({ + name: [required], + totalLimitCount: [required], + singleLimitCount: [required], + startTime: [required], + endTime: [required], + userSize: [required], + totalNum: [required], + successNum: [required], + orderUserCount: [required], + virtualGroup: [required], + status: [required], + limitDuration: [required] +}) + +// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive<CrudSchema[]>([ + { + label: '拼团名称', + field: 'name', + isSearch: true, + isTable: false, + form: { + colProps: { + span: 24 + } + } + }, + { + label: '活动时间', + field: 'activityTime', + formatter: dateFormatter, + search: { + show: true, + component: 'DatePicker', + componentProps: { + valueFormat: 'x', + type: 'datetimerange', + rangeSeparator: '至' + } + }, + form: { + component: 'DatePicker', + componentProps: { + valueFormat: 'x', + type: 'datetimerange', + rangeSeparator: '至' + }, + value: [getNowDateTime().valueOf(), getNowDateTime().valueOf()], + colProps: { + span: 24 + } + } + }, + { + label: '参与人数', + field: 'orderUserCount', + isSearch: false, + form: { + component: 'InputNumber', + labelMessage: '参与人数不能少于两人', + value: 2 + } + }, + { + label: '限制时长', + field: 'limitDuration', + isSearch: false, + isTable: false, + form: { + component: 'InputNumber', + labelMessage: '限制时长(小时)', + componentProps: { + placeholder: '请输入限制时长(小时)' + } + } + }, + { + label: '总限购数量', + field: 'totalLimitCount', + isSearch: false, + isTable: false, + form: { + component: 'InputNumber', + value: 0 + } + }, + { + label: '单次限购数量', + field: 'singleLimitCount', + isSearch: false, + isTable: false, + form: { + component: 'InputNumber', + value: 0 + } + }, + { + label: '购买人数', + field: 'userSize', + isSearch: false, + isForm: false + }, + { + label: '开团组数', + field: 'totalNum', + isSearch: false, + isForm: false + }, + { + label: '成团组数', + field: 'successNum', + isSearch: false, + isForm: false + }, + { + label: '虚拟成团', + field: 'virtualGroup', + isSearch: false, + isTable: false, + isForm: false + }, + { + label: '活动状态', + field: 'status', + dictType: DICT_TYPE.COMMON_STATUS, + dictClass: 'number', + isSearch: true, + isForm: false + }, + { + label: '拼团商品', + field: 'spuId', + isSearch: false, + form: { + colProps: { + span: 24 + } + } + }, + { + label: '操作', + field: 'action', + isForm: false + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas) diff --git a/src/views/mall/promotion/combination/activity/index.vue b/src/views/mall/promotion/combination/activity/index.vue new file mode 100644 index 00000000..e5b6f86d --- /dev/null +++ b/src/views/mall/promotion/combination/activity/index.vue @@ -0,0 +1,116 @@ +<template> + <!-- 搜索工作栏 --> + <ContentWrap> + <Search :schema="allSchemas.searchSchema" @reset="setSearchParams" @search="setSearchParams"> + <!-- 新增等操作按钮 --> + <template #actionMore> + <el-button + v-hasPermi="['promotion:combination-activity:create']" + plain + type="primary" + @click="openForm('create')" + > + <Icon class="mr-5px" icon="ep:plus" /> + 新增 + </el-button> + </template> + </Search> + </ContentWrap> + + <!-- 列表 --> + <ContentWrap> + <Table + v-model:currentPage="tableObject.currentPage" + v-model:pageSize="tableObject.pageSize" + :columns="allSchemas.tableColumns" + :data="tableObject.tableList" + :loading="tableObject.loading" + :pagination="{ + total: tableObject.total + }" + > + <template #spuId="{ row }"> + <el-image + :src="row.picUrl" + class="w-30px h-30px align-middle mr-5px" + @click="imagePreview(row.picUrl)" + /> + <span class="align-middle">{{ row.spuName }}</span> + </template> + <template #action="{ row }"> + <el-button + v-hasPermi="['promotion:combination-activity:update']" + link + type="primary" + @click="openForm('update', row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['promotion:combination-activity:delete']" + link + type="danger" + @click="handleDelete(row.id)" + > + 删除 + </el-button> + </template> + </Table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <CombinationActivityForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" setup> +import { allSchemas } from './combinationActivity.data' +import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationactivity' +import CombinationActivityForm from './CombinationActivityForm.vue' +import { cloneDeep } from 'lodash-es' +import { createImageViewer } from '@/components/ImageViewer' + +defineOptions({ name: 'PromotionCombinationActivity' }) + +// tableObject:表格的属性对象,可获得分页大小、条数等属性 +// tableMethods:表格的操作对象,可进行获得分页、删除记录等操作 +// 详细可见:https://doc.iocoder.cn/vue3/crud-schema/ +const { tableObject, tableMethods } = useTable({ + getListApi: CombinationActivityApi.getCombinationActivityPage, // 分页接口 + delListApi: CombinationActivityApi.deleteCombinationActivity // 删除接口 +}) +// 获得表格的各种操作 +const { getList, setSearchParams } = tableMethods + +/** 商品图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = (id: number) => { + tableMethods.delList(id, false) +} + +/** 初始化 **/ +onMounted(() => { + /* + TODO + 后面准备封装成一个函数来操作 tableColumns 重新排列:比如说需求是表单上商品选择是在后面的而列表展示的时候需要调到位置。 + 封装效果支持批量操作,给出 field 和需要插入的位置,例:[{field:'spuId',index: 1}] 效果为把 field 为 spuId 的 column 移动到第一个位置 + */ + // 处理一下表格列让商品往前 + const index = allSchemas.tableColumns.findIndex((item) => item.field === 'spuId') + const column = cloneDeep(allSchemas.tableColumns[index]) + allSchemas.tableColumns.splice(index, 1) + // 添加到开头 + allSchemas.tableColumns.unshift(column) + getList() +}) +</script> diff --git a/src/views/mall/promotion/components/SpuAndSkuList.vue b/src/views/mall/promotion/components/SpuAndSkuList.vue index e239a39a..8efb7f83 100644 --- a/src/views/mall/promotion/components/SpuAndSkuList.vue +++ b/src/views/mall/promotion/components/SpuAndSkuList.vue @@ -1,5 +1,5 @@ <template> - <el-table :data="spuData" :default-expand-all="true"> + <el-table :data="spuData" :expand-row-keys="expandRowKeys" row-key="id"> <el-table-column type="expand" width="30"> <template #default="{ row }"> <SkuList @@ -10,22 +10,7 @@ :rule-config="ruleConfig" > <template #extension> - <el-table-column align="center" label="秒杀库存" min-width="168"> - <template #default="{ row: sku }"> - <el-input-number v-model="sku.productConfig.stock" :min="0" class="w-100%" /> - </template> - </el-table-column> - <el-table-column align="center" label="秒杀价格(元)" min-width="168"> - <template #default="{ row: sku }"> - <el-input-number - v-model="sku.productConfig.seckillPrice" - :min="0" - :precision="2" - :step="0.1" - class="w-100%" - /> - </template> - </el-table-column> + <slot></slot> </template> </SkuList> </template> @@ -47,35 +32,31 @@ </el-table> </template> <script generic="T extends Spu" lang="ts" setup> -// TODO 后续计划重新封装作为活动商品配置通用组件;可以等其他活动做到的时候,在统一处理 SPU 选择组件哈 import { formatToFraction } from '@/utils' import { createImageViewer } from '@/components/ImageViewer' import { Spu } from '@/api/mall/product/spu' import { RuleConfig, SkuList } from '@/views/mall/product/spu/components' -import { SeckillProductVO } from '@/api/mall/promotion/seckill/seckillActivity' import { SpuProperty } from '@/views/mall/promotion/components/index' defineOptions({ name: 'PromotionSpuAndSkuList' }) -// TODO @puhui999:是不是改成传递一个 spu 就好啦? 因为活动商品可以多选所以展示编辑的时候需要展示多个 const props = defineProps<{ - spuList: T[] + spuList: T[] // TODO 为了方便兼容后续可能有需要展示多个 spu 的情况暂时保持,如果后续都是只操作一个 spu 的话则可更改为接受一个 spu 或保持 ruleConfig: RuleConfig[] spuPropertyListP: SpuProperty<T>[] }>() const spuData = ref<Spu[]>([]) // spu 详情数据列表 const skuListRef = ref() // 商品属性列表Ref - const spuPropertyList = ref<SpuProperty<T>[]>([]) // spuId 对应的 sku 的属性列表 - +const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。 /** - * 获取所有 sku 秒杀配置 + * 获取所有 sku 活动配置 * @param extendedAttribute 在 sku 上扩展的属性,例:秒杀活动 sku 扩展属性 productConfig 请参考 seckillActivity.ts */ -const getSkuConfigs: <V>(extendedAttribute: string) => V[] = (extendedAttribute: string) => { +const getSkuConfigs = (extendedAttribute: string) => { skuListRef.value.validateSku() - const seckillProducts: SeckillProductVO[] = [] + const seckillProducts = [] spuPropertyList.value.forEach((item) => { item.spuDetail.skus.forEach((sku) => { seckillProducts.push(sku[extendedAttribute]) @@ -116,6 +97,10 @@ watch( (data) => { if (!data) return spuPropertyList.value = data as SpuProperty<T>[] + // 解决如果之前选择的是单规格 spu 的话后面选择多规格 sku 多规格属性信息不展示的问题。解决方法:让 SkuList 组件重新渲染(行折叠会干掉包含的组件展开时会重新加载) + setTimeout(() => { + expandRowKeys.value = data.map((item) => item.spuId) + }, 200) }, { deep: true, diff --git a/src/views/mall/promotion/components/SpuSelect.vue b/src/views/mall/promotion/components/SpuSelect.vue index 94c60c9c..c62e419c 100644 --- a/src/views/mall/promotion/components/SpuSelect.vue +++ b/src/views/mall/promotion/components/SpuSelect.vue @@ -57,6 +57,7 @@ <template #default> <SkuList v-if="isExpand" + ref="skuListRef" :isComponent="true" :isDetail="true" :prop-form-data="spuData" @@ -110,7 +111,7 @@ </template> <script lang="ts" setup> -import { getPropertyList, Properties, SkuList } from '@/views/mall/product/spu/components' +import { getPropertyList, PropertyAndValues, SkuList } from '@/views/mall/product/spu/components' import { ElTable } from 'element-plus' import { dateFormatter } from '@/utils/formatTime' import { createImageViewer } from '@/components/ImageViewer' @@ -143,19 +144,66 @@ const queryParams = ref({ categoryId: null, createTime: [] }) // 查询参数 -const propertyList = ref<Properties[]>([]) // 商品属性列表 +const propertyList = ref<PropertyAndValues[]>([]) // 商品属性列表 const spuListRef = ref<InstanceType<typeof ElTable>>() -const spuData = ref<ProductSpuApi.Spu | {}>() // 商品详情 +const skuListRef = ref() // 商品属性选择 Ref +const spuData = ref<ProductSpuApi.Spu>() // 商品详情 const isExpand = ref(false) // 控制 SKU 列表显示 const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。 +//============ 商品选择相关 ============ +const selectedSpuId = ref<number>(0) // 选中的商品 spuId +const selectedSkuIds = ref<number[]>([]) // 选中的商品 skuIds +const selectSku = (val: ProductSpuApi.Sku[]) => { + if (selectedSpuId.value === 0) { + message.warning('请先选择商品再选择相应的规格!!!') + skuListRef.value.clearSelection() + return + } + selectedSkuIds.value = val.map((sku) => sku.id!) +} +const selectSpu = (val: ProductSpuApi.Spu[]) => { + if (val.length === 0) { + selectedSpuId.value = 0 + return + } + // 只选择一个 + selectedSpuId.value = val.map((spu) => spu.id!)[0] + // 切换选择 spu 如果有选择的 sku 则清空,确保选择的 sku 是对应的 spu 下面的 + if (selectedSkuIds.value.length > 0) { + selectedSkuIds.value = [] + } + // 如果大于1个 + if (val.length > 1) { + // 清空选择 + spuListRef.value.clearSelection() + // 变更为最后一次选择的 + spuListRef.value.toggleRowSelection(val.pop(), true) + return + } + expandChange(val[0], val) +} + // 计算商品属性 -const expandChange = async (row: ProductSpuApi.Spu, expandedRows: ProductSpuApi.Spu[]) => { +const expandChange = async (row: ProductSpuApi.Spu, expandedRows?: ProductSpuApi.Spu[]) => { + // 判断需要展开的 spuId === 选择的 spuId。如果选择了 A 就展开 A 的 skuList。如果选择了 A 手动展开 B 则阻断 + // 目的防止误选 sku + if (selectedSpuId.value !== 0) { + if (row.id !== selectedSpuId.value) { + message.warning('你已选择商品请先取消') + expandRowKeys.value = [selectedSpuId.value] + return + } + // 如果以展开 skuList 则选择此对应的 spu 不需要重新获取渲染 skuList + if (isExpand.value && spuData.value?.id === row.id) { + return + } + } spuData.value = {} propertyList.value = [] isExpand.value = false - // 如果展开个数为 0 - if (expandedRows.length === 0) { + if (expandedRows?.length === 0) { + // 如果展开个数为 0 expandRowKeys.value = [] return } @@ -167,33 +215,15 @@ const expandChange = async (row: ProductSpuApi.Spu, expandedRows: ProductSpuApi. expandRowKeys.value = [row.id!] } -//============ 商品选择相关 ============ -const selectedSpuIds = ref<number[]>([]) // 选中的商品 spuIds -const selectedSkuIds = ref<number[]>([]) // 选中的商品 skuIds -const selectSku = (val: ProductSpuApi.Sku[]) => { - selectedSkuIds.value = val.map((sku) => sku.id!) -} -const selectSpu = (val: ProductSpuApi.Spu[]) => { - selectedSpuIds.value = val.map((spu) => spu.id!) - // // 只选择一个 - // selectedSpu.value = val[0] - // // 如果大于1个 - // if (val.length > 1) { - // // 清空选择 - // spuListRef.value.clearSelection() - // // 变更为最后一次选择的 - // spuListRef.value.toggleRowSelection(val.pop(), true) - // } -} // 确认选择时的触发事件 const emits = defineEmits<{ - (e: 'confirm', spuIds: number[], skuIds?: number[]): void + (e: 'confirm', spuId: number, skuIds?: number[]): void }>() /** * 确认选择返回选中的 spu 和 sku (如果需要选择sku的话) */ const confirm = () => { - if (selectedSpuIds.value.length === 0) { + if (selectedSpuId.value === 0) { message.warning('没有选择任何商品') return } @@ -203,10 +233,12 @@ const confirm = () => { } // 返回各自 id 列表 props.isSelectSku - ? emits('confirm', selectedSpuIds.value, selectedSkuIds.value) - : emits('confirm', selectedSpuIds.value) + ? emits('confirm', selectedSpuId.value, selectedSkuIds.value) + : emits('confirm', selectedSpuId.value) // 关闭弹窗 dialogVisible.value = false + selectedSpuId.value = 0 + selectedSkuIds.value = [] } /** 打开弹窗 */ diff --git a/src/views/mall/promotion/components/index.ts b/src/views/mall/promotion/components/index.ts index a4b4e75b..b42c8ce9 100644 --- a/src/views/mall/promotion/components/index.ts +++ b/src/views/mall/promotion/components/index.ts @@ -1,11 +1,11 @@ import SpuSelect from './SpuSelect.vue' import SpuAndSkuList from './SpuAndSkuList.vue' -import { Properties } from '@/views/mall/product/spu/components' +import { PropertyAndValues } from '@/views/mall/product/spu/components' type SpuProperty<T> = { spuId: number spuDetail: T - propertyList: Properties[] + propertyList: PropertyAndValues[] } /** diff --git a/src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue b/src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue index e1aee4c7..ac7a06b6 100644 --- a/src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue +++ b/src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue @@ -8,14 +8,31 @@ :schema="allSchemas.formSchema" > <!-- 先选择 --> - <template #spuIds> + <template #spuId> <el-button @click="spuSelectRef.open()">选择商品</el-button> <SpuAndSkuList ref="spuAndSkuListRef" :rule-config="ruleConfig" :spu-list="spuList" :spu-property-list-p="spuPropertyList" - /> + > + <el-table-column align="center" label="秒杀库存" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number v-model="sku.productConfig.stock" :min="0" class="w-100%" /> + </template> + </el-table-column> + <el-table-column align="center" label="秒杀价格(元)" min-width="168"> + <template #default="{ row: sku }"> + <el-input-number + v-model="sku.productConfig.seckillPrice" + :min="0" + :precision="2" + :step="0.1" + class="w-100%" + /> + </template> + </el-table-column> + </SpuAndSkuList> </template> </Form> <template #footer> @@ -23,15 +40,17 @@ <el-button @click="dialogVisible = false">取 消</el-button> </template> </Dialog> - <SpuSelect ref="spuSelectRef" @confirm="selectSpu" /> + <SpuSelect ref="spuSelectRef" :isSelectSku="true" @confirm="selectSpu" /> </template> <script lang="ts" setup> import { SpuAndSkuList, SpuProperty, SpuSelect } from '../../components' import { allSchemas, rules } from './seckillActivity.data' import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity' -import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components' +import { SeckillProductVO } from '@/api/mall/promotion/seckill/seckillActivity' import * as ProductSpuApi from '@/api/mall/product/spu' +import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components' +import { convertToInteger, formatToFraction } from '@/utils' defineOptions({ name: 'PromotionSeckillActivityForm' }) @@ -43,6 +62,9 @@ const dialogTitle = ref('') // 弹窗的标题 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 const formType = ref('') // 表单的类型:create - 新增;update - 修改 const formRef = ref() // 表单 Ref + +// ================= 商品选择相关 ================= + const spuSelectRef = ref() // 商品和属性选择 Ref const spuAndSkuListRef = ref() // sku 秒杀配置组件Ref const ruleConfig: RuleConfig[] = [ @@ -57,17 +79,76 @@ const ruleConfig: RuleConfig[] = [ message: '商品秒杀价格必须大于 0.01 !!!' } ] +const spuList = ref<SeckillActivityApi.SpuExtension[]>([]) // 选择的 spu +const spuPropertyList = ref<SpuProperty<SeckillActivityApi.SpuExtension>[]>([]) +const selectSpu = (spuId: number, skuIds: number[]) => { + formRef.value.setValues({ spuId }) + getSpuDetails(spuId, skuIds) +} +/** + * 获取 SPU 详情 + */ +const getSpuDetails = async ( + spuId: number, + skuIds: number[] | undefined, + products?: SeckillProductVO[] +) => { + const spuProperties: SpuProperty<SeckillActivityApi.SpuExtension>[] = [] + const res = (await ProductSpuApi.getSpuDetailList([spuId])) as SeckillActivityApi.SpuExtension[] + if (res.length == 0) { + return + } + spuList.value = [] + // 因为只能选择一个 + const spu = res[0] + const selectSkus = + typeof skuIds === 'undefined' ? spu?.skus : spu?.skus?.filter((sku) => skuIds.includes(sku.id!)) + selectSkus?.forEach((sku) => { + let config: SeckillActivityApi.SeckillProductVO = { + skuId: sku.id!, + stock: 0, + seckillPrice: 0 + } + if (typeof products !== 'undefined') { + const product = products.find((item) => item.skuId === sku.id) + if (product) { + // 分转元 + product.seckillPrice = formatToFraction(product.seckillPrice) + } + config = product || config + } + sku.productConfig = config + }) + spu.skus = selectSkus as SeckillActivityApi.SkuExtension[] + spuProperties.push({ + spuId: spu.id!, + spuDetail: spu, + propertyList: getPropertyList(spu) + }) + spuList.value.push(spu) + spuPropertyList.value = spuProperties +} + +// ================= end ================= + /** 打开弹窗 */ const open = async (type: string, id?: number) => { dialogVisible.value = true dialogTitle.value = t('action.' + type) formType.value = type - resetForm() - // 修改时,设置数据 TODO 没测试估计有问题 + await resetForm() + // 修改时,设置数据 if (id) { formLoading.value = true try { - const data = await SeckillActivityApi.getSeckillActivity(id) + const data = (await SeckillActivityApi.getSeckillActivity( + id + )) as SeckillActivityApi.SeckillActivityVO + await getSpuDetails( + data.spuId!, + data.products?.map((sku) => sku.skuId), + data.products + ) formRef.value.setValues(data) } finally { formLoading.value = false @@ -76,47 +157,11 @@ const open = async (type: string, id?: number) => { } defineExpose({ open }) // 提供 open 方法,用于打开弹窗 -const spuList = ref<SeckillActivityApi.SpuExtension[]>([]) // 选择的 spu -const spuPropertyList = ref<SpuProperty<SeckillActivityApi.SpuExtension>[]>([]) -const selectSpu = (spuIds: number[]) => { - formRef.value.setValues({ spuIds }) - getSpuDetails(spuIds) -} -/** - * 获取 SPU 详情 - * TODO 获取 SPU 详情,放到各自活动表单来做,让 SpuAndSkuList 职责单一点 - * @param spuIds - */ -const getSpuDetails = async (spuIds: number[]) => { - const spuProperties: SpuProperty<SeckillActivityApi.SpuExtension>[] = [] - spuList.value = [] - // TODO puhui999: 考虑后端添加通过 spuIds 批量获取 - for (const spuId of spuIds) { - // 获取 SPU 详情 - const res = (await ProductSpuApi.getSpu(spuId)) as SeckillActivityApi.SpuExtension - if (!res) { - continue - } - spuList.value.push(res) - // 初始化每个 sku 秒杀配置 - res.skus?.forEach((sku) => { - const config: SeckillActivityApi.SeckillProductVO = { - spuId, - skuId: sku.id!, - stock: 0, - seckillPrice: 0 - } - sku.productConfig = config - }) - spuProperties.push({ spuId, spuDetail: res, propertyList: getPropertyList(res) }) - } - spuPropertyList.value = spuProperties -} - /** 重置表单 */ -const resetForm = () => { +const resetForm = async () => { spuList.value = [] spuPropertyList.value = [] + await nextTick() formRef.value.getElFormRef().resetFields() } /** 提交表单 */ @@ -130,8 +175,13 @@ const submitForm = async () => { formLoading.value = true try { const data = formRef.value.formModel as SeckillActivityApi.SeckillActivityVO - data.spuIds = spuList.value.map((spu) => spu.id!) - data.products = spuAndSkuListRef.value.getSkuConfigs('productConfig') + const products = spuAndSkuListRef.value.getSkuConfigs('productConfig') + products.forEach((item: SeckillProductVO) => { + // 秒杀价格元转分 + item.seckillPrice = convertToInteger(item.seckillPrice) + }) + // 获取秒杀商品配置 + data.products = products if (formType.value === 'create') { await SeckillActivityApi.createSeckillActivity(data) message.success(t('common.createSuccess')) diff --git a/src/views/mall/promotion/seckill/activity/index.vue b/src/views/mall/promotion/seckill/activity/index.vue index f06b855f..71adeae8 100644 --- a/src/views/mall/promotion/seckill/activity/index.vue +++ b/src/views/mall/promotion/seckill/activity/index.vue @@ -32,6 +32,14 @@ @expand-change="expandChange" > <template #expand> 展示活动商品和商品相关属性活动配置</template> + <template #spuId="{ row }"> + <el-image + :src="row.picUrl" + class="w-30px h-30px align-middle mr-5px" + @click="imagePreview(row.picUrl)" + /> + <span class="align-middle">{{ row.spuName }}</span> + </template> <template #configIds="{ row }"> <el-tag v-for="(name, index) in convertSeckillConfigNames(row)" :key="index" class="mr-5px"> {{ name }} @@ -66,6 +74,8 @@ import { allSchemas } from './seckillActivity.data' import { getListAllSimple } from '@/api/mall/promotion/seckill/seckillConfig' import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity' import SeckillActivityForm from './SeckillActivityForm.vue' +import { cloneDeep } from 'lodash-es' +import { createImageViewer } from '@/components/ImageViewer' defineOptions({ name: 'PromotionSeckillActivity' }) @@ -89,12 +99,16 @@ const openForm = (type: string, id?: number) => { const handleDelete = (id: number) => { tableMethods.delList(id, false) } - -// TODO @puhui:是不是直接叫 configList 就好啦 -const seckillConfigAllSimple = ref([]) // 时段配置精简列表 +/** 商品图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + urlList: [imgUrl] + }) +} +const configList = ref([]) // 时段配置精简列表 const convertSeckillConfigNames = computed( () => (row) => - seckillConfigAllSimple.value + configList.value ?.filter((item) => row.configIds.includes(item.id)) ?.map((config) => config.name) ) @@ -106,7 +120,18 @@ const expandChange = (row, expandedRows) => { /** 初始化 **/ onMounted(async () => { + /* + TODO + 后面准备封装成一个函数来操作 tableColumns 重新排列:比如说需求是表单上商品选择是在后面的而列表展示的时候需要调到位置。 + 封装效果支持批量操作,给出 field 和需要插入的位置,例:[{field:'spuId',index: 1}] 效果为把 field 为 spuId 的 column 移动到第一个位置 + */ + // 处理一下表格列让商品往前 + const index = allSchemas.tableColumns.findIndex((item) => item.field === 'spuId') + const column = cloneDeep(allSchemas.tableColumns[index]) + allSchemas.tableColumns.splice(index, 1) + // 添加到开头 + allSchemas.tableColumns.unshift(column) await getList() - seckillConfigAllSimple.value = await getListAllSimple() + configList.value = await getListAllSimple() }) </script> diff --git a/src/views/mall/promotion/seckill/activity/seckillActivity.data.ts b/src/views/mall/promotion/seckill/activity/seckillActivity.data.ts index c858374c..70766d49 100644 --- a/src/views/mall/promotion/seckill/activity/seckillActivity.data.ts +++ b/src/views/mall/promotion/seckill/activity/seckillActivity.data.ts @@ -152,6 +152,17 @@ const crudSchemas = reactive<CrudSchema[]>([ width: 120 } }, + { + label: '排序', + field: 'sort', + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 80 + } + }, { label: '秒杀库存', field: 'stock', @@ -167,18 +178,15 @@ const crudSchemas = reactive<CrudSchema[]>([ { label: '秒杀总库存', field: 'totalStock', - form: { - component: 'InputNumber', - value: 0 - }, + isForm: false, table: { width: 120 } }, { label: '秒杀活动商品', - field: 'spuIds', - isTable: false, + field: 'spuId', + isTable: true, isSearch: false, form: { colProps: { @@ -186,7 +194,7 @@ const crudSchemas = reactive<CrudSchema[]>([ } }, table: { - width: 200 + width: 300 } }, { @@ -206,17 +214,6 @@ const crudSchemas = reactive<CrudSchema[]>([ width: 120 } }, - { - label: '排序', - field: 'sort', - form: { - component: 'InputNumber', - value: 0 - }, - table: { - width: 80 - } - }, { label: '状态', field: 'status', diff --git a/src/views/mall/promotion/seckill/config/SeckillConfigForm.vue b/src/views/mall/promotion/seckill/config/SeckillConfigForm.vue index e25f8fce..22b07d3a 100644 --- a/src/views/mall/promotion/seckill/config/SeckillConfigForm.vue +++ b/src/views/mall/promotion/seckill/config/SeckillConfigForm.vue @@ -10,6 +10,7 @@ <script lang="ts" name="SeckillConfigForm" setup> import * as SeckillConfigApi from '@/api/mall/promotion/seckill/seckillConfig' import { allSchemas, rules } from './seckillConfig.data' +import { cloneDeep } from 'lodash-es' const { t } = useI18n() // 国际化 const message = useMessage() // 消息弹窗 @@ -30,6 +31,9 @@ const open = async (type: string, id?: number) => { formLoading.value = true try { const data = await SeckillConfigApi.getSeckillConfig(id) + data.sliderPicUrls = data['sliderPicUrls']?.map((item) => ({ + url: item + })) formRef.value.setValues(data) } finally { formLoading.value = false @@ -48,12 +52,20 @@ const submitForm = async () => { // 提交请求 formLoading.value = true try { + // 处理轮播图列表 const data = formRef.value.formModel as SeckillConfigApi.SeckillConfigVO + const cloneData = cloneDeep(data) + const newSliderPicUrls = [] + cloneData.sliderPicUrls.forEach((item) => { + // 如果是前端选的图 + typeof item === 'object' ? newSliderPicUrls.push(item.url) : newSliderPicUrls.push(item) + }) + cloneData.sliderPicUrls = newSliderPicUrls if (formType.value === 'create') { - await SeckillConfigApi.createSeckillConfig(data) + await SeckillConfigApi.createSeckillConfig(cloneData) message.success(t('common.createSuccess')) } else { - await SeckillConfigApi.updateSeckillConfig(data) + await SeckillConfigApi.updateSeckillConfig(cloneData) message.success(t('common.updateSuccess')) } dialogVisible.value = false diff --git a/src/views/mall/promotion/seckill/config/index.vue b/src/views/mall/promotion/seckill/config/index.vue index 60c89d7c..8072b9d3 100644 --- a/src/views/mall/promotion/seckill/config/index.vue +++ b/src/views/mall/promotion/seckill/config/index.vue @@ -29,8 +29,14 @@ total: tableObject.total }" > - <template #picUrl="{ row }"> - <el-image :src="row.picUrl" class="w-30px h-30px" @click="imagePreview(row.picUrl)" /> + <template #sliderPicUrls="{ row }"> + <el-image + v-for="(item, index) in row.sliderPicUrls" + :key="index" + :src="item" + class="w-60px h-60px mr-10px" + @click="imagePreview(row.sliderPicUrls)" + /> </template> <template #status="{ row }"> <el-switch @@ -70,6 +76,7 @@ import * as SeckillConfigApi from '@/api/mall/promotion/seckill/seckillConfig' import SeckillConfigForm from './SeckillConfigForm.vue' import { createImageViewer } from '@/components/ImageViewer' import { CommonStatusEnum } from '@/utils/constants' +import { isArray } from '@/utils/is' const message = useMessage() // 消息弹窗 // tableObject:表格的属性对象,可获得分页大小、条数等属性 @@ -82,10 +89,18 @@ const { tableObject, tableMethods } = useTable({ // 获得表格的各种操作 const { getList, setSearchParams } = tableMethods -/** 商品图预览 */ -const imagePreview = (imgUrl: string) => { +/** 轮播图预览预览 */ +const imagePreview = (args) => { + const urlList = [] + if (isArray(args)) { + args.forEach((item) => { + urlList.push(item) + }) + } else { + urlList.push(args) + } createImageViewer({ - urlList: [imgUrl] + urlList }) } diff --git a/src/views/mall/promotion/seckill/config/seckillConfig.data.ts b/src/views/mall/promotion/seckill/config/seckillConfig.data.ts index 49bf36af..dbc9b360 100644 --- a/src/views/mall/promotion/seckill/config/seckillConfig.data.ts +++ b/src/views/mall/promotion/seckill/config/seckillConfig.data.ts @@ -46,11 +46,14 @@ const crudSchemas = reactive<CrudSchema[]>([ } }, { - label: '秒杀主图', - field: 'picUrl', + label: '秒杀轮播图', + field: 'sliderPicUrls', isSearch: false, form: { - component: 'UploadImg' + component: 'UploadImgs' + }, + table: { + width: 300 } }, {