Sfoglia il codice sorgente

Merge branch 'dev' of https://gitee.com/yudaocode/yudao-ui-admin-vue3

YunaiV 1 anno fa
parent
commit
887936ccf0
47 ha cambiato i file con 2530 aggiunte e 290 eliminazioni
  1. 0 2
      .env.dev
  2. 0 2
      .env.local
  3. 0 2
      .env.prod
  4. 0 2
      .env.stage
  5. 0 2
      .env.test
  6. 1 1
      .vscode/settings.json
  7. 6 1
      src/api/login/index.ts
  8. 7 0
      src/api/login/types.ts
  9. 91 0
      src/api/mall/promotion/point/index.ts
  10. 6 1
      src/api/mall/promotion/reward/rewardActivity.ts
  11. 8 2
      src/api/pay/order/index.ts
  12. 8 0
      src/components/AppLinkInput/data.ts
  13. 1 1
      src/components/DiyEditor/components/mobile/ProductCard/index.vue
  14. 96 0
      src/components/DiyEditor/components/mobile/PromotionPoint/config.ts
  15. 202 0
      src/components/DiyEditor/components/mobile/PromotionPoint/index.vue
  16. 154 0
      src/components/DiyEditor/components/mobile/PromotionPoint/property.vue
  17. 3 2
      src/components/Editor/src/Editor.vue
  18. 9 14
      src/components/UploadFile/src/UploadImgs.vue
  19. 20 11
      src/components/UploadFile/src/useUpload.ts
  20. 0 1
      src/utils/dict.ts
  21. 251 114
      src/views/Login/components/RegisterForm.vue
  22. 151 0
      src/views/knowledge/dataset-form/form-step1.vue
  23. 168 0
      src/views/knowledge/dataset-form/form-step2.vue
  24. 152 0
      src/views/knowledge/dataset.vue
  25. 1 1
      src/views/mall/product/property/value/index.vue
  26. 5 5
      src/views/mall/product/spu/components/SkuList.vue
  27. 1 1
      src/views/mall/product/spu/form/InfoForm.vue
  28. 44 40
      src/views/mall/promotion/combination/activity/index.vue
  29. 9 9
      src/views/mall/promotion/components/SpuAndSkuList.vue
  30. 2 1
      src/views/mall/promotion/coupon/components/CouponSelect.vue
  31. 18 3
      src/views/mall/promotion/coupon/formatter.ts
  32. 4 1
      src/views/mall/promotion/coupon/template/CouponTemplateForm.vue
  33. 7 1
      src/views/mall/promotion/coupon/template/index.vue
  34. 69 33
      src/views/mall/promotion/discountActivity/DiscountActivityForm.vue
  35. 11 2
      src/views/mall/promotion/discountActivity/discountActivity.data.ts
  36. 227 0
      src/views/mall/promotion/point/activity/PointActivityForm.vue
  37. 219 0
      src/views/mall/promotion/point/activity/index.vue
  38. 55 0
      src/views/mall/promotion/point/activity/pointActivity.data.ts
  39. 154 0
      src/views/mall/promotion/point/components/PointShowcase.vue
  40. 300 0
      src/views/mall/promotion/point/components/PointTableSelect.vue
  41. 8 2
      src/views/mall/promotion/rewardActivity/RewardForm.vue
  42. 12 1
      src/views/mall/promotion/rewardActivity/components/RewardRule.vue
  43. 32 5
      src/views/mall/promotion/rewardActivity/index.vue
  44. 11 0
      src/views/mp/components/wx-account-select/main.vue
  45. 6 25
      src/views/mp/statistics/index.vue
  46. 1 1
      src/views/pay/cashier/index.vue
  47. 0 1
      types/env.d.ts

+ 0 - 2
.env.dev

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://api-dashboard.yudao.iocoder.cn/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.local

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://localhost:48080'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.prod

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://localhost:48080'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.stage

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://api-dashboard.yudao.iocoder.cn/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 0 - 2
.env.test

@@ -8,8 +8,6 @@ VITE_BASE_URL='http://localhost:48080'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持S3服务
 VITE_UPLOAD_TYPE=server
-# 上传路径
-VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
 # 接口地址
 VITE_API_URL=/admin-api

+ 1 - 1
.vscode/settings.json

@@ -87,7 +87,7 @@
     "source.fixAll.stylelint": "explicit"
   },
   "[vue]": {
-    "editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
+    "editor.defaultFormatter": "esbenp.prettier-vscode"
   },
   "i18n-ally.localesPaths": ["src/locales"],
   "i18n-ally.keystyle": "nested",

+ 6 - 1
src/api/login/index.ts

@@ -1,6 +1,6 @@
 import request from '@/config/axios'
 import { getRefreshToken } from '@/utils/auth'
-import type { UserLoginVO } from './types'
+import type { RegisterVO, UserLoginVO } from './types'
 
 export interface SmsCodeVO {
   mobile: string
@@ -17,6 +17,11 @@ export const login = (data: UserLoginVO) => {
   return request.post({ url: '/system/auth/login', data })
 }
 
+// 注册
+export const register = (data: RegisterVO) => {
+  return request.post({ url: '/system/auth/register', data })
+}
+
 // 刷新访问令牌
 export const refreshToken = () => {
   return request.post({ url: '/system/auth/refresh-token?refreshToken=' + getRefreshToken() })

+ 7 - 0
src/api/login/types.ts

@@ -29,3 +29,10 @@ export type UserVO = {
   loginIp: string
   loginDate: string
 }
+
+export type RegisterVO = {
+  tenantName: string
+  username: string
+  password: string
+  captchaVerification: string
+}

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

@@ -0,0 +1,91 @@
+import request from '@/config/axios'
+import { Sku, Spu } from '@/api/mall/product/spu' // 积分商城活动 VO
+
+// 积分商城活动 VO
+export interface PointActivityVO {
+  id: number // 积分商城活动编号
+  spuId: number // 积分商城活动商品
+  status: number // 活动状态
+  stock: number // 积分商城活动库存
+  totalStock: number // 积分商城活动总库存
+  remark?: string // 备注
+  sort: number // 排序
+  createTime: string // 创建时间
+  products: PointProductVO[] // 积分商城商品
+
+  // ========== 商品字段 ==========
+  spuName: string // 商品名称
+  picUrl: string // 商品主图
+  marketPrice: number // 商品市场价,单位:分
+
+  //======================= 显示所需兑换积分最少的 sku 信息 =======================
+  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[] // 重写类型
+}
+
+export interface SpuExtension0 extends Spu {
+  pointStock: number // 积分商城活动库存
+  pointTotalStock: number // 积分商城活动总库存
+  point: number // 兑换积分
+  pointPrice: number // 兑换金额,单位:分
+}
+
+// 积分商城活动 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 })
+  },
+
+  // 查询积分商城活动列表,基于活动编号数组
+  getPointActivityListByIds: async (ids: number[]) => {
+    return request.get({ url: `/promotion/point-activity/list-by-ids?ids=${ids}` })
+  },
+
+  // 新增积分商城活动
+  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 })
+  }
+}

+ 6 - 1
src/api/mall/promotion/reward/rewardActivity.ts

@@ -47,7 +47,12 @@ export const getReward = async (id: number) => {
   return await request.get({ url: '/promotion/reward-activity/get?id=' + id })
 }
 
-// 删除限时折扣活动
+// 删除满减送活动
 export const deleteRewardActivity = async (id: number) => {
   return await request.delete({ url: '/promotion/reward-activity/delete?id=' + id })
 }
+
+// 关闭满减送活动
+export const closeRewardActivity = async (id: number) => {
+  return await request.put({ url: '/promotion/reward-activity/close?id=' + id })
+}

+ 8 - 2
src/api/pay/order/index.ts

@@ -84,8 +84,14 @@ export const getOrderPage = async (params: OrderPageReqVO) => {
 }
 
 // 查询详情支付订单
-export const getOrder = async (id: number) => {
-  return await request.get({ url: '/pay/order/get?id=' + id })
+export const getOrder = async (id: number, sync?: boolean) => {
+  return await request.get({
+    url: '/pay/order/get',
+    params: {
+      id,
+      sync
+    }
+  })
 }
 
 // 获得支付订单的明细

+ 8 - 0
src/components/AppLinkInput/data.ts

@@ -5,6 +5,7 @@ export interface AppLinkGroup {
   // 链接列表
   links: AppLink[]
 }
+
 // APP 链接
 export interface AppLink {
   // 链接名称
@@ -21,6 +22,8 @@ export const enum APP_LINK_TYPE_ENUM {
   ACTIVITY_COMBINATION,
   // 秒杀活动
   ACTIVITY_SECKILL,
+  // 积分商城活动
+  ACTIVITY_POINT,
   // 文章详情
   ARTICLE_DETAIL,
   // 优惠券详情
@@ -131,6 +134,11 @@ export const APP_LINK_GROUP_LIST = [
         type: APP_LINK_TYPE_ENUM.ACTIVITY_SECKILL
       },
       {
+        name: '积分商城活动',
+        path: '/pages/activity/point/list',
+        type: APP_LINK_TYPE_ENUM.ACTIVITY_POINT
+      },
+      {
         name: '签到中心',
         path: '/pages/app/sign'
       },

+ 1 - 1
src/components/DiyEditor/components/mobile/ProductCard/index.vue

@@ -67,7 +67,7 @@
             class="text-16px"
             :style="{ color: property.fields.price.color }"
           >
-            ¥{{ fenToYuan(spu.price) }}
+            ¥{{ fenToYuan(spu.price as any) }}
           </span>
           <!-- 市场价 -->
           <span

+ 96 - 0
src/components/DiyEditor/components/mobile/PromotionPoint/config.ts

@@ -0,0 +1,96 @@
+import {ComponentStyle, DiyComponent} from '@/components/DiyEditor/util'
+
+/** 积分商城属性 */
+export interface PromotionPointProperty {
+  // 布局类型:单列 | 三列
+  layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol'
+  // 商品字段
+  fields: {
+    // 商品名称
+    name: PromotionPointFieldProperty
+    // 商品简介
+    introduction: PromotionPointFieldProperty
+    // 商品价格
+    price: PromotionPointFieldProperty
+    // 市场价
+    marketPrice: PromotionPointFieldProperty
+    // 商品销量
+    salesCount: PromotionPointFieldProperty
+    // 商品库存
+    stock: PromotionPointFieldProperty
+  }
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标图片
+    imgUrl: string
+  }
+  // 按钮
+  btnBuy: {
+    // 类型:文字 | 图片
+    type: 'text' | 'img'
+    // 文字
+    text: string
+    // 文字按钮:背景渐变起始颜色
+    bgBeginColor: string
+    // 文字按钮:背景渐变结束颜色
+    bgEndColor: string
+    // 图片按钮:图片地址
+    imgUrl: string
+  }
+  // 上圆角
+  borderRadiusTop: number
+  // 下圆角
+  borderRadiusBottom: number
+  // 间距
+  space: number
+  // 秒杀活动编号
+  activityIds: number[]
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 商品字段
+export interface PromotionPointFieldProperty {
+  // 是否显示
+  show: boolean
+  // 颜色
+  color: string
+}
+
+// 定义组件
+export const component = {
+  id: 'PromotionPoint',
+  name: '积分商城',
+  icon: 'ep:present',
+  property: {
+    layoutType: 'oneColBigImg',
+    fields: {
+      name: { show: true, color: '#000' },
+      introduction: { show: true, color: '#999' },
+      price: { show: true, color: '#ff3000' },
+      marketPrice: { show: true, color: '#c4c4c4' },
+      salesCount: { show: true, color: '#c4c4c4' },
+      stock: { show: false, color: '#c4c4c4' }
+    },
+    badge: { show: false, imgUrl: '' },
+    btnBuy: {
+      type: 'text',
+      text: '立即兑换',
+      bgBeginColor: '#FF6000',
+      bgEndColor: '#FE832A',
+      imgUrl: ''
+    },
+    borderRadiusTop: 8,
+    borderRadiusBottom: 8,
+    space: 8,
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<PromotionPointProperty>

+ 202 - 0
src/components/DiyEditor/components/mobile/PromotionPoint/index.vue

@@ -0,0 +1,202 @@
+<template>
+  <div ref="containerRef" :class="`box-content min-h-30px w-full flex flex-row flex-wrap`">
+    <div
+      v-for="(spu, index) in spuList"
+      :key="index"
+      :style="{
+        ...calculateSpace(index),
+        ...calculateWidth(),
+        borderTopLeftRadius: `${property.borderRadiusTop}px`,
+        borderTopRightRadius: `${property.borderRadiusTop}px`,
+        borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
+        borderBottomRightRadius: `${property.borderRadiusBottom}px`
+      }"
+      class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
+    >
+      <!-- 角标 -->
+      <div v-if="property.badge.show" class="absolute left-0 top-0 z-1 items-center justify-center">
+        <el-image :src="property.badge.imgUrl" class="h-26px w-38px" fit="cover" />
+      </div>
+      <!-- 商品封面图 -->
+      <div
+        :class="[
+          'h-140px',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-140px': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
+      >
+        <el-image :src="spu.picUrl" class="h-full w-full" fit="cover" />
+      </div>
+      <div
+        :class="[
+          ' flex flex-col gap-8px p-8px box-border',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-[calc(100%-140px-16px)]': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
+      >
+        <!-- 商品名称 -->
+        <div
+          v-if="property.fields.name.show"
+          :class="[
+            'text-14px ',
+            {
+              truncate: property.layoutType !== 'oneColSmallImg',
+              'overflow-ellipsis line-clamp-2': property.layoutType === 'oneColSmallImg'
+            }
+          ]"
+          :style="{ color: property.fields.name.color }"
+        >
+          {{ spu.name }}
+        </div>
+        <!-- 商品简介 -->
+        <div
+          v-if="property.fields.introduction.show"
+          :style="{ color: property.fields.introduction.color }"
+          class="truncate text-12px"
+        >
+          {{ spu.introduction }}
+        </div>
+        <div>
+          <!-- 积分 -->
+          <span
+            v-if="property.fields.price.show"
+            :style="{ color: property.fields.price.color }"
+            class="text-16px"
+          >
+            {{ spu.point }}积分
+            {{ !spu.pointPrice || spu.pointPrice === 0 ? '' : `+${fenToYuan(spu.pointPrice)}元` }}
+          </span>
+          <!-- 市场价 -->
+          <span
+            v-if="property.fields.marketPrice.show && spu.marketPrice"
+            :style="{ color: property.fields.marketPrice.color }"
+            class="ml-4px text-10px line-through"
+          >
+            ¥{{ fenToYuan(spu.marketPrice) }}
+          </span>
+        </div>
+        <div class="text-12px">
+          <!-- 销量 -->
+          <span
+            v-if="property.fields.salesCount.show"
+            :style="{ color: property.fields.salesCount.color }"
+          >
+            已兑{{ (spu.pointTotalStock || 0) - (spu.pointStock || 0) }}件
+          </span>
+          <!-- 库存 -->
+          <span v-if="property.fields.stock.show" :style="{ color: property.fields.stock.color }">
+            库存{{ spu.pointTotalStock || 0 }}
+          </span>
+        </div>
+      </div>
+      <!-- 购买按钮 -->
+      <div class="absolute bottom-8px right-8px">
+        <!-- 文字按钮 -->
+        <span
+          v-if="property.btnBuy.type === 'text'"
+          :style="{
+            background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`
+          }"
+          class="rounded-full p-x-12px p-y-4px text-12px text-white"
+        >
+          {{ property.btnBuy.text }}
+        </span>
+        <!-- 图片按钮 -->
+        <el-image
+          v-else
+          :src="property.btnBuy.imgUrl"
+          class="h-28px w-28px rounded-full"
+          fit="cover"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+<script lang="ts" setup>
+import { PromotionPointProperty } from './config'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import { PointActivityApi, PointActivityVO, SpuExtension0 } from '@/api/mall/promotion/point'
+import { fenToYuan } from '@/utils'
+
+/** 积分商城卡片 */
+defineOptions({ name: 'PromotionPoint' })
+// 定义属性
+const props = defineProps<{ property: PromotionPointProperty }>()
+// 商品列表
+const spuList = ref<SpuExtension0[]>([])
+const spuIdList = ref<number[]>([])
+const pointActivityList = ref<PointActivityVO[]>([])
+
+watch(
+  () => props.property.activityIds,
+  async () => {
+    try {
+      // 新添加的积分商城组件,是没有活动ID的
+      const activityIds = props.property.activityIds
+      // 检查活动ID的有效性
+      if (Array.isArray(activityIds) && activityIds.length > 0) {
+        // 获取积分商城活动详情列表
+        pointActivityList.value = await PointActivityApi.getPointActivityListByIds(activityIds)
+
+        // 获取积分商城活动的 SPU 详情列表
+        spuList.value = []
+        spuIdList.value = pointActivityList.value.map((activity) => activity.spuId)
+        if (spuIdList.value.length > 0) {
+          spuList.value = await ProductSpuApi.getSpuDetailList(spuIdList.value)
+        }
+
+        // 更新 SPU 的最低兑换积分和所需兑换金额
+        pointActivityList.value.forEach((activity) => {
+          // 匹配spuId
+          const spu = spuList.value.find((spu) => spu.id === activity.spuId)
+          if (spu) {
+            spu.pointStock = activity.stock
+            spu.pointTotalStock = activity.totalStock
+            spu.point = activity.point
+            spu.pointPrice = activity.price
+          }
+        })
+      }
+    } catch (error) {
+      console.error('获取积分商城活动细节或 SPU 细节时出错:', error)
+    }
+  },
+  {
+    immediate: true,
+    deep: true
+  }
+)
+
+/**
+ * 计算商品的间距
+ * @param index 商品索引
+ */
+const calculateSpace = (index: number) => {
+  // 商品的列数
+  const columns = props.property.layoutType === 'twoCol' ? 2 : 1
+  // 第一列没有左边距
+  const marginLeft = index % columns === 0 ? '0' : props.property.space + 'px'
+  // 第一行没有上边距
+  const marginTop = index < columns ? '0' : props.property.space + 'px'
+
+  return { marginLeft, marginTop }
+}
+
+// 容器
+const containerRef = ref()
+// 计算商品的宽度
+const calculateWidth = () => {
+  let width = '100%'
+  // 双列时每列的宽度为:(总宽度 - 间距)/ 2
+  if (props.property.layoutType === 'twoCol') {
+    width = `${(containerRef.value.offsetWidth - props.property.space) / 2}px`
+  }
+  return { width }
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 154 - 0
src/components/DiyEditor/components/mobile/PromotionPoint/property.vue

@@ -0,0 +1,154 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-form :model="formData" label-width="80px">
+      <el-card class="property-group" header="积分商城活动" shadow="never">
+        <PointShowcase v-model="formData.activityIds" />
+      </el-card>
+      <el-card class="property-group" header="商品样式" shadow="never">
+        <el-form-item label="布局" prop="type">
+          <el-radio-group v-model="formData.layoutType">
+            <el-tooltip class="item" content="单列大图" placement="bottom">
+              <el-radio-button value="oneColBigImg">
+                <Icon icon="fluent:text-column-one-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="单列小图" placement="bottom">
+              <el-radio-button value="oneColSmallImg">
+                <Icon icon="fluent:text-column-two-left-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="双列" placement="bottom">
+              <el-radio-button value="twoCol">
+                <Icon icon="fluent:text-column-two-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <!--<el-tooltip class="item" content="三列" placement="bottom">
+              <el-radio-button value="threeCol">
+                <Icon icon="fluent:text-column-three-24-filled" />
+              </el-radio-button>
+            </el-tooltip>-->
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="商品名称" prop="fields.name.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.name.color" />
+            <el-checkbox v-model="formData.fields.name.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品简介" prop="fields.introduction.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.introduction.color" />
+            <el-checkbox v-model="formData.fields.introduction.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品价格" prop="fields.price.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.price.color" />
+            <el-checkbox v-model="formData.fields.price.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="市场价" prop="fields.marketPrice.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.marketPrice.color" />
+            <el-checkbox v-model="formData.fields.marketPrice.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品销量" prop="fields.salesCount.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.salesCount.color" />
+            <el-checkbox v-model="formData.fields.salesCount.show" />
+          </div>
+        </el-form-item>
+        <el-form-item label="商品库存" prop="fields.stock.show">
+          <div class="flex gap-8px">
+            <ColorInput v-model="formData.fields.stock.color" />
+            <el-checkbox v-model="formData.fields.stock.show" />
+          </div>
+        </el-form-item>
+      </el-card>
+      <el-card class="property-group" header="角标" shadow="never">
+        <el-form-item label="角标" prop="badge.show">
+          <el-switch v-model="formData.badge.show" />
+        </el-form-item>
+        <el-form-item v-if="formData.badge.show" label="角标" prop="badge.imgUrl">
+          <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
+            <template #tip> 建议尺寸:36 * 22</template>
+          </UploadImg>
+        </el-form-item>
+      </el-card>
+      <el-card class="property-group" header="按钮" shadow="never">
+        <el-form-item label="按钮类型" prop="btnBuy.type">
+          <el-radio-group v-model="formData.btnBuy.type">
+            <el-radio-button value="text">文字</el-radio-button>
+            <el-radio-button value="img">图片</el-radio-button>
+          </el-radio-group>
+        </el-form-item>
+        <template v-if="formData.btnBuy.type === 'text'">
+          <el-form-item label="按钮文字" prop="btnBuy.text">
+            <el-input v-model="formData.btnBuy.text" />
+          </el-form-item>
+          <el-form-item label="左侧背景" prop="btnBuy.bgBeginColor">
+            <ColorInput v-model="formData.btnBuy.bgBeginColor" />
+          </el-form-item>
+          <el-form-item label="右侧背景" prop="btnBuy.bgEndColor">
+            <ColorInput v-model="formData.btnBuy.bgEndColor" />
+          </el-form-item>
+        </template>
+        <template v-else>
+          <el-form-item label="图片" prop="btnBuy.imgUrl">
+            <UploadImg v-model="formData.btnBuy.imgUrl" height="56px" width="56px">
+              <template #tip> 建议尺寸:56 * 56</template>
+            </UploadImg>
+          </el-form-item>
+        </template>
+      </el-card>
+      <el-card class="property-group" header="商品样式" shadow="never">
+        <el-form-item label="上圆角" prop="borderRadiusTop">
+          <el-slider
+            v-model="formData.borderRadiusTop"
+            :max="100"
+            :min="0"
+            :show-input-controls="false"
+            input-size="small"
+            show-input
+          />
+        </el-form-item>
+        <el-form-item label="下圆角" prop="borderRadiusBottom">
+          <el-slider
+            v-model="formData.borderRadiusBottom"
+            :max="100"
+            :min="0"
+            :show-input-controls="false"
+            input-size="small"
+            show-input
+          />
+        </el-form-item>
+        <el-form-item label="间隔" prop="space">
+          <el-slider
+            v-model="formData.space"
+            :max="100"
+            :min="0"
+            :show-input-controls="false"
+            input-size="small"
+            show-input
+          />
+        </el-form-item>
+      </el-card>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script lang="ts" setup>
+import { PromotionPointProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import PointShowcase from '@/views/mall/promotion/point/components/PointShowcase.vue'
+
+// 秒杀属性面板
+defineOptions({ name: 'PromotionPointProperty' })
+
+const props = defineProps<{ modelValue: PromotionPointProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style lang="scss" scoped></style>

+ 3 - 2
src/components/Editor/src/Editor.vue

@@ -7,6 +7,7 @@ import { isNumber } from '@/utils/is'
 import { ElMessage } from 'element-plus'
 import { useLocaleStore } from '@/store/modules/locale'
 import { getAccessToken, getTenantId } from '@/utils/auth'
+import { getUploadUrl } from '@/components/UploadFile/src/useUpload'
 
 defineOptions({ name: 'Editor' })
 
@@ -88,7 +89,7 @@ const editorConfig = computed((): IEditorConfig => {
       scroll: true,
       MENU_CONF: {
         ['uploadImage']: {
-          server: import.meta.env.VITE_UPLOAD_URL,
+          server: getUploadUrl(),
           // 单个文件的最大体积限制,默认为 2M
           maxFileSize: 5 * 1024 * 1024,
           // 最多可上传几个文件,默认为 100
@@ -136,7 +137,7 @@ const editorConfig = computed((): IEditorConfig => {
           }
         },
         ['uploadVideo']: {
-          server: import.meta.env.VITE_UPLOAD_URL,
+          server: getUploadUrl(),
           // 单个文件的最大体积限制,默认为 10M
           maxFileSize: 10 * 1024 * 1024,
           // 最多可上传几个文件,默认为 100

+ 9 - 14
src/components/UploadFile/src/UploadImgs.vue

@@ -25,7 +25,7 @@
       <template #file="{ file }">
         <img :src="file.url" class="upload-image" />
         <div class="upload-handle" @click.stop>
-          <div class="handle-icon" @click="handlePictureCardPreview(file)">
+          <div class="handle-icon" @click="imagePreview(file.url!)">
             <Icon icon="ep:zoom-in" />
             <span>查看</span>
           </div>
@@ -39,16 +39,12 @@
     <div class="el-upload__tip">
       <slot name="tip"></slot>
     </div>
-    <el-image-viewer
-      v-if="imgViewVisible"
-      :url-list="[viewImageUrl]"
-      @close="imgViewVisible = false"
-    />
   </div>
 </template>
 <script lang="ts" setup>
 import type { UploadFile, UploadProps, UploadUserFile } from 'element-plus'
 import { ElNotification } from 'element-plus'
+import { createImageViewer } from '@/components/ImageViewer'
 
 import { propTypes } from '@/utils/propTypes'
 import { useUpload } from '@/components/UploadFile/src/useUpload'
@@ -56,6 +52,13 @@ import { useUpload } from '@/components/UploadFile/src/useUpload'
 defineOptions({ name: 'UploadImgs' })
 
 const message = useMessage() // 消息弹窗
+// 查看图片
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    zIndex: 9999999,
+    urlList: [imgUrl]
+  })
+}
 
 type FileTypes =
   | 'image/apng'
@@ -178,14 +181,6 @@ const handleExceed = () => {
     type: 'warning'
   })
 }
-
-// 图片预览
-const viewImageUrl = ref('')
-const imgViewVisible = ref(false)
-const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
-  viewImageUrl.value = uploadFile.url!
-  imgViewVisible.value = true
-}
 </script>
 
 <style lang="scss" scoped>

+ 20 - 11
src/components/UploadFile/src/useUpload.ts

@@ -3,9 +3,16 @@ import CryptoJS from 'crypto-js'
 import { UploadRawFile, UploadRequestOptions } from 'element-plus/es/components/upload/src/upload'
 import axios from 'axios'
 
+/**
+ * 获得上传 URL
+ */
+export const getUploadUrl = (): string => {
+  return import.meta.env.VITE_BASE_URL + import.meta.env.VITE_API_URL + '/infra/file/upload'
+}
+
 export const useUpload = () => {
   // 后端上传地址
-  const uploadUrl = import.meta.env.VITE_UPLOAD_URL
+  const uploadUrl = getUploadUrl()
   // 是否使用前端直连上传
   const isClientUpload = UPLOAD_TYPE.CLIENT === import.meta.env.VITE_UPLOAD_TYPE
   // 重写ElUpload上传方法
@@ -17,16 +24,18 @@ export const useUpload = () => {
       // 1.2 获取文件预签名地址
       const presignedInfo = await FileApi.getFilePresignedUrl(fileName)
       // 1.3 上传文件(不能使用 ElUpload 的 ajaxUpload 方法的原因:其使用的是 FormData 上传,Minio 不支持)
-      return axios.put(presignedInfo.uploadUrl, options.file, {
-        headers: {
-          'Content-Type': options.file.type,
-        }
-      }).then(() => {
-        // 1.4. 记录文件信息到后端(异步)
-        createFile(presignedInfo, fileName, options.file)
-        // 通知成功,数据格式保持与后端上传的返回结果一致
-        return { data: presignedInfo.url }
-      })
+      return axios
+        .put(presignedInfo.uploadUrl, options.file, {
+          headers: {
+            'Content-Type': options.file.type
+          }
+        })
+        .then(() => {
+          // 1.4. 记录文件信息到后端(异步)
+          createFile(presignedInfo, fileName, options.file)
+          // 通知成功,数据格式保持与后端上传的返回结果一致
+          return { data: presignedInfo.url }
+        })
     } else {
       // 模式二:后端上传
       // 重写 el-upload httpRequest 文件上传成功会走成功的钩子,失败走失败的钩子

+ 0 - 1
src/utils/dict.ts

@@ -194,7 +194,6 @@ export enum DICT_TYPE {
   PROMOTION_COUPON_TEMPLATE_VALIDITY_TYPE = 'promotion_coupon_template_validity_type', // 优惠劵模板的有限期类型
   PROMOTION_COUPON_STATUS = 'promotion_coupon_status', // 优惠劵的状态
   PROMOTION_COUPON_TAKE_TYPE = 'promotion_coupon_take_type', // 优惠劵的领取方式
-  PROMOTION_ACTIVITY_STATUS = 'promotion_activity_status', // 优惠活动的状态
   PROMOTION_CONDITION_TYPE = 'promotion_condition_type', // 营销的条件类型枚举
   PROMOTION_BARGAIN_RECORD_STATUS = 'promotion_bargain_record_status', // 砍价记录的状态
   PROMOTION_COMBINATION_RECORD_STATUS = 'promotion_combination_record_status', // 拼团记录的状态

+ 251 - 114
src/views/Login/components/RegisterForm.vue

@@ -1,142 +1,279 @@
 <template>
-  <Form
+  <el-form
     v-show="getShow"
-    :rules="rules"
-    :schema="schema"
-    class="w-[100%] dark:(border-1 border-[var(--el-border-color)] border-solid)"
-    hide-required-asterisk
+    ref="formLogin"
+    :model="registerData.registerForm"
+    :rules="registerRules"
+    class="login-form"
     label-position="top"
+    label-width="120px"
     size="large"
-    @register="register"
   >
-    <template #title>
-      <LoginFormTitle style="width: 100%" />
-    </template>
-
-    <template #code="form">
-      <div class="w-[100%] flex">
-        <el-input v-model="form['code']" :placeholder="t('login.codePlaceholder')" />
-      </div>
-    </template>
-
-    <template #register>
-      <div class="w-[100%]">
-        <XButton
-          :loading="loading"
-          :title="t('login.register')"
-          class="w-[100%]"
-          type="primary"
-          @click="loginRegister()"
-        />
-      </div>
-      <div class="mt-15px w-[100%]">
-        <XButton :title="t('login.hasUser')" class="w-[100%]" @click="handleBackLogin()" />
-      </div>
-    </template>
-  </Form>
+    <el-row style="margin-right: -10px; margin-left: -10px">
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item>
+          <LoginFormTitle style="width: 100%" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item v-if="registerData.tenantEnable === 'true'" prop="tenantName">
+          <el-input
+            v-model="registerData.registerForm.tenantName"
+            :placeholder="t('login.tenantname')"
+            :prefix-icon="iconHouse"
+            link
+            type="primary"
+            size="large"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="username">
+          <el-input
+            v-model="registerData.registerForm.username"
+            :placeholder="t('login.username')"
+            size="large"
+            :prefix-icon="iconAvatar"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="username">
+          <el-input
+            v-model="registerData.registerForm.nickname"
+            placeholder="昵称"
+            size="large"
+            :prefix-icon="iconAvatar"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="password">
+          <el-input
+            v-model="registerData.registerForm.password"
+            type="password"
+            auto-complete="off"
+            :placeholder="t('login.password')"
+            size="large"
+            :prefix-icon="iconLock"
+            show-password
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item prop="confirmPassword">
+          <el-input
+            v-model="registerData.registerForm.confirmPassword"
+            type="password"
+            size="large"
+            auto-complete="off"
+            :placeholder="t('login.checkPassword')"
+            :prefix-icon="iconLock"
+            show-password
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24" style="padding-right: 10px; padding-left: 10px">
+        <el-form-item>
+          <XButton
+            :loading="loginLoading"
+            :title="t('login.register')"
+            class="w-[100%]"
+            type="primary"
+            @click="getCode()"
+          />
+        </el-form-item>
+      </el-col>
+      <Verify
+        ref="verify"
+        :captchaType="captchaType"
+        :imgSize="{ width: '400px', height: '200px' }"
+        mode="pop"
+        @success="handleRegister"
+      />
+    </el-row>
+    <XButton :title="t('login.hasUser')" class="w-[100%]" @click="handleBackLogin()" />
+  </el-form>
 </template>
 <script lang="ts" setup>
-import type { FormRules } from 'element-plus'
-
-import { useForm } from '@/hooks/web/useForm'
-import { useValidator } from '@/hooks/web/useValidator'
+import { ElLoading } from 'element-plus'
 import LoginFormTitle from './LoginFormTitle.vue'
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+import { useIcon } from '@/hooks/web/useIcon'
+import * as authUtil from '@/utils/auth'
+import { usePermissionStore } from '@/store/modules/permission'
+import * as LoginApi from '@/api/login'
 import { LoginStateEnum, useLoginState } from './useLogin'
-import { FormSchema } from '@/types/form'
 
 defineOptions({ name: 'RegisterForm' })
 
 const { t } = useI18n()
-const { required } = useValidator()
-const { register, elFormRef } = useForm()
+const iconHouse = useIcon({ icon: 'ep:house' })
+const iconAvatar = useIcon({ icon: 'ep:avatar' })
+const iconLock = useIcon({ icon: 'ep:lock' })
+const formLogin = ref()
 const { handleBackLogin, getLoginState } = useLoginState()
+const { currentRoute, push } = useRouter()
+const permissionStore = usePermissionStore()
+const redirect = ref<string>('')
+const loginLoading = ref(false)
+const verify = ref()
+const captchaType = ref('blockPuzzle') // blockPuzzle 滑块 clickWord 点击文字
+
 const getShow = computed(() => unref(getLoginState) === LoginStateEnum.REGISTER)
 
-const schema = reactive<FormSchema[]>([
-  {
-    field: 'title',
-    colProps: {
-      span: 24
+const equalToPassword = (rule, value, callback) => {
+  if (registerData.registerForm.password !== value) {
+    callback(new Error('两次输入的密码不一致'))
+  } else {
+    callback()
+  }
+}
+
+const registerRules = {
+  tenantName: [
+    { required: true, trigger: 'blur', message: '请输入您所属的租户' },
+    { min: 2, max: 20, message: '租户账号长度必须介于 2 和 20 之间', trigger: 'blur' }
+  ],
+  username: [
+    { required: true, trigger: 'blur', message: '请输入您的账号' },
+    { min: 4, max: 30, message: '用户账号长度必须介于 4 和 30 之间', trigger: 'blur' }
+  ],
+  nickname: [
+    { required: true, trigger: 'blur', message: '请输入您的昵称' },
+    { min: 0, max: 30, message: '昵称长度必须介于 0 和 30 之间', trigger: 'blur' }
+  ],
+  password: [
+    { required: true, trigger: 'blur', message: '请输入您的密码' },
+    { min: 5, max: 20, message: '用户密码长度必须介于 5 和 20 之间', trigger: 'blur' },
+    { pattern: /^[^<>"'|\\]+$/, message: '不能包含非法字符:< > " \' \\\ |', trigger: 'blur' }
+  ],
+  confirmPassword: [
+    { required: true, trigger: 'blur', message: '请再次输入您的密码' },
+    { required: true, validator: equalToPassword, trigger: 'blur' }
+  ]
+}
+
+const registerData = reactive({
+  isShowPassword: false,
+  captchaEnable: import.meta.env.VITE_APP_CAPTCHA_ENABLE,
+  tenantEnable: import.meta.env.VITE_APP_TENANT_ENABLE,
+  registerForm: {
+    tenantName: import.meta.env.VITE_APP_DEFAULT_LOGIN_TENANT || '',
+    nickname: '',
+    tenantId: 0,
+    username: '',
+    password: '',
+    confirmPassword: '',
+    captchaVerification: ''
+  }
+})
+
+// 提交注册
+const handleRegister = async (params: any) => {
+  loading.value = true
+  try {
+    if (registerData.tenantEnable) {
+      await getTenantId()
+      registerData.registerForm.tenantId = authUtil.getTenantId()
     }
-  },
-  {
-    field: 'username',
-    label: t('login.username'),
-    value: '',
-    component: 'Input',
-    colProps: {
-      span: 24
-    },
-    componentProps: {
-      placeholder: t('login.usernamePlaceholder')
+
+    if (registerData.captchaEnable) {
+      registerData.registerForm.captchaVerification = params.captchaVerification
     }
-  },
-  {
-    field: 'password',
-    label: t('login.password'),
-    value: '',
-    component: 'InputPassword',
-    colProps: {
-      span: 24
-    },
-    componentProps: {
-      style: {
-        width: '100%'
-      },
-      strength: true,
-      placeholder: t('login.passwordPlaceholder')
+
+    const res = await LoginApi.register(registerData.registerForm)
+    if (!res) {
+      return
     }
-  },
-  {
-    field: 'check_password',
-    label: t('login.checkPassword'),
-    value: '',
-    component: 'InputPassword',
-    colProps: {
-      span: 24
-    },
-    componentProps: {
-      style: {
-        width: '100%'
-      },
-      strength: true,
-      placeholder: t('login.passwordPlaceholder')
+    loading.value = ElLoading.service({
+      lock: true,
+      text: '正在加载系统中...',
+      background: 'rgba(0, 0, 0, 0.7)'
+    })
+
+    authUtil.removeLoginForm()
+
+    authUtil.setToken(res)
+    if (!redirect.value) {
+      redirect.value = '/'
     }
-  },
-  {
-    field: 'code',
-    label: t('login.code'),
-    colProps: {
-      span: 24
+    // 判断是否为SSO登录
+    if (redirect.value.indexOf('sso') !== -1) {
+      window.location.href = window.location.href.replace('/login?redirect=', '')
+    } else {
+      push({ path: redirect.value || permissionStore.addRouters[0].path })
     }
+  } finally {
+    loginLoading.value = false
+    loading.value.close()
+  }
+}
+
+// 获取验证码
+const getCode = async () => {
+  // 情况一,未开启:则直接注册
+  if (registerData.captchaEnable === 'false') {
+    await handleRegister({})
+  } else {
+    // 情况二,已开启:则展示验证码;只有完成验证码的情况,才进行注册
+    // 弹出验证码
+    verify.value.show()
+  }
+}
+
+// 获取租户 ID
+const getTenantId = async () => {
+  if (registerData.tenantEnable === 'true') {
+    const res = await LoginApi.getTenantIdByName(registerData.registerForm.tenantName)
+    authUtil.setTenantId(res)
+  }
+}
+
+// 根据域名,获得租户信息
+const getTenantByWebsite = async () => {
+  const website = location.host
+  const res = await LoginApi.getTenantByWebsite(website)
+  if (res) {
+    registerData.registerForm.tenantName = res.name
+    authUtil.setTenantId(res.id)
+  }
+}
+const loading = ref() // ElLoading.service 返回的实例
+
+watch(
+  () => currentRoute.value,
+  (route: RouteLocationNormalizedLoaded) => {
+    redirect.value = route?.query?.redirect as string
   },
   {
-    field: 'register',
-    colProps: {
-      span: 24
-    }
+    immediate: true
   }
-])
+)
+onMounted(() => {
+  // getCookie()
+  getTenantByWebsite()
+})
+</script>
 
-const rules: FormRules = {
-  username: [required()],
-  password: [required()],
-  check_password: [required()],
-  code: [required()]
+<style lang="scss" scoped>
+:deep(.anticon) {
+  &:hover {
+    color: var(--el-color-primary) !important;
+  }
 }
 
-const loading = ref(false)
-
-const loginRegister = async () => {
-  const formRef = unref(elFormRef)
-  formRef?.validate(async (valid) => {
-    if (valid) {
-      try {
-        loading.value = true
-      } finally {
-        loading.value = false
-      }
-    }
-  })
+.login-code {
+  float: right;
+  width: 100%;
+  height: 38px;
+
+  img {
+    width: 100%;
+    height: auto;
+    max-width: 100px;
+    vertical-align: middle;
+    cursor: pointer;
+  }
 }
-</script>
+</style>

+ 151 - 0
src/views/knowledge/dataset-form/form-step1.vue

@@ -0,0 +1,151 @@
+<template>
+  <div class="upload-container">
+    <!-- 标题 -->
+    <div class="title">
+      <div>选择数据源</div>
+    </div>
+
+    <!-- 数据源选择 -->
+    <div class="resource-btn" >导入已有文本</div>
+
+    <!-- 上传文件区域 -->
+    <el-form>
+      <div class="upload-section">
+        <div class="upload-label">上传文本文件</div>
+        <el-upload
+          class="upload-area"
+          action="#"
+          :file-list="fileList"
+          :on-remove="handleRemove"
+          :before-upload="beforeUpload"
+          list-type="text"
+          drag
+        >
+          <i class="el-icon-upload"></i>
+          <div class="el-upload__text">拖拽文件至此,或者 <em>选择文件</em></div>
+          <div class="el-upload__tip">
+            已支持 TXT、MARKDOWN、PDF、HTML、XLSX、XLS、DOCX、CSV、EML、MSG、PPTX、PPT、XML、EPUB,每个文件不超过 15MB。
+          </div>
+        </el-upload>
+      </div>
+
+      <!-- 下一步按钮 -->
+      <div class="next-button">
+        <el-button type="primary" :disabled="!fileList.length">下一步</el-button>
+      </div>
+    </el-form>
+
+    <!-- 知识库创建 -->
+    <div class="create-knowledge">
+      <el-link type="primary" underline>创建一个空知识库</el-link>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+
+const fileList = ref([])
+
+const handleRemove = (file, fileList) => {
+  console.log(file, fileList)
+}
+
+const beforeUpload = (file) => {
+  fileList.value.push(file)
+  return false
+}
+</script>
+
+<style scoped lang="scss">
+.upload-container {
+  width: 600px;
+  margin: 0 auto;
+  padding: 20px;
+  background-color: #fff;
+  border-radius: 8px;
+  border: 1px solid #ebebeb;
+}
+
+.title {
+  font-size: 22px;
+  font-weight: bold;
+}
+
+.resource-btn {
+  margin-top: 20px;
+  border-radius: 10px;
+  cursor: pointer;
+  width: 150px;
+  border: 1.5px solid #528bff;
+  padding: 10px;
+  text-align: center;
+  font-weight: 500;
+  font-size: 14px;
+  line-height: 30px;
+  color: #101828;
+}
+
+.upload-section {
+  margin: 20px 0;
+  padding-top: 10px;
+}
+
+.upload-label {
+  font-size: 16px;
+  font-weight: bold;
+  margin-bottom: 10px;
+  color: #303133;
+}
+
+.upload-area {
+  margin-top: 10px;
+  border: 1px dashed #d9d9d9;
+  padding: 40px;
+  text-align: center;
+  background-color: #f5f7fa;
+  border-radius: 8px;
+}
+
+.el-upload__text em {
+  color: #409eff;
+  cursor: pointer;
+}
+
+.el-upload__tip {
+  margin-top: 10px;
+  font-size: 12px;
+  color: #909399;
+}
+
+.next-button {
+  text-align: left;
+  margin-top: 20px;
+}
+
+.create-knowledge {
+  text-align: left;
+  margin-top: 20px;
+}
+
+.el-form-item {
+  margin-bottom: 0;
+}
+
+.source-radio-group {
+  display: flex;
+  justify-content: space-between;
+}
+
+.el-radio-button {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 14px;
+  padding: 10px 20px;
+}
+
+.el-radio-button .el-icon {
+  margin-right: 8px;
+}
+</style>

File diff suppressed because it is too large
+ 168 - 0
src/views/knowledge/dataset-form/form-step2.vue


+ 152 - 0
src/views/knowledge/dataset.vue

@@ -0,0 +1,152 @@
+<template>
+  <div class="knowledge-base-container">
+    <div class="card-container">
+      <el-card class="create-card" shadow="hover">
+        <div class="create-content">
+          <el-icon class="create-icon"><Plus /></el-icon>
+          <span class="create-text">创建知识库</span>
+        </div>
+        <div class="create-footer">
+          导入您自己的文本数据或通过 Webhook 实时写入数据以增强 LLM 的上下文。
+        </div>
+      </el-card>
+
+      <el-card class="document-card" shadow="hover" v-for="index in 4" :key="index">
+        <div class="document-header">
+          <el-icon><Folder /></el-icon>
+          <span>接口鉴权示例代码.md</span>
+        </div>
+        <div class="document-info">
+          <el-tag size="small">1 文档</el-tag>
+          <el-tag size="small" type="info">5 千字符</el-tag>
+          <el-tag size="small" type="warning">0 关联应用</el-tag>
+        </div>
+        <p class="document-description">
+          useful for when you want to answer queries about the 接口鉴权示例代码.md
+        </p>
+      </el-card>
+    </div>
+
+    <div class="pagination-container">
+      <el-pagination
+        v-model:current-page="currentPage"
+        v-model:page-size="pageSize"
+        :page-sizes="[10, 20, 30, 40]"
+        :small="false"
+        :disabled="false"
+        :background="true"
+        layout="total, sizes, prev, pager, next, jumper"
+        :total="total"
+        @size-change="handleSizeChange"
+        @current-change="handleCurrentChange"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { Folder, Plus } from '@element-plus/icons-vue'
+
+const currentPage = ref(1)
+const pageSize = ref(10)
+const total = ref(100) // 假设总共有100条数据
+
+const handleSizeChange = (val) => {
+  console.log(`每页 ${val} 条`)
+}
+
+const handleCurrentChange = (val) => {
+  console.log(`当前页: ${val}`)
+}
+</script>
+
+<style scoped>
+.knowledge-base-container {
+  font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
+  position: absolute;
+  padding: 20px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  top: 0;
+  bottom: 40px;
+  width: 100%;
+}
+
+.card-container {
+  display: flex;
+  flex-wrap: wrap; /* Enable wrapping */
+  gap: 20px;
+  margin-bottom: auto; /* Pushes pagination to the bottom */
+}
+
+.create-card, .document-card {
+  flex: 1 1 360px; /* Allow cards to grow and shrink */
+  min-width: 0;
+  max-width: 400px;
+  border-radius: 10px;
+  cursor: pointer;
+}
+
+.create-card {
+  background-color: rgba(168, 168, 168, 0.22);
+}
+.create-card:hover {
+  background-color: #fff;
+}
+
+.create-content {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 15px;
+}
+
+.create-icon {
+  font-size: 24px;
+  color: #409EFF;
+}
+
+.create-text {
+  font-size: 18px;
+  font-weight: bold;
+  color: #303133;
+}
+
+.create-footer {
+  font-size: 14px;
+  color: #909399;
+  line-height: 1.5;
+}
+
+.document-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 16px;
+  font-weight: bold;
+  margin-bottom: 15px;
+}
+
+.document-info {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 15px;
+}
+
+.document-description {
+  color: #606266;
+  font-size: 14px;
+  line-height: 1.5;
+}
+
+.pagination-container {
+  position: absolute;
+  width: 100%;
+  bottom: 0;
+  display: flex;
+  justify-content: center;
+  margin-top: 20px;
+}
+</style>

+ 1 - 1
src/views/mall/product/property/value/index.vue

@@ -105,7 +105,7 @@ const list = ref([]) // 列表的数据
 const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  propertyId: Number(params.propertyId),
+  propertyId: params.propertyId,
   name: undefined
 })
 const queryFormRef = ref() // 搜索的表单

+ 5 - 5
src/views/mall/product/spu/components/SkuList.vue

@@ -180,17 +180,17 @@
     </el-table-column>
     <el-table-column align="center" label="销售价(元)" min-width="80">
       <template #default="{ row }">
-        {{ formatToFraction(row.price) }}
+        {{ row.price }}
       </template>
     </el-table-column>
     <el-table-column align="center" label="市场价(元)" min-width="80">
       <template #default="{ row }">
-        {{ formatToFraction(row.marketPrice) }}
+        {{ row.marketPrice }}
       </template>
     </el-table-column>
     <el-table-column align="center" label="成本价(元)" min-width="80">
       <template #default="{ row }">
-        {{ formatToFraction(row.costPrice) }}
+        {{ row.costPrice }}
       </template>
     </el-table-column>
     <el-table-column align="center" label="库存" min-width="80">
@@ -211,12 +211,12 @@
     <template v-if="formData!.subCommissionType">
       <el-table-column align="center" label="一级返佣(元)" min-width="80">
         <template #default="{ row }">
-          {{ formatToFraction(row.firstBrokeragePrice) }}
+          {{ row.firstBrokeragePrice }}
         </template>
       </el-table-column>
       <el-table-column align="center" label="二级返佣(元)" min-width="80">
         <template #default="{ row }">
-          {{ formatToFraction(row.secondBrokeragePrice) }}
+          {{ row.secondBrokeragePrice }}
         </template>
       </el-table-column>
     </template>

+ 1 - 1
src/views/mall/product/spu/form/InfoForm.vue

@@ -45,7 +45,7 @@
         :show-word-limit="true"
         class="w-80!"
         maxlength="128"
-        placeholder="请输入商品名称"
+        placeholder="请输入商品简介"
         type="textarea"
       />
     </el-form-item>

+ 44 - 40
src/views/mall/promotion/combination/activity/index.vue

@@ -4,27 +4,27 @@
   <ContentWrap>
     <!-- 搜索工作栏 -->
     <el-form
-      class="-mb-15px"
-      :model="queryParams"
       ref="queryFormRef"
       :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
       label-width="68px"
     >
       <el-form-item label="活动名称" prop="name">
         <el-input
           v-model="queryParams.name"
-          placeholder="请输入活动名称"
+          class="!w-240px"
           clearable
+          placeholder="请输入活动名称"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </el-form-item>
       <el-form-item label="活动状态" prop="status">
         <el-select
           v-model="queryParams.status"
-          placeholder="请选择活动状态"
-          clearable
           class="!w-240px"
+          clearable
+          placeholder="请选择活动状态"
         >
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@@ -35,15 +35,22 @@
         </el-select>
       </el-form-item>
       <el-form-item>
-        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <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
-          type="primary"
+          v-hasPermi="['promotion:combination-activity:create']"
           plain
+          type="primary"
           @click="openForm('create')"
-          v-hasPermi="['promotion:combination-activity:create']"
         >
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
         </el-button>
       </el-form-item>
     </el-form>
@@ -51,77 +58,77 @@
 
   <!-- 列表 -->
   <ContentWrap>
-    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
-      <el-table-column label="活动编号" prop="id" min-width="80" />
-      <el-table-column label="活动名称" prop="name" min-width="140" />
+    <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="140" prop="name" />
       <el-table-column label="活动时间" min-width="210">
         <template #default="scope">
           {{ formatDate(scope.row.startTime, 'YYYY-MM-DD') }}
           ~ {{ formatDate(scope.row.endTime, 'YYYY-MM-DD') }}
         </template>
       </el-table-column>
-      <el-table-column label="商品图片" prop="spuName" min-width="80">
+      <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-src-list="[scope.row.picUrl]"
             preview-teleported
           />
         </template>
       </el-table-column>
-      <el-table-column label="商品标题" prop="spuName" min-width="300" />
+      <el-table-column label="商品标题" min-width="300" prop="spuName" />
       <el-table-column
+        :formatter="fenToYuanFormat"
         label="原价"
-        prop="marketPrice"
         min-width="100"
-        :formatter="fenToYuanFormat"
+        prop="marketPrice"
       />
-      <el-table-column label="拼团价" prop="seckillPrice" min-width="100">
+      <el-table-column label="拼团价" min-width="100" prop="seckillPrice">
         <template #default="scope">
           {{ formatCombinationPrice(scope.row.products) }}
         </template>
       </el-table-column>
-      <el-table-column label="开团组数" prop="groupCount" min-width="100" />
-      <el-table-column label="成团组数" prop="groupSuccessCount" min-width="100" />
-      <el-table-column label="购买次数" prop="recordCount" min-width="100" />
-      <el-table-column label="活动状态" align="center" prop="status" min-width="100">
+      <el-table-column label="开团组数" min-width="100" prop="groupCount" />
+      <el-table-column label="成团组数" min-width="100" prop="groupSuccessCount" />
+      <el-table-column label="购买次数" min-width="100" prop="recordCount" />
+      <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
-        label="创建时间"
+        :formatter="dateFormatter"
         align="center"
+        label="创建时间"
         prop="createTime"
-        :formatter="dateFormatter"
         width="180px"
       />
-      <el-table-column label="操作" align="center" width="150px" fixed="right">
+      <el-table-column align="center" fixed="right" label="操作" width="150px">
         <template #default="scope">
           <el-button
+            v-hasPermi="['promotion:combination-activity:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
-            v-hasPermi="['promotion:combination-activity:update']"
           >
             编辑
           </el-button>
           <el-button
+            v-if="scope.row.status === 0"
+            v-hasPermi="['promotion:combination-activity:close']"
             link
             type="danger"
             @click="handleClose(scope.row.id)"
-            v-if="scope.row.status === 0"
-            v-hasPermi="['promotion:combination-activity:close']"
           >
             关闭
           </el-button>
           <el-button
+            v-else
+            v-hasPermi="['promotion:combination-activity:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-else
-            v-hasPermi="['promotion:combination-activity:delete']"
           >
             删除
           </el-button>
@@ -130,9 +137,9 @@
     </el-table>
     <!-- 分页 -->
     <Pagination
-      :total="total"
-      v-model:page="queryParams.pageNo"
       v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
       @pagination="getList"
     />
   </ContentWrap>
@@ -141,12 +148,11 @@
   <CombinationActivityForm ref="formRef" @success="getList" />
 </template>
 
-<script setup lang="ts">
+<script lang="ts" setup>
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import { dateFormatter } from '@/utils/formatTime'
+import { dateFormatter, formatDate } from '@/utils/formatTime'
 import * as CombinationActivityApi from '@/api/mall/promotion/combination/combinationActivity'
 import CombinationActivityForm from './CombinationActivityForm.vue'
-import { formatDate } from '@/utils/formatTime'
 import { fenToYuanFormat } from '@/utils/formatter'
 import { fenToYuan } from '@/utils'
 
@@ -165,7 +171,6 @@ const queryParams = reactive({
   status: null
 })
 const queryFormRef = ref() // 搜索的表单
-const exportLoading = ref(false) // 导出的加载中
 
 /** 查询列表 */
 const getList = async () => {
@@ -197,12 +202,11 @@ const openForm = (type: string, id?: number) => {
   formRef.value.open(type, id)
 }
 
-// TODO 芋艿:这里要改下
 /** 关闭按钮操作 */
 const handleClose = async (id: number) => {
   try {
     // 关闭的二次确认
-    await message.confirm('确认关闭该秒杀活动吗?')
+    await message.confirm('确认关闭该拼团活动吗?')
     // 发起关闭
     await CombinationActivityApi.closeCombinationActivity(id)
     message.success('关闭成功')

+ 9 - 9
src/views/mall/promotion/components/SpuAndSkuList.vue

@@ -30,13 +30,13 @@
     <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
-      v-if="spuData.length > 1 && isDelete"
+      v-if="spuData.length > 1 && deletable"
       align="center"
       label="操作"
       min-width="90"
     >
       <template #default="scope">
-        <el-button type="primary" link @click="deleteSpu(scope.row.id)"> 删除 </el-button>
+        <el-button link type="primary" @click="deleteSpu(scope.row.id)"> 删除</el-button>
       </template>
     </el-table-column>
   </el-table>
@@ -56,13 +56,13 @@ const props = defineProps<{
   spuList: T[]
   ruleConfig: RuleConfig[]
   spuPropertyListP: SpuProperty<T>[]
-  isDelete?: boolean // SPU 是否可删除;TODO deletable 换成这个名字好点。
+  deletable?: boolean // SPU 是否可删除;
 }>()
 
 const spuData = ref<Spu[]>([]) // spu 详情数据列表
 const skuListRef = ref() // 商品属性列表Ref
 const spuPropertyList = ref<SpuProperty<T>[]>([]) // spuId 对应的 sku 的属性列表
-const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
+const expandRowKeys = ref<string[]>([]) // 控制展开行需要设置 row-key 属性才能使用,该属性为展开行的 keys 数组。
 
 /**
  * 获取所有 sku 活动配置
@@ -71,10 +71,10 @@ const expandRowKeys = ref<number[]>() // 控制展开行需要设置 row-key 属
  */
 const getSkuConfigs = (extendedAttribute: string) => {
   skuListRef.value.validateSku()
-  const seckillProducts = []
+  const seckillProducts: any[] = []
   spuPropertyList.value.forEach((item) => {
-    item.spuDetail.skus.forEach((sku) => {
-      seckillProducts.push(sku[extendedAttribute])
+    item.spuDetail.skus?.forEach((sku: any) => {
+      seckillProducts.push(sku[extendedAttribute] as any)
     })
   })
   return seckillProducts
@@ -124,10 +124,10 @@ watch(
   () => props.spuPropertyListP,
   (data) => {
     if (!data) return
-    spuPropertyList.value = data as SpuProperty<T>[]
+    spuPropertyList.value = data as SpuProperty<T>[] as any
     // 解决如果之前选择的是单规格 spu 的话后面选择多规格 sku 多规格属性信息不展示的问题。解决方法:让 SkuList 组件重新渲染(行折叠会干掉包含的组件展开时会重新加载)
     setTimeout(() => {
-      expandRowKeys.value = data.map((item) => item.spuId)
+      expandRowKeys.value = data.map((item) => item.spuId + '')
     }, 200)
   },
   {

+ 2 - 1
src/views/mall/promotion/coupon/components/CouponSelect.vue

@@ -116,6 +116,7 @@ import {
   validityTypeFormat
 } from '@/views/mall/promotion/coupon/formatter'
 import * as CouponTemplateApi from '@/api/mall/promotion/coupon/couponTemplate'
+import { CouponTemplateTakeTypeEnum } from '@/utils/constants'
 
 defineOptions({ name: 'CouponSelect' })
 
@@ -138,7 +139,7 @@ const queryParams = reactive({
   pageSize: 10,
   name: null,
   discountType: null,
-  canTakeTypes: null
+  canTakeTypes: [CouponTemplateTakeTypeEnum.USER.type] // 只获得直接领取的券
 })
 const queryFormRef = ref() // 搜索的表单
 const selectedCouponList = ref<CouponTemplateApi.CouponTemplateVO[]>([]) // 选择的数据

+ 18 - 3
src/views/mall/promotion/coupon/formatter.ts

@@ -16,10 +16,14 @@ export const discountFormat = (row: CouponTemplateVO) => {
 
 // 格式化【领取上限】
 export const takeLimitCountFormat = (row: CouponTemplateVO) => {
-  if (row.takeLimitCount === -1) {
-    return '无领取限制'
+  if (row.takeLimitCount) {
+    if (row.takeLimitCount === -1) {
+      return '无领取限制'
+    }
+    return `${row.takeLimitCount} 张/人`
+  } else {
+    return ' '
   }
-  return `${row.takeLimitCount} 张/人`
 }
 
 // 格式化【有效期限】
@@ -33,8 +37,19 @@ export const validityTypeFormat = (row: CouponTemplateVO) => {
   return '未知【' + row.validityType + '】'
 }
 
+// 格式化【totalCount】
+export const totalCountFormat = (row: CouponTemplateVO) => {
+  if (row.totalCount === -1) {
+    return '不限制'
+  }
+  return row.totalCount
+}
+
 // 格式化【剩余数量】
 export const remainedCountFormat = (row: CouponTemplateVO) => {
+  if (row.totalCount === -1) {
+    return '不限制'
+  }
   return row.totalCount - row.takeCount
 }
 

+ 4 - 1
src/views/mall/promotion/coupon/template/CouponTemplateForm.vue

@@ -115,6 +115,7 @@
         <el-radio-group v-model="formData.takeType">
           <el-radio :key="1" :value="1">直接领取</el-radio>
           <el-radio :key="2" :value="2">指定发放</el-radio>
+          <el-radio :key="2" :value="3">新人卷</el-radio>
         </el-radio-group>
       </el-form-item>
       <el-form-item v-if="formData.takeType === 1" label="发放数量" prop="totalCount">
@@ -309,7 +310,9 @@ const submitForm = async () => {
       validEndTime:
         formData.value.validTimes && formData.value.validTimes.length === 2
           ? formData.value.validTimes[1]
-          : undefined
+          : undefined,
+      totalCount: formData.value.takeType === 1 ? formData.value.totalCount : -1,
+      takeLimitCount: formData.value.takeType === 1 ? formData.value.takeLimitCount : -1
     } as unknown as CouponTemplateApi.CouponTemplateVO
 
     // 设置商品范围

+ 7 - 1
src/views/mall/promotion/coupon/template/index.vue

@@ -109,7 +109,12 @@
         prop="validityType"
         width="185"
       />
-      <el-table-column align="center" label="发放数量" prop="totalCount" />
+      <el-table-column
+        :formatter="totalCountFormat"
+        align="center"
+        label="发放数量"
+        prop="totalCount"
+      />
       <el-table-column
         :formatter="remainedCountFormat"
         align="center"
@@ -189,6 +194,7 @@ import {
   discountFormat,
   remainedCountFormat,
   takeLimitCountFormat,
+  totalCountFormat,
   validityTypeFormat
 } from '@/views/mall/promotion/coupon/formatter'
 

+ 69 - 33
src/views/mall/promotion/discountActivity/DiscountActivityForm.vue

@@ -8,28 +8,40 @@
       :schema="allSchemas.formSchema"
     >
       <!-- 先选择 -->
-      <!-- TODO @zhangshuai:商品允许选择多个 -->
-      <!-- TODO @zhangshuai:选择后的 SKU,需要后面加个【删除】按钮 -->
-      <!-- TODO @zhangshuai:展示的金额,貌似不对,大了 100 倍,需要看下 -->
-      <!-- TODO @zhangshuai:“优惠类型”,是每个 SKU 可以自定义已设置哈。因为每个商品 SKU 的折扣和减少价格,可能不同。具体交互,可以注册一个 youzan.com 看看;它的交互方式是,如果设置了“优惠金额”,则算“减价”;如果再次设置了“折扣百分比”,就算“打折”;这样形成一个互斥的优惠类型 -->
       <template #spuId>
         <el-button @click="spuSelectRef.open()">选择商品</el-button>
         <SpuAndSkuList
           ref="spuAndSkuListRef"
+          :deletable="true"
           :rule-config="ruleConfig"
           :spu-list="spuList"
           :spu-property-list-p="spuPropertyList"
-          :isDelete="true"
           @delete="deleteSpu"
         >
           <el-table-column align="center" label="优惠金额" min-width="168">
-            <template #default="{ row: sku }">
-              <el-input-number v-model="sku.productConfig.discountPrice" :min="0" class="w-100%" />
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.productConfig.discountPrice"
+                :max="parseFloat(fenToYuan(row.price))"
+                :min="0"
+                :precision="2"
+                :step="0.1"
+                class="w-100%"
+                @change="handleSkuDiscountPriceChange(row)"
+              />
             </template>
           </el-table-column>
           <el-table-column align="center" label="折扣百分比(%)" min-width="168">
-            <template #default="{ row: sku }">
-              <el-input-number v-model="sku.productConfig.discountPercent" class="w-100%" />
+            <template #default="{ row }">
+              <el-input-number
+                v-model="row.productConfig.discountPercent"
+                :max="100"
+                :min="0"
+                :precision="2"
+                :step="0.1"
+                class="w-100%"
+                @change="handleSkuDiscountPercentChange(row)"
+              />
             </template>
           </el-table-column>
         </SpuAndSkuList>
@@ -45,11 +57,12 @@
 <script lang="ts" setup>
 import { SpuAndSkuList, SpuProperty, SpuSelect } from '../components'
 import { allSchemas, rules } from './discountActivity.data'
-import { cloneDeep } from 'lodash-es'
+import { cloneDeep, debounce } from 'lodash-es'
 import * as DiscountActivityApi from '@/api/mall/promotion/discount/discountActivity'
 import * as ProductSpuApi from '@/api/mall/product/spu'
 import { getPropertyList, RuleConfig } from '@/views/mall/product/spu/components'
-import { formatToFraction } from '@/utils'
+import { convertToInteger, erpCalculatePercentage, fenToYuan, yuanToFen } from '@/utils'
+import { PromotionDiscountTypeEnum } from '@/utils/constants'
 
 defineOptions({ name: 'PromotionDiscountActivityForm' })
 
@@ -65,7 +78,13 @@ const formRef = ref() // 表单 Ref
 
 const spuSelectRef = ref() // 商品和属性选择 Ref
 const spuAndSkuListRef = ref() // sku 限时折扣  配置组件Ref
-const ruleConfig: RuleConfig[] = []
+const ruleConfig: RuleConfig[] = [
+  {
+    name: 'productConfig.discountPrice',
+    rule: (arg) => arg > 0,
+    message: '商品优惠金额不能为 0 !!!'
+  }
+]
 const spuList = ref<DiscountActivityApi.SpuExtension[]>([]) // 选择的 spu
 const spuPropertyList = ref<SpuProperty<DiscountActivityApi.SpuExtension>[]>([])
 const spuIds = ref<number[]>([])
@@ -101,21 +120,20 @@ const getSpuDetails = async (
   selectSkus?.forEach((sku) => {
     let config: DiscountActivityApi.DiscountProductVO = {
       skuId: sku.id!,
-      spuId: spu.id,
+      spuId: spu.id!,
       discountType: 1,
       discountPercent: 0,
       discountPrice: 0
     }
     if (typeof products !== 'undefined') {
       const product = products.find((item) => item.skuId === sku.id)
+      if (product) {
+        product.discountPercent = fenToYuan(product.discountPercent) as any
+        product.discountPrice = fenToYuan(product.discountPrice) as any
+      }
       config = product || config
     }
     sku.productConfig = config
-    sku.price = formatToFraction(sku.price)
-    sku.marketPrice = formatToFraction(sku.marketPrice)
-    sku.costPrice = formatToFraction(sku.costPrice)
-    sku.firstBrokeragePrice = formatToFraction(sku.firstBrokeragePrice)
-    sku.secondBrokeragePrice = formatToFraction(sku.secondBrokeragePrice)
   })
   spu.skus = selectSkus as DiscountActivityApi.SkuExtension[]
   spuPropertyList.value.push({
@@ -168,25 +186,13 @@ const submitForm = async () => {
   // 提交请求
   formLoading.value = true
   try {
-    const data = formRef.value.formModel as DiscountActivityApi.DiscountActivityVO
     // 获取折扣商品配置
     const products = cloneDeep(spuAndSkuListRef.value.getSkuConfigs('productConfig'))
-    // 校验优惠金额、折扣百分比,是否正确
-    // TODO @puhui999:这个交互,可以参考下 youzan 的
-    let discountInvalid = false
     products.forEach((item: DiscountActivityApi.DiscountProductVO) => {
-      if (item.discountPrice != null && item.discountPrice > 0) {
-        item.discountType = 1
-      } else if (item.discountPercent != null && item.discountPercent > 0) {
-        item.discountType = 2
-      } else {
-        discountInvalid = true
-      }
+      item.discountPercent = convertToInteger(item.discountPercent)
+      item.discountPrice = convertToInteger(yuanToFen(item.discountPrice))
     })
-    if (discountInvalid) {
-      message.error('优惠金额和折扣百分比需要填写一个')
-      return
-    }
+    const data = cloneDeep(formRef.value.formModel) as DiscountActivityApi.DiscountActivityVO
     data.products = products
     // 真正提交
     if (formType.value === 'create') {
@@ -204,6 +210,36 @@ const submitForm = async () => {
   }
 }
 
+/** 处理 sku 优惠金额变动 */
+const handleSkuDiscountPriceChange = debounce((row: any) => {
+  // 校验边界
+  if (row.productConfig.discountPrice <= 0) {
+    return
+  }
+
+  // 设置优惠类型:满减
+  row.productConfig.discountType = PromotionDiscountTypeEnum.PRICE.type
+  // 设置折扣
+  row.productConfig.discountPercent = erpCalculatePercentage(
+    row.price - yuanToFen(row.productConfig.discountPrice),
+    row.price
+  )
+}, 200)
+/** 处理 sku 优惠折扣变动 */
+const handleSkuDiscountPercentChange = debounce((row: any) => {
+  // 校验边界
+  if (row.productConfig.discountPercent <= 0 || row.productConfig.discountPercent >= 100) {
+    return
+  }
+
+  // 设置优惠类型:折扣
+  row.productConfig.discountType = PromotionDiscountTypeEnum.PERCENT.type
+  // 设置满减金额
+  row.productConfig.discountPrice = fenToYuan(
+    row.price - row.price * (row.productConfig.discountPercent / 100.0 || 0)
+  )
+}, 200)
+
 /** 重置表单 */
 const resetForm = async () => {
   spuList.value = []

+ 11 - 2
src/views/mall/promotion/discountActivity/discountActivity.data.ts

@@ -1,10 +1,8 @@
 import type { CrudSchema } from '@/hooks/web/useCrudSchemas'
 import { dateFormatter2 } from '@/utils/formatTime'
 
-// TODO @zhangshai:
 // 表单校验
 export const rules = reactive({
-  spuId: [required],
   name: [required],
   startTime: [required],
   endTime: [required],
@@ -73,6 +71,17 @@ const crudSchemas = reactive<CrudSchema[]>([
     }
   },
   {
+    label: '优惠类型',
+    field: 'discountType',
+    dictType: DICT_TYPE.PROMOTION_DISCOUNT_TYPE,
+    dictClass: 'number',
+    isSearch: true,
+    form: {
+      component: 'Radio',
+      value: 1
+    }
+  },
+  {
     label: '活动商品',
     field: 'spuId',
     isTable: true,

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

@@ -0,0 +1,227 @@
+<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 v-if="!isFormUpdate" @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"
+                :max="sku.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 isFormUpdate = ref(false) // 是否更新表单
+
+// ================= 商品选择相关 =================
+
+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
+      isFormUpdate.value = true
+      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 = []
+  isFormUpdate.value = false
+  await nextTick()
+  formRef.value.getElFormRef().resetFields()
+}
+</script>

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

@@ -0,0 +1,219 @@
+<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="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 align="center" label="已兑换数量" min-width="100" prop="redeemedQuantity">
+        <template #default="{ row }">
+          {{ getRedeemedQuantity(row) }}
+        </template>
+      </el-table-column>
+      <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 getRedeemedQuantity = computed(() => (row: any) => (row.totalStock || 0) - (row.stock || 0)) // 获得商品已兑换数量
+
+/** 查询列表 */
+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)

+ 154 - 0
src/views/mall/promotion/point/components/PointShowcase.vue

@@ -0,0 +1,154 @@
+<template>
+  <div class="flex flex-wrap items-center gap-8px">
+    <div
+      v-for="(pointActivity, index) in pointActivityList"
+      :key="pointActivity.id"
+      class="select-box spu-pic"
+    >
+      <el-tooltip :content="pointActivity.name">
+        <div class="relative h-full w-full">
+          <el-image :src="pointActivity.picUrl" class="h-full w-full" />
+          <Icon
+            v-show="!disabled"
+            class="del-icon"
+            icon="ep:circle-close-filled"
+            @click="handleRemoveActivity(index)"
+          />
+        </div>
+      </el-tooltip>
+    </div>
+    <el-tooltip v-if="canAdd" content="选择活动">
+      <div class="select-box" @click="openSeckillActivityTableSelect">
+        <Icon icon="ep:plus" />
+      </div>
+    </el-tooltip>
+  </div>
+  <!-- 拼团活动选择对话框(表格形式) -->
+  <PointTableSelect
+    ref="pointActivityTableSelectRef"
+    :multiple="limit != 1"
+    @change="handleActivitySelected"
+  />
+</template>
+<script lang="ts" setup>
+import PointTableSelect from './PointTableSelect.vue'
+import { PointActivityApi, PointActivityVO } from '@/api/mall/promotion/point'
+import { propTypes } from '@/utils/propTypes'
+import { oneOfType } from 'vue-types'
+import { isArray } from '@/utils/is'
+
+// 活动橱窗,一般用于装修时使用
+// 提供功能:展示活动列表、添加活动、删除活动
+defineOptions({ name: 'PointShowcase' })
+
+const props = defineProps({
+  modelValue: oneOfType<number | Array<number>>([Number, Array]).isRequired,
+  // 限制数量:默认不限制
+  limit: propTypes.number.def(Number.MAX_VALUE),
+  disabled: propTypes.bool.def(false)
+})
+
+// 计算是否可以添加
+const canAdd = computed(() => {
+  // 情况一:禁用时不可以添加
+  if (props.disabled) return false
+  // 情况二:未指定限制数量时,可以添加
+  if (!props.limit) return true
+  // 情况三:检查已添加数量是否小于限制数量
+  return pointActivityList.value.length < props.limit
+})
+
+// 拼团活动列表
+const pointActivityList = ref<PointActivityVO[]>([])
+
+watch(
+  () => props.modelValue,
+  async () => {
+    const ids = isArray(props.modelValue)
+      ? // 情况一:多选
+        props.modelValue
+      : // 情况二:单选
+        props.modelValue
+        ? [props.modelValue]
+        : []
+    // 不需要返显
+    if (ids.length === 0) {
+      pointActivityList.value = []
+      return
+    }
+    // 只有活动发生变化之后,才会查询活动
+    if (
+      pointActivityList.value.length === 0 ||
+      pointActivityList.value.some((pointActivity) => !ids.includes(pointActivity.id!))
+    ) {
+      pointActivityList.value = await PointActivityApi.getPointActivityListByIds(ids)
+    }
+  },
+  { immediate: true }
+)
+
+/** 活动表格选择对话框 */
+const pointActivityTableSelectRef = ref()
+// 打开对话框
+const openSeckillActivityTableSelect = () => {
+  pointActivityTableSelectRef.value.open(pointActivityList.value)
+}
+
+/**
+ * 选择活动后触发
+ * @param activityList 选中的活动列表
+ */
+const handleActivitySelected = (activityList: PointActivityVO | PointActivityVO[]) => {
+  pointActivityList.value = isArray(activityList) ? activityList : [activityList]
+  emitActivityChange()
+}
+
+/**
+ * 删除活动
+ * @param index 活动索引
+ */
+const handleRemoveActivity = (index: number) => {
+  pointActivityList.value.splice(index, 1)
+  emitActivityChange()
+}
+const emit = defineEmits(['update:modelValue', 'change'])
+const emitActivityChange = () => {
+  if (props.limit === 1) {
+    const pointActivity = pointActivityList.value.length > 0 ? pointActivityList.value[0] : null
+    emit('update:modelValue', pointActivity?.id || 0)
+    emit('change', pointActivity)
+  } else {
+    emit(
+      'update:modelValue',
+      pointActivityList.value.map((pointActivity) => pointActivity.id)
+    )
+    emit('change', pointActivityList.value)
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.select-box {
+  display: flex;
+  width: 60px;
+  height: 60px;
+  border: 1px dashed var(--el-border-color-darker);
+  border-radius: 8px;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+}
+
+.spu-pic {
+  position: relative;
+}
+
+.del-icon {
+  position: absolute;
+  top: -10px;
+  right: -10px;
+  z-index: 1;
+  width: 20px !important;
+  height: 20px !important;
+}
+</style>

+ 300 - 0
src/views/mall/promotion/point/components/PointTableSelect.vue

@@ -0,0 +1,300 @@
+<template>
+  <Dialog v-model="dialogVisible" :appendToBody="true" title="选择活动" width="70%">
+    <ContentWrap>
+      <!-- 搜索工作栏 -->
+      <el-form
+        ref="queryFormRef"
+        :inline="true"
+        :model="queryParams"
+        class="-mb-15px"
+        label-width="68px"
+      >
+        <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-form-item>
+      </el-form>
+      <el-table v-loading="loading" :data="list" show-overflow-tooltip>
+        <!-- 1. 多选模式(不能使用type="selection",Element会忽略Header插槽) -->
+        <el-table-column v-if="multiple" width="55">
+          <template #header>
+            <el-checkbox
+              v-model="isCheckAll"
+              :indeterminate="isIndeterminate"
+              @change="handleCheckAll"
+            />
+          </template>
+          <template #default="{ row }">
+            <el-checkbox
+              v-model="checkedStatus[row.id]"
+              @change="(checked: boolean) => handleCheckOne(checked, row, true)"
+            />
+          </template>
+        </el-table-column>
+        <!-- 2. 单选模式 -->
+        <el-table-column v-else label="#" width="55">
+          <template #default="{ row }">
+            <el-radio
+              v-model="selectedActivityId"
+              :value="row.id"
+              @change="handleSingleSelected(row)"
+            >
+              <!-- 空格不能省略,是为了让单选框不显示label,如果不指定label不会有选中的效果 -->
+              &nbsp;
+            </el-radio>
+          </template>
+        </el-table-column>
+        <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 align="center" label="已兑换数量" min-width="100" prop="redeemedQuantity">
+          <template #default="{ row }">
+            {{ getRedeemedQuantity(row) }}
+          </template>
+        </el-table-column>
+        <el-table-column
+          :formatter="dateFormatter"
+          align="center"
+          label="创建时间"
+          prop="createTime"
+          width="180px"
+        />
+      </el-table>
+      <!-- 分页 -->
+      <Pagination
+        v-model:limit="queryParams.pageSize"
+        v-model:page="queryParams.pageNo"
+        :total="total"
+        @pagination="getList"
+      />
+    </ContentWrap>
+    <template v-if="multiple" #footer>
+      <el-button type="primary" @click="handleEmitChange">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { CHANGE_EVENT } from 'element-plus'
+import { PointActivityApi, PointActivityVO } from '@/api/mall/promotion/point'
+import { fenToYuanFormat } from '@/utils/formatter'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+
+/**
+ * 活动表格选择对话框
+ * 1. 单选模式:
+ *    1.1 点击表格左侧的单选框时,结束选择,并关闭对话框
+ *    1.2 再次打开时,保持选中状态
+ * 2. 多选模式:
+ *    2.1 点击表格左侧的多选框时,记录选中的活动
+ *    2.2 切换分页时,保持活动的选中状态
+ *    2.3 点击右下角的确定按钮时,结束选择,关闭对话框
+ *    2.4 再次打开时,保持选中状态
+ */
+defineOptions({ name: 'PointTableSelect' })
+
+defineProps({
+  // 多选模式
+  multiple: propTypes.bool.def(false)
+})
+
+// 列表的总页数
+const total = ref(0)
+// 列表的数据
+const list = ref<PointActivityVO[]>([])
+// 列表的加载中
+const loading = ref(false)
+// 弹窗的是否展示
+const dialogVisible = ref(false)
+// 查询参数
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  name: null,
+  status: undefined
+})
+const getRedeemedQuantity = computed(() => (row: any) => (row.totalStock || 0) - (row.stock || 0)) // 获得商品已兑换数量
+/** 打开弹窗 */
+const open = (pointList?: PointActivityVO[]) => {
+  // 重置
+  checkedActivities.value = []
+  checkedStatus.value = {}
+  isCheckAll.value = false
+  isIndeterminate.value = false
+
+  // 处理已选中
+  if (pointList && pointList.length > 0) {
+    checkedActivities.value = [...pointList]
+    checkedStatus.value = Object.fromEntries(pointList.map((activityVO) => [activityVO.id, true]))
+  }
+
+  dialogVisible.value = true
+  resetQuery()
+}
+// 提供 open 方法,用于打开弹窗
+defineExpose({ open })
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await PointActivityApi.getPointActivityPage(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+    // checkbox绑定undefined会有问题,需要给一个bool值
+    list.value.forEach(
+      (activityVO) =>
+        (checkedStatus.value[activityVO.id] = checkedStatus.value[activityVO.id] || false)
+    )
+    // 计算全选框状态
+    calculateIsCheckAll()
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.value.pageNo = 1
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryParams.value = {
+    pageNo: 1,
+    pageSize: 10,
+    name: null,
+    status: undefined
+  }
+  getList()
+}
+
+// 是否全选
+const isCheckAll = ref(false)
+// 全选框是否处于中间状态:不是全部选中 && 任意一个选中
+const isIndeterminate = ref(false)
+// 选中的活动
+const checkedActivities = ref<PointActivityVO[]>([])
+// 选中状态:key为活动ID,value为是否选中
+const checkedStatus = ref<Record<string, boolean>>({})
+
+// 选中的活动 activityId
+const selectedActivityId = ref()
+/** 单选中时触发 */
+const handleSingleSelected = (pointActivityVO: PointActivityVO) => {
+  emits(CHANGE_EVENT, pointActivityVO)
+  // 关闭弹窗
+  dialogVisible.value = false
+  // 记住上次选择的ID
+  selectedActivityId.value = pointActivityVO.id
+}
+
+/** 多选完成 */
+const handleEmitChange = () => {
+  // 关闭弹窗
+  dialogVisible.value = false
+  emits(CHANGE_EVENT, [...checkedActivities.value])
+}
+
+/** 确认选择时的触发事件 */
+const emits = defineEmits<{
+  (e: CHANGE_EVENT, v: PointActivityVO | PointActivityVO[] | any): void
+}>()
+
+/** 全选/全不选 */
+const handleCheckAll = (checked: boolean) => {
+  isCheckAll.value = checked
+  isIndeterminate.value = false
+
+  list.value.forEach((pointActivity) => handleCheckOne(checked, pointActivity, false))
+}
+
+/**
+ * 选中一行
+ * @param checked 是否选中
+ * @param pointActivity 活动
+ * @param isCalcCheckAll 是否计算全选
+ */
+const handleCheckOne = (
+  checked: boolean,
+  pointActivity: PointActivityVO,
+  isCalcCheckAll: boolean
+) => {
+  if (checked) {
+    checkedActivities.value.push(pointActivity)
+    checkedStatus.value[pointActivity.id] = true
+  } else {
+    const index = findCheckedIndex(pointActivity)
+    if (index > -1) {
+      checkedActivities.value.splice(index, 1)
+      checkedStatus.value[pointActivity.id] = false
+      isCheckAll.value = false
+    }
+  }
+
+  // 计算全选框状态
+  if (isCalcCheckAll) {
+    calculateIsCheckAll()
+  }
+}
+
+// 查找活动在已选中活动列表中的索引
+const findCheckedIndex = (activityVO: PointActivityVO) =>
+  checkedActivities.value.findIndex((item) => item.id === activityVO.id)
+
+// 计算全选框状态
+const calculateIsCheckAll = () => {
+  isCheckAll.value = list.value.every((activityVO) => checkedStatus.value[activityVO.id])
+  // 计算中间状态:不是全部选中 && 任意一个选中
+  isIndeterminate.value =
+    !isCheckAll.value && list.value.some((activityVO) => checkedStatus.value[activityVO.id])
+}
+</script>

+ 8 - 2
src/views/mall/promotion/rewardActivity/RewardForm.vue

@@ -56,7 +56,7 @@
         label="分类"
         prop="productCategoryIds"
       >
-        <ProductCategorySelect v-model="formData.productCategoryIds" />
+        <ProductCategorySelect v-model="formData.productCategoryIds" :multiple="true" />
       </el-form-item>
       <el-form-item label="备注" prop="remark">
         <el-input v-model="formData.remark" placeholder="请输入备注" />
@@ -119,6 +119,9 @@ const open = async (type: string, id?: number) => {
       // 规则分转元
       data.rules?.forEach((item: any) => {
         item.discountPrice = fenToYuan(item.discountPrice || 0)
+        if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
+          item.limit = fenToYuan(item.limit || 0)
+        }
       })
       formData.value = data
       // 获得商品范围
@@ -151,6 +154,9 @@ const submitForm = async () => {
     // 规则元转分
     data.rules.forEach((item) => {
       item.discountPrice = yuanToFen(item.discountPrice || 0)
+      if (data.conditionType === PromotionConditionTypeEnum.PRICE.type) {
+        item.limit = yuanToFen(item.limit || 0)
+      }
     })
     // 设置商品范围
     setProductScopeValues(data)
@@ -188,7 +194,7 @@ const getProductScope = async () => {
     case PromotionProductScopeEnum.CATEGORY.scope:
       await nextTick()
       let productCategoryIds = formData.value.productScopeValues as any
-      if (Array.isArray(productCategoryIds) && productCategoryIds.length > 0) {
+      if (Array.isArray(productCategoryIds) && productCategoryIds.length === 1) {
         // 单选时使用数组不能反显
         productCategoryIds = productCategoryIds[0]
       }

+ 12 - 1
src/views/mall/promotion/rewardActivity/components/RewardRule.vue

@@ -10,14 +10,25 @@
         <el-form ref="formRef" :model="rule">
           <el-form-item label="优惠门槛:" label-width="100px" prop="limit">
+            <el-input-number
+              v-if="PromotionConditionTypeEnum.PRICE.type === formData.conditionType"
+              v-model="rule.limit"
+              :min="0"
+              :precision="2"
+              :step="0.1"
+              class="w-150px! p-x-20px!"
+              placeholder=""
+              type="number"
+              controls-position="right"
+            />
             <el-input
+              v-else
               v-model="rule.limit"
               :min="0"
               class="w-150px! p-x-20px!"
               placeholder=""
               type="number"
             />
-            <!-- TODO @puhui999:走字典数据? -->
             {{ PromotionConditionTypeEnum.PRICE.type === formData.conditionType ? '元' : '件' }}
           </el-form-item>
           <el-form-item label="优惠内容:" label-width="100px">

+ 32 - 5
src/views/mall/promotion/rewardActivity/index.vue

@@ -27,7 +27,7 @@
           placeholder="请选择活动状态"
         >
           <el-option
-            v-for="dict in getIntDictOptions(DICT_TYPE.PROMOTION_ACTIVITY_STATUS)"
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
             :key="dict.value"
             :label="dict.label"
             :value="dict.value"
@@ -55,7 +55,7 @@
           重置
         </el-button>
         <el-button
-          v-hasPermi="['product:brand:create']"
+          v-hasPermi="['promotion:reward-activity:create']"
           plain
           type="primary"
           @click="openForm('create')"
@@ -71,6 +71,11 @@
   <ContentWrap>
     <el-table v-loading="loading" :data="list" default-expand-all row-key="id">
       <el-table-column label="活动名称" prop="name" />
+      <el-table-column label="活动范围" prop="productScope" >
+        <template #default="scope">
+          <dict-tag :type="DICT_TYPE.PROMOTION_PRODUCT_SCOPE" :value="scope.row.productScope" />
+        </template>
+      </el-table-column>
       <el-table-column
         :formatter="dateFormatter"
         align="center"
@@ -85,7 +90,7 @@
       />
       <el-table-column align="center" label="状态" prop="status">
         <template #default="scope">
-          <dict-tag :type="DICT_TYPE.PROMOTION_ACTIVITY_STATUS" :value="scope.row.status" />
+          <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
       <el-table-column
@@ -98,7 +103,7 @@
       <el-table-column align="center" label="操作">
         <template #default="scope">
           <el-button
-            v-hasPermi="['product:brand:update']"
+            v-hasPermi="['promotion:reward-activity:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
@@ -106,7 +111,16 @@
             编辑
           </el-button>
           <el-button
-            v-hasPermi="['product:brand:delete']"
+            v-if="scope.row.status === 0"
+            v-hasPermi="['promotion:reward-activity:close']"
+            link
+            type="danger"
+            @click="handleClose(scope.row.id)"
+          >
+            关闭
+          </el-button>
+          <el-button
+            v-hasPermi="['promotion:reward-activity:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
@@ -180,6 +194,19 @@ const openForm = (type: string, id?: number) => {
   formRef.value?.open(type, id)
 }
 
+/** 关闭按钮操作 */
+const handleClose = async (id: number) => {
+  try {
+    // 关闭的二次确认
+    await message.confirm('确认关闭该满减活动吗?')
+    // 发起关闭
+    await RewardActivityApi.closeRewardActivity(id)
+    message.success('关闭成功')
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
 /** 删除按钮操作 */
 const handleDelete = async (id: number) => {
   try {

+ 11 - 0
src/views/mp/components/wx-account-select/main.vue

@@ -6,6 +6,11 @@
 
 <script lang="ts" setup>
 import * as MpAccountApi from '@/api/mp/account'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+
+const message = useMessage() // 消息弹窗
+const { delView } = useTagsViewStore() // 视图操作
+const { push, currentRoute } = useRouter() // 路由
 
 defineOptions({ name: 'WxAccountSelect' })
 
@@ -22,6 +27,12 @@ const emit = defineEmits<{
 
 const handleQuery = async () => {
   accountList.value = await MpAccountApi.getSimpleAccountList()
+  if (accountList.value.length == 0) {
+    message.error('未配置公众号,请在【公众号管理 -> 账号管理】菜单,进行配置')
+    delView(unref(currentRoute))
+    await push({ name: 'MpAccount' })
+    return
+  }
   // 默认选中第一个
   if (accountList.value.length > 0) {
     account.id = accountList.value[0].id

+ 6 - 25
src/views/mp/statistics/index.vue

@@ -3,14 +3,7 @@
   <ContentWrap>
     <el-form class="-mb-15px" ref="queryForm" :inline="true" label-width="68px">
       <el-form-item label="公众号" prop="accountId">
-        <el-select v-model="accountId" @change="getSummary" class="!w-240px">
-          <el-option
-            v-for="item in accountList"
-            :key="item.id"
-            :label="item.name"
-            :value="item.id"
-          />
-        </el-select>
+        <WxAccountSelect @change="onAccountChanged" />
       </el-form-item>
       <el-form-item label="时间范围" prop="dateRange">
         <el-date-picker
@@ -76,7 +69,7 @@
 <script lang="ts" setup>
 import { formatDate, addTime, betweenDay, beginOfDay, endOfDay } from '@/utils/formatTime'
 import * as StatisticsApi from '@/api/mp/statistics'
-import * as MpAccountApi from '@/api/mp/account'
+import WxAccountSelect from '@/views/mp/components/wx-account-select'
 
 defineOptions({ name: 'MpStatistics' })
 
@@ -88,7 +81,6 @@ const dateRange = ref([
   endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24))
 ])
 const accountId = ref(-1) // 选中的公众号编号
-const accountList = ref<MpAccountApi.AccountVO[]>([]) // 公众号账号列表
 
 const xAxisDate = ref([] as any[]) // X 轴的日期范围
 // 用户增减数据图表配置项
@@ -230,13 +222,10 @@ const interfaceSummaryOption = reactive({
   ]
 })
 
-/** 加载公众号账号的列表 */
-const getAccountList = async () => {
-  accountList.value = await MpAccountApi.getSimpleAccountList()
-  // 默认选中第一个
-  if (accountList.value.length > 0) {
-    accountId.value = accountList.value[0].id!
-  }
+/** 侦听公众号变化 **/
+const onAccountChanged = (id: number) => {
+  accountId.value = id
+  getSummary()
 }
 
 /** 加载数据 */
@@ -357,12 +346,4 @@ const interfaceSummaryChart = async () => {
     })
   } catch {}
 }
-
-/** 初始化 */
-onMounted(async () => {
-  // 获取公众号下拉列表
-  await getAccountList()
-  // 加载数据
-  getSummary()
-})
 </script>

+ 1 - 1
src/views/pay/cashier/index.vue

@@ -231,7 +231,7 @@ const getDetail = async () => {
     goReturnUrl('cancel')
     return
   }
-  const data = await PayOrderApi.getOrder(id.value)
+  const data = await PayOrderApi.getOrder(id.value, true)
   payOrder.value = data
   // 1.2 无法查询到支付信息
   if (!data) {

+ 0 - 1
types/env.d.ts

@@ -19,7 +19,6 @@ interface ImportMetaEnv {
   readonly VITE_APP_DEFAULT_LOGIN_PASSWORD: string
   readonly VITE_APP_DOCALERT_ENABLE: string
   readonly VITE_BASE_URL: string
-  readonly VITE_UPLOAD_URL: string
   readonly VITE_API_URL: string
   readonly VITE_BASE_PATH: string
   readonly VITE_DROP_DEBUGGER: string