浏览代码

【新增】商城: 积分商城

puhui999 10 月之前
父节点
当前提交
360cce10c7

+ 78 - 0
src/api/mall/promotion/point/index.ts

@@ -0,0 +1,78 @@
+import request from '@/config/axios'
+import { Sku, Spu } from '@/api/mall/product/spu'
+
+// 积分商城活动 VO
+export interface PointActivityVO {
+  id: number // 积分商城活动编号
+  spuId: number // 积分商城活动商品
+  status: number // 活动状态
+  remark?: string // 备注
+  sort: number // 排序
+  createTime: string // 创建时间
+  products: PointProductVO[] // 积分商城商品
+
+  // ========== 商品字段 ==========
+  spuName: string // 商品名称
+  picUrl: string // 商品主图
+  marketPrice: number // 商品市场价,单位:分
+
+  //======================= 显示所需兑换积分最少的 sku 信息 =======================
+  maxCount: number // 可兑换数量
+  point: number // 兑换积分
+  price: number // 兑换金额,单位:分
+}
+
+// 秒杀活动所需属性
+export interface PointProductVO {
+  id?: number // 积分商城商品编号
+  activityId?: number // 积分商城活动 id
+  spuId?: number // 商品 SPU 编号
+  skuId: number // 商品 SKU 编号
+  count: number // 可兑换数量
+  point: number // 兑换积分
+  price: number // 兑换金额,单位:分
+  stock: number // 积分商城商品库存
+  activityStatus?: number // 积分商城商品状态
+}
+
+// 扩展 Sku 配置
+export type SkuExtension = Sku & {
+  productConfig: PointProductVO
+}
+
+export interface SpuExtension extends Spu {
+  skus: SkuExtension[] // 重写类型
+}
+
+// 积分商城活动 API
+export const PointActivityApi = {
+  // 查询积分商城活动分页
+  getPointActivityPage: async (params: any) => {
+    return await request.get({ url: `/promotion/point-activity/page`, params })
+  },
+
+  // 查询积分商城活动详情
+  getPointActivity: async (id: number) => {
+    return await request.get({ url: `/promotion/point-activity/get?id=` + id })
+  },
+
+  // 新增积分商城活动
+  createPointActivity: async (data: PointActivityVO) => {
+    return await request.post({ url: `/promotion/point-activity/create`, data })
+  },
+
+  // 修改积分商城活动
+  updatePointActivity: async (data: PointActivityVO) => {
+    return await request.put({ url: `/promotion/point-activity/update`, data })
+  },
+
+  // 删除积分商城活动
+  deletePointActivity: async (id: number) => {
+    return await request.delete({ url: `/promotion/point-activity/delete?id=` + id })
+  },
+
+  // 关闭秒杀活动
+  closePointActivity: async (id: number) => {
+    return await request.put({ url: '/promotion/point-activity/close?id=' + id })
+  }
+}

+ 219 - 0
src/views/mall/promotion/point/activity/PointActivityForm.vue

@@ -0,0 +1,219 @@
+<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="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.count" :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.point" :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.price"
+                :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 { SpuAndSkuList, SpuProperty, SpuSelect } from '../../components'
+import { allSchemas, rules } from './pointActivity.data'
+import { cloneDeep } from 'lodash-es'
+import {
+  PointActivityApi,
+  PointActivityVO,
+  PointProductVO,
+  SkuExtension,
+  SpuExtension
+} from '@/api/mall/promotion/point'
+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' })
+
+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 ruleConfig: RuleConfig[] = [
+  {
+    name: 'productConfig.stock',
+    rule: (arg) => arg >= 1,
+    message: '商品可兑换库存必须大于等于 1 !!!'
+  },
+  {
+    name: 'productConfig.point',
+    rule: (arg) => arg >= 1,
+    message: '商品所需兑换积分必须大于等于 1 !!!'
+  },
+  {
+    name: 'productConfig.count',
+    rule: (arg) => arg >= 1,
+    message: '商品可兑换次数必须大于等于 1 !!!'
+  }
+]
+const spuList = ref<SpuExtension[]>([]) // 选择的 spu
+const spuPropertyList = ref<SpuProperty<SpuExtension>[]>([])
+const selectSpu = (spuId: number, skuIds: number[]) => {
+  formRef.value.setValues({ spuId })
+  getSpuDetails(spuId, skuIds)
+}
+/**
+ * 获取 SPU 详情
+ */
+const getSpuDetails = async (
+  spuId: number,
+  skuIds: number[] | undefined,
+  products?: PointProductVO[]
+) => {
+  const spuProperties: SpuProperty<SpuExtension>[] = []
+  const res = (await ProductSpuApi.getSpuDetailList([spuId])) as 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: PointProductVO = {
+      skuId: sku.id!,
+      stock: 0,
+      price: 0,
+      point: 0,
+      count: 0
+    }
+    if (typeof products !== 'undefined') {
+      const product = products.find((item) => item.skuId === sku.id)
+      if (product) {
+        product.price = formatToFraction(product.price) as any
+      }
+      config = product || config
+    }
+    sku.productConfig = config
+  })
+  spu.skus = selectSkus as 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 PointActivityApi.getPointActivity(id)) as PointActivityVO
+      await getSpuDetails(
+        data.spuId!,
+        data.products?.map((sku) => sku.skuId),
+        data.products
+      )
+      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 products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig'))
+    products.forEach((item: PointProductVO) => {
+      item.price = convertToInteger(item.price)
+    })
+    const data = formRef.value.formModel as PointActivityVO
+    data.products = products
+    // 真正提交
+    if (formType.value === 'create') {
+      await PointActivityApi.createPointActivity(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await PointActivityApi.updatePointActivity(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = async () => {
+  spuList.value = []
+  spuPropertyList.value = []
+  await nextTick()
+  formRef.value.getElFormRef().resetFields()
+}
+</script>

+ 223 - 0
src/views/mall/promotion/point/activity/index.vue

@@ -0,0 +1,223 @@
+<template>
+  <doc-alert title="【营销】积分商城活动" url="https://doc.iocoder.cn/mall/promotion-point/" />
+
+  <ContentWrap>
+    <!-- 搜索工作栏 -->
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <el-form-item label="活动名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          class="!w-240px"
+          clearable
+          placeholder="请输入活动名称"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="活动状态" prop="status">
+        <el-select
+          v-model="queryParams.status"
+          class="!w-240px"
+          clearable
+          placeholder="请选择活动状态"
+        >
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item>
+        <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-button
+          v-hasPermi="['promotion:point-activity:create']"
+          plain
+          type="primary"
+          @click="openForm('create')"
+        >
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column label="活动编号" min-width="80" prop="id" />
+      <el-table-column label="商品图片" min-width="80" prop="spuName">
+        <template #default="scope">
+          <el-image
+            :preview-src-list="[scope.row.picUrl]"
+            :src="scope.row.picUrl"
+            class="h-40px w-40px"
+            preview-teleported
+          />
+        </template>
+      </el-table-column>
+      <el-table-column label="商品标题" min-width="300" prop="spuName" />
+      <el-table-column
+        :formatter="fenToYuanFormat"
+        label="原价"
+        min-width="100"
+        prop="marketPrice"
+      />
+      <el-table-column label="原价" min-width="100" prop="marketPrice" />
+      <el-table-column align="center" label="活动状态" min-width="100" prop="status">
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="库存" min-width="80" prop="stock" />
+      <el-table-column align="center" label="总库存" min-width="80" prop="totalStock" />
+      <el-table-column label="已兑换数量" min-width="80" prop="redeemedQuantity" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180px"
+      />
+      <el-table-column align="center" fixed="right" label="操作" width="150px">
+        <template #default="scope">
+          <el-button
+            v-hasPermi="['promotion:point-activity:update']"
+            link
+            type="primary"
+            @click="openForm('update', scope.row.id)"
+          >
+            编辑
+          </el-button>
+          <el-button
+            v-if="scope.row.status === 0"
+            v-hasPermi="['promotion:point-activity:close']"
+            link
+            type="danger"
+            @click="handleClose(scope.row.id)"
+          >
+            关闭
+          </el-button>
+          <el-button
+            v-else
+            v-hasPermi="['promotion:point-activity:delete']"
+            link
+            type="danger"
+            @click="handleDelete(scope.row.id)"
+          >
+            删除
+          </el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <PointActivityForm ref="formRef" @success="getList" />
+</template>
+
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+import PointActivityForm from './PointActivityForm.vue'
+import { fenToYuanFormat } from '@/utils/formatter'
+import { PointActivityApi } from '@/api/mall/promotion/point'
+
+defineOptions({ name: 'PointActivity' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  status: null
+})
+const queryFormRef = ref() // 搜索的表单
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await PointActivityApi.getPointActivityPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 关闭按钮操作 */
+const handleClose = async (id: number) => {
+  try {
+    // 关闭的二次确认
+    await message.confirm('确认关闭该积分商城活动吗?')
+    // 发起关闭
+    await PointActivityApi.closePointActivity(id)
+    message.success('关闭成功')
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await PointActivityApi.deletePointActivity(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 初始化 **/
+onMounted(async () => {
+  await getList()
+})
+</script>

+ 55 - 0
src/views/mall/promotion/point/activity/pointActivity.data.ts

@@ -0,0 +1,55 @@
+import type { CrudSchema } from '@/hooks/web/useCrudSchemas'
+
+// 表单校验
+export const rules = reactive({
+  spuId: [required],
+  sort: [required]
+})
+
+// CrudSchema https://doc.iocoder.cn/vue3/crud-schema/
+const crudSchemas = reactive<CrudSchema[]>([
+  {
+    label: '排序',
+    field: 'sort',
+    form: {
+      component: 'InputNumber',
+      value: 0
+    },
+    table: {
+      width: 80
+    }
+  },
+  {
+    label: '积分商城活动商品',
+    field: 'spuId',
+    isTable: true,
+    isSearch: false,
+    form: {
+      colProps: {
+        span: 24
+      }
+    },
+    table: {
+      width: 300
+    }
+  },
+  {
+    label: '备注',
+    field: 'remark',
+    isSearch: false,
+    form: {
+      component: 'Input',
+      componentProps: {
+        type: 'textarea',
+        rows: 4
+      },
+      colProps: {
+        span: 24
+      }
+    },
+    table: {
+      width: 300
+    }
+  }
+])
+export const { allSchemas } = useCrudSchemas(crudSchemas)