diff --git a/src/api/mall/product/spu.ts b/src/api/mall/product/spu.ts index b6bec97e..5ddaa92e 100644 --- a/src/api/mall/product/spu.ts +++ b/src/api/mall/product/spu.ts @@ -49,6 +49,16 @@ export interface Spu { recommendGood?: boolean // 是否优品 } +export interface SpuRespVO extends Spu { + price: number + salesCount: number + marketPrice: number + costPrice: number + stock: number + createTime: Date + status: number +} + // 获得 Spu 列表 export const getSpuPage = (params: PageParam) => { return request.get({ url: '/product/spu/page', params }) diff --git a/src/api/mall/promotion/seckill/seckillActivity.ts b/src/api/mall/promotion/seckill/seckillActivity.ts new file mode 100644 index 00000000..fc2d1871 --- /dev/null +++ b/src/api/mall/promotion/seckill/seckillActivity.ts @@ -0,0 +1,67 @@ +import request from '@/config/axios' +import { Sku, SpuRespVO } 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[] +} + +export interface SeckillProductVO { + spuId: number + skuId: number + seckillPrice: number + stock: number +} + +type SkuExtension = Sku & { + productConfig: SeckillProductVO +} + +export interface SpuExtension extends SpuRespVO { + skus: SkuExtension[] // 重写类型 +} + +// 查询秒杀活动列表 +export const getSeckillActivityPage = async (params) => { + return await request.get({ url: '/promotion/seckill-activity/page', params }) +} + +// 查询秒杀活动详情 +export const getSeckillActivity = async (id: number) => { + return await request.get({ url: '/promotion/seckill-activity/get?id=' + id }) +} + +// 新增秒杀活动 +export const createSeckillActivity = async (data: SeckillActivityVO) => { + return await request.post({ url: '/promotion/seckill-activity/create', data }) +} + +// 修改秒杀活动 +export const updateSeckillActivity = async (data: SeckillActivityVO) => { + return await request.put({ url: '/promotion/seckill-activity/update', data }) +} + +// 删除秒杀活动 +export const deleteSeckillActivity = async (id: number) => { + return await request.delete({ url: '/promotion/seckill-activity/delete?id=' + id }) +} + +// 导出秒杀活动 Excel +export const exportSeckillActivityApi = async (params) => { + return await request.download({ url: '/promotion/seckill-activity/export-excel', params }) +} diff --git a/src/api/mall/promotion/seckill/seckillConfig.ts b/src/api/mall/promotion/seckill/seckillConfig.ts new file mode 100644 index 00000000..aff72821 --- /dev/null +++ b/src/api/mall/promotion/seckill/seckillConfig.ts @@ -0,0 +1,54 @@ +import request from '@/config/axios' + +export interface SeckillConfigVO { + id: number + name: string + startTime: string + endTime: string + picUrl: string + status: number +} + +// 查询秒杀时段配置列表 +export const getSeckillConfigPage = async (params) => { + return await request.get({ url: '/promotion/seckill-config/page', params }) +} + +// 查询秒杀时段配置详情 +export const getSeckillConfig = async (id: number) => { + return await request.get({ url: '/promotion/seckill-config/get?id=' + id }) +} + +// 获得所有开启状态的秒杀时段精简列表 +export const getListAllSimple = async () => { + return await request.get({ url: '/promotion/seckill-config/list-all-simple' }) +} + +// 新增秒杀时段配置 +export const createSeckillConfig = async (data: SeckillConfigVO) => { + return await request.post({ url: '/promotion/seckill-config/create', data }) +} + +// 修改秒杀时段配置 +export const updateSeckillConfig = async (data: SeckillConfigVO) => { + return await request.put({ url: '/promotion/seckill-config/update', data }) +} + +// 修改时段配置状态 +export const updateSeckillConfigStatus = (id: number, status: number) => { + const data = { + id, + status + } + return request.put({ url: '/promotion/seckill-config/update-status', data: data }) +} + +// 删除秒杀时段配置 +export const deleteSeckillConfig = async (id: number) => { + return await request.delete({ url: '/promotion/seckill-config/delete?id=' + id }) +} + +// 导出秒杀时段配置 Excel +export const exportSeckillConfigApi = async (params) => { + return await request.download({ url: '/promotion/seckill-config/export-excel', params }) +} diff --git a/src/components/Dialog/src/Dialog.vue b/src/components/Dialog/src/Dialog.vue index 8fb71759..c1114d36 100644 --- a/src/components/Dialog/src/Dialog.vue +++ b/src/components/Dialog/src/Dialog.vue @@ -17,7 +17,7 @@ const props = defineProps({ }) const getBindValue = computed(() => { - const delArr: string[] = ['fullscreen', 'title', 'maxHeight'] + const delArr: string[] = ['fullscreen', 'title', 'maxHeight', 'appendToBody'] const attrs = useAttrs() const obj = { ...attrs, ...props } for (const key in obj) { diff --git a/src/hooks/web/useCrudSchemas.ts b/src/hooks/web/useCrudSchemas.ts index a29f75ab..984e57c5 100644 --- a/src/hooks/web/useCrudSchemas.ts +++ b/src/hooks/web/useCrudSchemas.ts @@ -1,7 +1,7 @@ import { reactive } from 'vue' import { AxiosPromise } from 'axios' import { findIndex } from '@/utils' -import { eachTree, treeMap, filter } from '@/utils/tree' +import { eachTree, filter, treeMap } from '@/utils/tree' import { getBoolDictOptions, getDictOptions, getIntDictOptions } from '@/utils/dict' import { FormSchema } from '@/types/form' @@ -36,8 +36,11 @@ type CrudSearchParams = { type CrudTableParams = { // 是否显示表头 show?: boolean + // 列宽配置 + width?: number | string + // 列是否固定在左侧或者右侧 + fixed?: 'left' | 'right' } & Omit<FormSchema, 'field'> - type CrudFormParams = { // 是否显示表单项 show?: boolean diff --git a/src/types/table.d.ts b/src/types/table.d.ts index 3294234b..9cb4205b 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -1,6 +1,8 @@ export type TableColumn = { field: string label?: string + width?: number | string + fixed?: 'left' | 'right' children?: TableColumn[] } & Recordable diff --git a/src/utils/formatTime.ts b/src/utils/formatTime.ts index 0d68e362..b27cabdf 100644 --- a/src/utils/formatTime.ts +++ b/src/utils/formatTime.ts @@ -155,7 +155,7 @@ export const dateFormatter = (row, column, cellValue) => { * @returns 带时间00:00:00的日期 */ export function beginOfDay(param: Date) { - return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 0, 0, 0, 0) + return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 0, 0, 0) } /** @@ -164,7 +164,7 @@ export function beginOfDay(param: Date) { * @returns 带时间23:59:59的日期 */ export function endOfDay(param: Date) { - return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 23, 59, 59, 999) + return new Date(param.getFullYear(), param.getMonth(), param.getDate(), 23, 59, 59) } /** diff --git a/src/views/mall/product/spu/components/SkuList.vue b/src/views/mall/product/spu/components/SkuList.vue index dcb2ccaf..da263686 100644 --- a/src/views/mall/product/spu/components/SkuList.vue +++ b/src/views/mall/product/spu/components/SkuList.vue @@ -1,7 +1,7 @@ <template> <!-- 情况一:添加/修改 --> <el-table - v-if="!isDetail" + v-if="!isDetail && !isActivityComponent" :data="isBatch ? skuList : formData!.skus" border class="tabNumWidth" @@ -118,7 +118,9 @@ max-height="500" size="small" style="width: 99%" + @selection-change="handleSelectionChange" > + <el-table-column v-if="isComponent" type="selection" width="45" /> <el-table-column align="center" label="图片" min-width="80"> <template #default="{ row }"> <el-image :src="row.picUrl" class="w-60px h-60px" @click="imagePreview(row.picUrl)" /> @@ -188,6 +190,66 @@ </el-table-column> </template> </el-table> + + <!-- 情况三:作为活动组件 --> + <el-table + v-if="isActivityComponent" + :data="formData!.skus" + border + max-height="500" + size="small" + style="width: 99%" + > + <el-table-column v-if="isComponent" type="selection" width="45" /> + <el-table-column align="center" label="图片" min-width="80"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="w-60px h-60px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <template v-if="formData!.specType"> + <!-- 根据商品属性动态添加 --> + <el-table-column + v-for="(item, index) in tableHeaders" + :key="index" + :label="item.label" + align="center" + min-width="80" + > + <template #default="{ row }"> + <span style="font-weight: bold; color: #40aaff"> + {{ row.properties[index]?.valueName }} + </span> + </template> + </el-table-column> + </template> + <el-table-column align="center" label="商品条码" min-width="100"> + <template #default="{ row }"> + {{ row.barCode }} + </template> + </el-table-column> + <el-table-column align="center" label="销售价(元)" min-width="80"> + <template #default="{ row }"> + {{ row.price }} + </template> + </el-table-column> + <el-table-column align="center" label="市场价(元)" min-width="80"> + <template #default="{ row }"> + {{ row.marketPrice }} + </template> + </el-table-column> + <el-table-column align="center" label="成本价(元)" min-width="80"> + <template #default="{ row }"> + {{ row.costPrice }} + </template> + </el-table-column> + <el-table-column align="center" label="库存" min-width="80"> + <template #default="{ row }"> + {{ row.stock }} + </template> + </el-table-column> + <!-- 方便扩展每个活动配置的属性不一样 --> + <slot name="extension"></slot> + </el-table> </template> <script lang="ts" setup> import { PropType, Ref } from 'vue' @@ -196,6 +258,7 @@ import { propTypes } from '@/utils/propTypes' 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' defineOptions({ name: 'SkuList' }) @@ -208,8 +271,14 @@ const props = defineProps({ type: Array, default: () => [] }, + ruleConfig: { + type: Array as PropType<RuleConfig[]>, + default: () => [] + }, isBatch: propTypes.bool.def(false), // 是否作为批量操作组件 - isDetail: propTypes.bool.def(false) // 是否作为 sku 详情组件 + isDetail: propTypes.bool.def(false), // 是否作为 sku 详情组件 + isComponent: propTypes.bool.def(false), // 是否作为 sku 选择组件 + isActivityComponent: propTypes.bool.def(false) // 是否作为 sku 活动配置组件 }) const formData: Ref<Spu | undefined> = ref<Spu>() // 表单数据 const skuList = ref<Sku[]>([ @@ -230,6 +299,7 @@ const skuList = ref<Sku[]>([ /** 商品图预览 */ const imagePreview = (imgUrl: string) => { createImageViewer({ + zIndex: 9999999, urlList: [imgUrl] }) } @@ -257,14 +327,35 @@ const validateSku = (): boolean => { const checks = ['price', 'marketPrice', 'costPrice'] let validate = true // 默认通过 for (const sku of formData.value!.skus) { - if (checks.some((check) => sku[check] < 0.01)) { - validate = false // 只要有一个不通过则直接不通过 - break + // 作为活动组件的校验 + if (props.isActivityComponent) { + for (const rule of props.ruleConfig) { + if (sku[rule.name] < rule.geValue) { + validate = false // 只要有一个不通过则直接不通过 + break + } + } + } else { + if (checks.some((check) => sku[check] < 0.01)) { + validate = false // 只要有一个不通过则直接不通过 + break + } } } return validate } +const emit = defineEmits<{ + (e: 'selectionChange', value: Sku[]): void +}>() +/** + * 选择时触发 + * @param Sku 传递过来的选中的 sku 是一个数组 + */ +const handleSelectionChange = (val: Sku[]) => { + emit('selectionChange', val) +} + /** * 将传进来的值赋值给 skuList */ diff --git a/src/views/mall/product/spu/components/index.ts b/src/views/mall/product/spu/components/index.ts index 549a9815..8c269e56 100644 --- a/src/views/mall/product/spu/components/index.ts +++ b/src/views/mall/product/spu/components/index.ts @@ -5,11 +5,53 @@ import ProductAttributes from './ProductAttributes.vue' import ProductPropertyAddForm from './ProductPropertyAddForm.vue' import SkuList from './SkuList.vue' +import { Spu } from '@/api/mall/product/spu' + +interface Properties { + id: number + name: string + values?: Properties[] +} + +interface RuleConfig { + name: string // 需要校验的字段 + geValue: number // TODO 暂定大于一个数字 +} + +/** + * 商品通用函数 + * @param spu + */ +const getPropertyList = (spu: Spu): Properties[] => { + // 直接拿返回的 skus 属性逆向生成出 propertyList + const properties: Properties[] = [] + // 只有是多规格才处理 + 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 { BasicInfoForm, DescriptionForm, OtherSettingsForm, ProductAttributes, ProductPropertyAddForm, - SkuList + SkuList, + getPropertyList, + Properties, + RuleConfig } diff --git a/src/views/mall/product/spu/index.vue b/src/views/mall/product/spu/index.vue index a3b8da0b..7dfd484b 100644 --- a/src/views/mall/product/spu/index.vue +++ b/src/views/mall/product/spu/index.vue @@ -17,7 +17,6 @@ @keyup.enter="handleQuery" /> </el-form-item> - <!-- TODO 分类只能选择二级分类目前还没做,还是先以联调通顺为主 fixL: 已完善 --> <el-form-item label="商品分类" prop="categoryId"> <el-tree-select v-model="queryParams.categoryId" @@ -79,12 +78,6 @@ /> </el-tabs> <el-table v-loading="loading" :data="list"> - <!-- TODO puhui:这几个属性哈,一行三个 fix - 商品分类:服装鞋包/箱包 -商品市场价格:100.00 -成本价:0.00 -收藏:5 -虚拟销量:999 --> <el-table-column type="expand" width="30"> <template #default="{ row }"> <el-form class="demo-table-expand" label-position="left"> @@ -292,7 +285,8 @@ const queryParams = ref({ pageSize: 10, tabType: 0, name: '', - categoryId: null + categoryId: null, + createTime: [] }) // 查询参数 const queryFormRef = ref() // 搜索的表单Ref diff --git a/src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue b/src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue new file mode 100644 index 00000000..37cc836d --- /dev/null +++ b/src/views/mall/promotion/seckill/activity/SeckillActivityForm.vue @@ -0,0 +1,102 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle" width="65%"> + <Form + ref="formRef" + v-loading="formLoading" + :isCol="true" + :rules="rules" + :schema="allSchemas.formSchema" + > + <!-- 先选择 --> + <template #spuId> + <el-button @click="spuAndSkuSelectForm.open('秒杀商品选择')">添加商品</el-button> + <SpuAndSkuList ref="spuAndSkuListRef" :spu-list="spuList" /> + </template> + </Form> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> + <SpuAndSkuSelectForm ref="spuAndSkuSelectForm" @confirm="selectSpu" /> +</template> +<script lang="ts" name="PromotionSeckillActivityForm" setup> +import { SpuAndSkuList, SpuAndSkuSelectForm } from './components' +import { allSchemas, rules } from './seckillActivity.data' +import { Spu } from '@/api/mall/product/spu' + +import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity' + +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 spuAndSkuSelectForm = ref() // 商品和属性选择 Ref +const spuAndSkuListRef = ref() // sku 秒杀配置组件Ref +/** 打开弹窗 */ +const open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + // 修改时,设置数据 TODO 没测试估计有问题 + if (id) { + formLoading.value = true + try { + const data = await SeckillActivityApi.getSeckillActivity(id) + formRef.value.setValues(data) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +const spuList = ref<Spu[]>([]) // 选择的 spu +const selectSpu = (val: Spu) => { + formRef.value.setValues({ spuId: val.id }) + spuList.value = [val] +} + +/** 提交表单 */ +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 SeckillActivityApi.SeckillActivityVO + data.spuIds = spuList.value.map((spu) => spu.id!) + data.products = spuAndSkuListRef.value.getSkuConfigs() + if (formType.value === 'create') { + await SeckillActivityApi.createSeckillActivity(data) + message.success(t('common.createSuccess')) + } else { + await SeckillActivityApi.updateSeckillActivity(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} +</script> +<style lang="scss" scoped> +.demo-table-expand { + padding-left: 42px; + + :deep(.el-form-item__label) { + width: 82px; + font-weight: bold; + color: #99a9bf; + } +} +</style> diff --git a/src/views/mall/promotion/seckill/activity/components/SpuAndSkuList.vue b/src/views/mall/promotion/seckill/activity/components/SpuAndSkuList.vue new file mode 100644 index 00000000..fc7ed15a --- /dev/null +++ b/src/views/mall/promotion/seckill/activity/components/SpuAndSkuList.vue @@ -0,0 +1,157 @@ +<template> + <el-table :data="spuData"> + <el-table-column type="expand" width="30"> + <template #default="{ 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> + <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> + </template> + </SkuList> + </template> + </el-table-column> + <el-table-column key="id" align="center" label="商品编号" prop="id" /> + <el-table-column label="商品图" min-width="80"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="w-30px h-30px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" /> + <el-table-column align="center" label="商品售价" min-width="90" prop="price"> + <template #default="{ row }"> + {{ formatToFraction(row.price) }} + </template> + </el-table-column> + <el-table-column align="center" label="销量" min-width="90" prop="salesCount" /> + <el-table-column align="center" label="库存" min-width="90" prop="stock" /> + </el-table> +</template> +<script lang="ts" name="SpuAndSkuList" setup> +// TODO 后续计划重新封装作为活动商品配置通用组件 +import { formatToFraction } from '@/utils' +import { createImageViewer } from '@/components/ImageViewer' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { SpuRespVO } from '@/api/mall/product/spu' +import { + getPropertyList, + Properties, + RuleConfig, + SkuList +} from '@/views/mall/product/spu/components' +import { SeckillProductVO, SpuExtension } from '@/api/mall/promotion/seckill/seckillActivity' + +const props = defineProps({ + spuList: { + type: Array, + default: () => [] + } +}) +const spuData = ref<SpuRespVO[]>([]) // spu 详情数据列表 +const skuListRef = ref() // 商品属性列表Ref +interface spuProperty { + spuId: number + spuDetail: SpuExtension + propertyList: Properties[] +} + +const spuPropertyList = ref<spuProperty[]>([]) // spuId 对应的 sku 的属性列表 +/** + * 获取 SPU 详情 + * @param spuIds + */ +const getSpuDetails = async (spuIds: number[]) => { + const spuProperties: spuProperty[] = [] + // TODO puhui999: 考虑后端添加通过 spuIds 批量获取 + for (const spuId of spuIds) { + // 获取 SPU 详情 + const res = (await ProductSpuApi.getSpu(spuId)) as SpuExtension + if (!res) { + continue + } + // 初始化每个 sku 秒杀配置 + res.skus?.forEach((sku) => { + const config: SeckillProductVO = { + spuId, + skuId: sku.id!, + stock: 0, + seckillPrice: 0 + } + sku.productConfig = config + }) + spuProperties.push({ spuId, spuDetail: res, propertyList: getPropertyList(res) }) + } + spuPropertyList.value = spuProperties +} +const ruleConfig: RuleConfig[] = [ + { + name: 'stock', + geValue: 10 + }, + { + name: 'seckillPrice', + geValue: 0.01 + } +] +const message = useMessage() // 消息弹窗 +/** + * 获取所有 sku 秒杀配置 + */ +const getSkuConfigs = (): SeckillProductVO[] => { + if (!skuListRef.value.validateSku()) { + // TODO 作为通用组件是需要进一步完善 + message.warning('请检查商品相关属性配置!!') + throw new Error('请检查商品相关属性配置!!') + } + const seckillProducts: SeckillProductVO[] = [] + spuPropertyList.value.forEach((item) => { + item.spuDetail.skus.forEach((sku) => { + seckillProducts.push(sku.productConfig) + }) + }) + return seckillProducts +} +// 暴露出给表单提交时使用 +defineExpose({ getSkuConfigs }) +/** 商品图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + zIndex: 99999999, + urlList: [imgUrl] + }) +} +/** + * 将传进来的值赋值给 skuList + */ +watch( + () => props.spuList, + (data) => { + if (!data) return + spuData.value = data as SpuRespVO[] + getSpuDetails(spuData.value.map((spu) => spu.id!)) + }, + { + deep: true, + immediate: true + } +) +</script> diff --git a/src/views/mall/promotion/seckill/activity/components/SpuAndSkuSelectForm.vue b/src/views/mall/promotion/seckill/activity/components/SpuAndSkuSelectForm.vue new file mode 100644 index 00000000..c3de9a2b --- /dev/null +++ b/src/views/mall/promotion/seckill/activity/components/SpuAndSkuSelectForm.vue @@ -0,0 +1,272 @@ +<template> + <Dialog v-model="dialogVisible" :appendToBody="true" :title="dialogTitle" width="70%"> + <ContentWrap> + <el-row :gutter="20" class="mb-10px"> + <el-col :span="6"> + <el-input + v-model="queryParams.name" + class="!w-240px" + clearable + placeholder="请输入商品名称" + @keyup.enter="handleQuery" + /> + </el-col> + <el-col :span="6"> + <el-tree-select + v-model="queryParams.categoryId" + :data="categoryList" + :props="defaultProps" + check-strictly + class="w-1/1" + node-key="id" + placeholder="请选择商品分类" + @change="nodeClick" + /> + </el-col> + <el-col :span="6"> + <el-date-picker + v-model="queryParams.createTime" + :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]" + class="!w-240px" + end-placeholder="结束日期" + start-placeholder="开始日期" + type="daterange" + value-format="YYYY-MM-DD HH:mm:ss" + /> + </el-col> + <el-col :span="6"> + <el-button @click="handleQuery"> + <Icon class="mr-5px" icon="ep:search" /> + 搜索 + </el-button> + <el-button @click="resetQuery"> + <Icon class="mr-5px" icon="ep:refresh" /> + 重置 + </el-button> + </el-col> + </el-row> + <el-table + ref="spuListRef" + v-loading="loading" + :data="list" + :expand-row-keys="expandRowKeys" + row-key="id" + @expand-change="expandChange" + @selection-change="selectSpu" + > + <el-table-column v-if="isSelectSku" type="expand" width="30"> + <template #default> + <SkuList + v-if="isExpand" + :isComponent="true" + :isDetail="true" + :prop-form-data="spuData" + :property-list="propertyList" + @selection-change="selectSku" + /> + </template> + </el-table-column> + <el-table-column type="selection" width="55" /> + <el-table-column key="id" align="center" label="商品编号" prop="id" /> + <el-table-column label="商品图" min-width="80"> + <template #default="{ row }"> + <el-image :src="row.picUrl" class="w-30px h-30px" @click="imagePreview(row.picUrl)" /> + </template> + </el-table-column> + <el-table-column + :show-overflow-tooltip="true" + label="商品名称" + min-width="300" + prop="name" + /> + <el-table-column align="center" label="商品售价" min-width="90" prop="price"> + <template #default="{ row }"> + {{ formatToFraction(row.price) }} + </template> + </el-table-column> + <el-table-column align="center" label="销量" min-width="90" prop="salesCount" /> + <el-table-column align="center" label="库存" min-width="90" prop="stock" /> + <el-table-column align="center" label="排序" min-width="70" prop="sort" /> + <el-table-column + :formatter="dateFormatter" + align="center" + label="创建时间" + prop="createTime" + width="180" + /> + </el-table> + <!-- 分页 --> + <Pagination + v-model:limit="queryParams.pageSize" + v-model:page="queryParams.pageNo" + :total="total" + @pagination="getList" + /> + </ContentWrap> + <template #footer> + <el-button type="primary" @click="confirm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> + +<script lang="ts" name="SeckillActivitySpuAndSkuSelect" setup> +import { getPropertyList, Properties, SkuList } from '@/views/mall/product/spu/components' +import { ElTable } from 'element-plus' +import { dateFormatter } from '@/utils/formatTime' +import { createImageViewer } from '@/components/ImageViewer' +import { formatToFraction } from '@/utils' +import { checkSelectedNode, defaultProps, handleTree } from '@/utils/tree' + +import * as ProductCategoryApi from '@/api/mall/product/category' +import * as ProductSpuApi from '@/api/mall/product/spu' +import { propTypes } from '@/utils/propTypes' + +const props = defineProps({ + // 默认不需要(不需要的情况下只返回 spu,需要的情况下返回 选中的 spu 和 sku 列表) + // 其它活动需要选择商品和商品属性导入此组件即可,需添加组件属性 :isSelectSku='true' + isSelectSku: propTypes.bool.def(false) // 是否需要选择 sku 属性 +}) + +const message = useMessage() // 消息弹窗 +const total = ref(0) // 列表的总页数 +const list = ref<any[]>([]) // 列表的数据 +const loading = ref(false) // 列表的加载中 +const dialogVisible = ref(false) // 弹窗的是否展示 +const dialogTitle = ref('') // 弹窗的标题 +const queryParams = ref({ + pageNo: 1, + pageSize: 10, + tabType: 0, // 默认获取上架的商品 + name: '', + categoryId: null, + createTime: [] +}) // 查询参数 +const propertyList = ref<Properties[]>([]) // 商品属性列表 +const spuListRef = ref<InstanceType<typeof ElTable>>() +const spuData = ref<ProductSpuApi.Spu | {}>() // 商品详情 +const isExpand = ref(false) // 控制 SKU 列表显示 +const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。 +// 计算商品属性 +const expandChange = async (row: ProductSpuApi.Spu, expandedRows: ProductSpuApi.Spu[]) => { + spuData.value = {} + propertyList.value = [] + isExpand.value = false + // 如果展开个数为 0 + if (expandedRows.length === 0) { + expandRowKeys.value = [] + return + } + // 获取 SPU 详情 + const res = (await ProductSpuApi.getSpu(row.id as number)) as ProductSpuApi.SpuRespVO + propertyList.value = getPropertyList(res) + spuData.value = res + isExpand.value = true + expandRowKeys.value = [row.id!] +} + +//============ 商品选择相关 ============ +const selectedSpu = ref<ProductSpuApi.Spu>() // 选中的商品 spu 只能选择一个 +const selectedSku = ref<ProductSpuApi.Sku[]>() // 选中的商品 sku +const selectSku = (val: ProductSpuApi.Sku[]) => { + selectedSku.value = val +} +const selectSpu = (val: ProductSpuApi.Spu[]) => { + // 只选择一个 + selectedSpu.value = val[0] + // 如果大于1个 + if (val.length > 1) { + // 清空选择 + spuListRef.value.clearSelection() + // 变更为最后一次选择的 + spuListRef.value.toggleRowSelection(val.pop(), true) + } +} +// 确认选择时的触发事件 +const emits = defineEmits<{ + (e: 'confirm', value: ProductSpuApi.Spu, value1?: ProductSpuApi.Sku[]): void +}>() +/** + * 确认选择返回选中的 spu 和 sku (如果需要选择sku的话) + */ +const confirm = () => { + if (typeof selectedSpu.value === 'undefined') { + message.warning('没有选择任何商品') + return + } + if ( + (props.isSelectSku && typeof selectedSku.value === 'undefined') || + selectedSku.value?.length === 0 + ) { + message.warning('没有选择任何商品属性') + return + } + // TODO 返回选择 sku 没测试过,后续测试完善 + props.isSelectSku + ? emits('confirm', selectedSpu.value!, selectedSku.value!) + : emits('confirm', selectedSpu.value!) + // 关闭弹窗 + dialogVisible.value = false +} +/** 打开弹窗 TODO 没做国际化 */ +const open = (title: string) => { + dialogTitle.value = title + dialogVisible.value = true +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 查询列表 */ +const getList = async () => { + loading.value = true + try { + const data = await ProductSpuApi.getSpuPage(queryParams.value) + list.value = data.list + total.value = data.total + } finally { + loading.value = false + } +} +/** 搜索按钮操作 */ +const handleQuery = () => { + getList() +} + +/** 重置按钮操作 */ +const resetQuery = () => { + queryParams.value = { + pageNo: 1, + pageSize: 10, + tabType: 0, // 默认获取上架的商品 + name: '', + categoryId: null, + createTime: [] + } + getList() +} +/** 商品图预览 */ +const imagePreview = (imgUrl: string) => { + createImageViewer({ + zIndex: 99999999, + urlList: [imgUrl] + }) +} + +const categoryList = ref() // 分类树 + +/** + * 校验所选是否为二级及以下节点 + */ +const nodeClick = () => { + if (!checkSelectedNode(categoryList.value, queryParams.value.categoryId)) { + queryParams.value.categoryId = null + message.warning('必须选择二级及以下节点!!') + } +} +/** 初始化 **/ +onMounted(async () => { + await getList() + // 获得分类树 + const data = await ProductCategoryApi.getCategoryList({}) + categoryList.value = handleTree(data, 'id', 'parentId') +}) +</script> diff --git a/src/views/mall/promotion/seckill/activity/components/index.ts b/src/views/mall/promotion/seckill/activity/components/index.ts new file mode 100644 index 00000000..ef92a41a --- /dev/null +++ b/src/views/mall/promotion/seckill/activity/components/index.ts @@ -0,0 +1,4 @@ +import SpuAndSkuSelectForm from './SpuAndSkuSelectForm.vue' +import SpuAndSkuList from './SpuAndSkuList.vue' + +export { SpuAndSkuSelectForm, SpuAndSkuList } diff --git a/src/views/mall/promotion/seckill/activity/index.vue b/src/views/mall/promotion/seckill/activity/index.vue new file mode 100644 index 00000000..de9f71db --- /dev/null +++ b/src/views/mall/promotion/seckill/activity/index.vue @@ -0,0 +1,86 @@ +<template> + <!-- 搜索工作栏 --> + <ContentWrap> + <Search :schema="allSchemas.searchSchema" @reset="setSearchParams" @search="setSearchParams"> + <!-- 新增等操作按钮 --> + <template #actionMore> + <el-button + v-hasPermi="['promotion:seckill-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 #action="{ row }"> + <el-button + v-hasPermi="['promotion:seckill-activity:update']" + link + type="primary" + @click="openForm('update', row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['promotion:seckill-activity:delete']" + link + type="danger" + @click="handleDelete(row.id)" + > + 删除 + </el-button> + </template> + </Table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SeckillActivityForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" name="PromotionSeckillActivity" setup> +import { allSchemas } from './seckillActivity.data' +import * as SeckillActivityApi from '@/api/mall/promotion/seckill/seckillActivity' +import SeckillActivityForm from './SeckillActivityForm.vue' + +// tableObject:表格的属性对象,可获得分页大小、条数等属性 +// tableMethods:表格的操作对象,可进行获得分页、删除记录等操作 +// 详细可见:https://doc.iocoder.cn/vue3/crud-schema/ +const { tableObject, tableMethods } = useTable({ + getListApi: SeckillActivityApi.getSeckillActivityPage, // 分页接口 + delListApi: SeckillActivityApi.deleteSeckillActivity // 删除接口 +}) +// 获得表格的各种操作 +const { getList, setSearchParams } = tableMethods + +/** 添加/修改操作 */ +const formRef = ref() +const openForm = (type: string, id?: number) => { + formRef.value.open(type, id) +} + +/** 删除按钮操作 */ +const handleDelete = (id: number) => { + tableMethods.delList(id, false) +} + +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/promotion/seckill/activity/seckillActivity.data.ts b/src/views/mall/promotion/seckill/activity/seckillActivity.data.ts new file mode 100644 index 00000000..70524b91 --- /dev/null +++ b/src/views/mall/promotion/seckill/activity/seckillActivity.data.ts @@ -0,0 +1,261 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter } from '@/utils/formatTime' +import { getListAllSimple } from '@/api/mall/promotion/seckill/seckillConfig' + +// 表单校验 +export const rules = reactive({ + spuId: [required], + name: [required], + startTime: [required], + endTime: [required], + sort: [required], + configIds: [required], + totalLimitCount: [required], + singleLimitCount: [required], + totalStock: [required] +}) + +// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive<CrudSchema[]>([ + { + label: '秒杀活动名称', + field: 'name', + isSearch: true, + form: { + colProps: { + span: 24 + } + }, + table: { + width: 120 + } + }, + { + label: '活动开始时间', + field: 'startTime', + formatter: dateFormatter, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD HH:mm:ss', + type: 'daterange', + defaultTime: [new Date('1 00:00:00'), new Date('1 23:59:59')] + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 300 + } + }, + { + label: '活动结束时间', + field: 'endTime', + formatter: dateFormatter, + isSearch: true, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD HH:mm:ss', + type: 'daterange', + defaultTime: [new Date('1 00:00:00'), new Date('1 23:59:59')] + } + }, + form: { + component: 'DatePicker', + componentProps: { + type: 'date', + valueFormat: 'x' + } + }, + table: { + width: 300 + } + }, + { + label: '秒杀时段', + field: 'configIds', + form: { + component: 'Select', + componentProps: { + multiple: true, + optionsAlias: { + labelField: 'name', + valueField: 'id' + } + }, + api: getListAllSimple + }, + table: { + width: 300 + } + }, + { + label: '新增订单数', + field: 'orderCount', + isForm: false, + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 300 + } + }, + { + label: '付款人数', + field: 'userCount', + isForm: false, + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 300 + } + }, + { + label: '订单实付金额', + field: 'totalPrice', + isForm: false, + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 300 + } + }, + { + label: '总限购数量', + field: 'totalLimitCount', + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 300 + } + }, + { + label: '单次限够数量', + field: 'singleLimitCount', + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 300 + } + }, + { + label: '秒杀库存', + field: 'stock', + isForm: false, + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 300 + } + }, + { + label: '秒杀总库存', + field: 'totalStock', + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 300 + } + }, + { + label: '秒杀活动商品', + field: 'spuId', + form: { + colProps: { + span: 24 + } + }, + table: { + width: 200 + } + }, + { + label: '创建时间', + field: 'createTime', + formatter: dateFormatter, + search: { + component: 'DatePicker', + componentProps: { + valueFormat: 'YYYY-MM-DD HH:mm:ss', + type: 'daterange', + defaultTime: [new Date('1 00:00:00'), new Date('1 23:59:59')] + } + }, + isForm: false, + table: { + width: 300 + } + }, + { + label: '排序', + field: 'sort', + form: { + component: 'InputNumber', + value: 0 + }, + table: { + width: 300 + } + }, + { + label: '状态', + field: 'status', + dictType: DICT_TYPE.COMMON_STATUS, + dictClass: 'number', + isForm: false, + isSearch: true, + form: { + component: 'Radio' + }, + table: { + width: 80 + } + }, + { + label: '备注', + field: 'remark', + form: { + component: 'Input', + componentProps: { + type: 'textarea', + rows: 4 + }, + colProps: { + span: 24 + } + }, + table: { + width: 300 + } + }, + { + label: '操作', + field: 'action', + isForm: false, + table: { + width: 120, + fixed: 'right' + } + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas) diff --git a/src/views/mall/promotion/seckill/config/SeckillConfigForm.vue b/src/views/mall/promotion/seckill/config/SeckillConfigForm.vue new file mode 100644 index 00000000..e25f8fce --- /dev/null +++ b/src/views/mall/promotion/seckill/config/SeckillConfigForm.vue @@ -0,0 +1,66 @@ +<template> + <Dialog v-model="dialogVisible" :title="dialogTitle"> + <Form ref="formRef" v-loading="formLoading" :rules="rules" :schema="allSchemas.formSchema" /> + <template #footer> + <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button> + <el-button @click="dialogVisible = false">取 消</el-button> + </template> + </Dialog> +</template> +<script lang="ts" name="SeckillConfigForm" setup> +import * as SeckillConfigApi from '@/api/mall/promotion/seckill/seckillConfig' +import { allSchemas, rules } from './seckillConfig.data' + +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 open = async (type: string, id?: number) => { + dialogVisible.value = true + dialogTitle.value = t('action.' + type) + formType.value = type + // 修改时,设置数据 + if (id) { + formLoading.value = true + try { + const data = await SeckillConfigApi.getSeckillConfig(id) + formRef.value.setValues(data) + } finally { + formLoading.value = false + } + } +} +defineExpose({ open }) // 提供 open 方法,用于打开弹窗 + +/** 提交表单 */ +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 SeckillConfigApi.SeckillConfigVO + if (formType.value === 'create') { + await SeckillConfigApi.createSeckillConfig(data) + message.success(t('common.createSuccess')) + } else { + await SeckillConfigApi.updateSeckillConfig(data) + message.success(t('common.updateSuccess')) + } + dialogVisible.value = false + // 发送操作成功的事件 + emit('success') + } finally { + formLoading.value = false + } +} +</script> diff --git a/src/views/mall/promotion/seckill/config/index.vue b/src/views/mall/promotion/seckill/config/index.vue new file mode 100644 index 00000000..ac851a63 --- /dev/null +++ b/src/views/mall/promotion/seckill/config/index.vue @@ -0,0 +1,120 @@ +<template> + <!-- 搜索工作栏 --> + <ContentWrap> + <Search :schema="allSchemas.searchSchema" @reset="setSearchParams" @search="setSearchParams"> + <!-- 新增等操作按钮 --> + <template #actionMore> + <el-button + v-hasPermi="['promotion:seckill-config: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 #picUrl="{ row }"> + <el-image :src="row.picUrl" class="w-30px h-30px" @click="imagePreview(row.picUrl)" /> + </template> + <template #status="{ row }"> + <el-switch + v-model="row.status" + :active-value="0" + :inactive-value="1" + @change="handleStatusChange(row)" + /> + </template> + <template #action="{ row }"> + <el-button + v-hasPermi="['promotion:seckill-config:update']" + link + type="primary" + @click="openForm('update', row.id)" + > + 编辑 + </el-button> + <el-button + v-hasPermi="['promotion:seckill-config:delete']" + link + type="danger" + @click="handleDelete(row.id)" + > + 删除 + </el-button> + </template> + </Table> + </ContentWrap> + + <!-- 表单弹窗:添加/修改 --> + <SeckillConfigForm ref="formRef" @success="getList" /> +</template> +<script lang="ts" name="SeckillConfig" setup> +import { allSchemas } from './seckillConfig.data' +import * as SeckillConfigApi from '@/api/mall/promotion/seckill/seckillConfig' +import SeckillConfigForm from './SeckillConfigForm.vue' +import { createImageViewer } from '@/components/ImageViewer' +import { CommonStatusEnum } from '@/utils/constants' + +const message = useMessage() // 消息弹窗 +// tableObject:表格的属性对象,可获得分页大小、条数等属性 +// tableMethods:表格的操作对象,可进行获得分页、删除记录等操作 +// 详细可见:https://doc.iocoder.cn/vue3/crud-schema/ +const { tableObject, tableMethods } = useTable({ + getListApi: SeckillConfigApi.getSeckillConfigPage, // 分页接口 + delListApi: SeckillConfigApi.deleteSeckillConfig // 删除接口 +}) +// 获得表格的各种操作 +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) +} +/** 修改用户状态 */ +const handleStatusChange = async (row: SeckillConfigApi.SeckillConfigVO) => { + try { + // 修改状态的二次确认 + const text = row.status === CommonStatusEnum.ENABLE ? '启用' : '停用' + await message.confirm('确认要"' + text + '""' + row.name + '?') + // 发起修改状态 + await SeckillConfigApi.updateSeckillConfigStatus(row.id, row.status) + // 刷新列表 + await getList() + } catch { + // 取消后,进行恢复按钮 + row.status = + row.status === CommonStatusEnum.ENABLE ? CommonStatusEnum.DISABLE : CommonStatusEnum.ENABLE + } +} +/** 初始化 **/ +onMounted(() => { + getList() +}) +</script> diff --git a/src/views/mall/promotion/seckill/config/seckillConfig.data.ts b/src/views/mall/promotion/seckill/config/seckillConfig.data.ts new file mode 100644 index 00000000..49bf36af --- /dev/null +++ b/src/views/mall/promotion/seckill/config/seckillConfig.data.ts @@ -0,0 +1,79 @@ +import type { CrudSchema } from '@/hooks/web/useCrudSchemas' +import { dateFormatter } from '@/utils/formatTime' + +// 表单校验 +export const rules = reactive({ + name: [required], + startTime: [required], + endTime: [required], + picUrl: [required], + status: [required] +}) + +// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/ +const crudSchemas = reactive<CrudSchema[]>([ + { + label: '秒杀时段名称', + field: 'name', + isSearch: true + }, + { + label: '开始时间点', + field: 'startTime', + isSearch: false, + search: { + component: 'TimePicker' + }, + form: { + component: 'TimePicker', + componentProps: { + valueFormat: 'HH:mm:ss' + } + } + }, + { + label: '结束时间点', + field: 'endTime', + isSearch: false, + search: { + component: 'TimePicker' + }, + form: { + component: 'TimePicker', + componentProps: { + valueFormat: 'HH:mm:ss' + } + } + }, + { + label: '秒杀主图', + field: 'picUrl', + isSearch: false, + form: { + component: 'UploadImg' + } + }, + { + label: '状态', + field: 'status', + dictType: DICT_TYPE.COMMON_STATUS, + dictClass: 'number', + isSearch: true, + form: { + component: 'Radio' + } + }, + { + label: '创建时间', + field: 'createTime', + isForm: false, + isSearch: false, + formatter: dateFormatter + }, + { + label: '操作', + field: 'action', + isForm: false + } +]) +export const { allSchemas } = useCrudSchemas(crudSchemas)