Kaynağa Gözat

!326 CRM 客户最新的代码
Merge pull request !326 from 芋道源码/dev

芋道源码 1 yıl önce
ebeveyn
işleme
4423e9b804
46 değiştirilmiş dosya ile 2926 ekleme ve 183 silme
  1. 1 8
      build/vite/index.ts
  2. 35 0
      src/api/crm/customerLimitConfig/index.ts
  3. 19 0
      src/api/crm/customerPoolConf/index.ts
  4. 48 0
      src/api/crm/permission/index.ts
  5. 5 0
      src/api/system/user/index.ts
  6. 2 22
      src/components/ColorInput/index.vue
  7. 48 0
      src/components/DiyEditor/components/mobile/MagicCube/config.ts
  8. 73 0
      src/components/DiyEditor/components/mobile/MagicCube/index.vue
  9. 76 0
      src/components/DiyEditor/components/mobile/MagicCube/property.vue
  10. 78 0
      src/components/DiyEditor/components/mobile/MenuGrid/config.ts
  11. 35 0
      src/components/DiyEditor/components/mobile/MenuGrid/index.vue
  12. 96 0
      src/components/DiyEditor/components/mobile/MenuGrid/property.vue
  13. 47 0
      src/components/DiyEditor/components/mobile/MenuList/config.ts
  14. 31 0
      src/components/DiyEditor/components/mobile/MenuList/index.vue
  15. 75 0
      src/components/DiyEditor/components/mobile/MenuList/property.vue
  16. 66 0
      src/components/DiyEditor/components/mobile/MenuSwiper/config.ts
  17. 119 0
      src/components/DiyEditor/components/mobile/MenuSwiper/index.vue
  18. 106 0
      src/components/DiyEditor/components/mobile/MenuSwiper/property.vue
  19. 97 0
      src/components/DiyEditor/components/mobile/ProductCard/config.ts
  20. 165 0
      src/components/DiyEditor/components/mobile/ProductCard/index.vue
  21. 149 0
      src/components/DiyEditor/components/mobile/ProductCard/property.vue
  22. 6 9
      src/components/DiyEditor/util.ts
  23. 59 0
      src/components/InputWithColor/index.vue
  24. 270 0
      src/components/MagicCubeEditor/index.vue
  25. 72 0
      src/components/MagicCubeEditor/util.ts
  26. 4 0
      src/config/axios/service.ts
  27. 21 0
      src/utils/color.ts
  28. 115 0
      src/views/crm/components/CrmPermissionForm.vue
  29. 156 0
      src/views/crm/components/CrmTeamList.vue
  30. 17 0
      src/views/crm/components/index.ts
  31. 10 5
      src/views/crm/contact/ContactForm.vue
  32. 1 0
      src/views/crm/contact/OwerSelect.vue
  33. 2 1
      src/views/crm/contact/detail/ContactBasicInfo.vue
  34. 1 0
      src/views/crm/contact/detail/ContactDetails.vue
  35. 2 0
      src/views/crm/contact/detail/index.vue
  36. 7 1
      src/views/crm/contact/index.vue
  37. 82 79
      src/views/crm/customer/detail/CustomerDetails.vue
  38. 8 11
      src/views/crm/customer/detail/index.vue
  39. 57 46
      src/views/crm/customer/index.vue
  40. 146 0
      src/views/crm/customerLimitConfig/CustomerLimitConfDetails.vue
  41. 224 0
      src/views/crm/customerLimitConfig/CustomerLimitConfigForm.vue
  42. 14 0
      src/views/crm/customerLimitConfig/customerLimitConf.ts
  43. 20 0
      src/views/crm/customerLimitConfig/index.vue
  44. 135 0
      src/views/crm/customerPoolConf/index.vue
  45. 126 0
      src/views/mall/product/spu/components/SpuShowcase.vue
  46. 0 1
      src/views/mall/promotion/diy/template/decorate.vue

+ 1 - 8
build/vite/index.ts

@@ -60,18 +60,11 @@ export function createVitePlugins() {
       }
     }),
     Components({
-      // 要搜索组件的目录的相对路径
-      dirs: ['src/components'],
-      // 组件的有效文件扩展名
-      extensions: ['vue', 'md'],
-      // 搜索子目录
-      deep: true,
-      include: [/\.vue$/, /\.vue\?vue/],
       // 生成自定义 `auto-components.d.ts` 全局声明
       dts: 'src/types/auto-components.d.ts',
       // 自定义组件的解析器
       resolvers: [ElementPlusResolver()],
-      exclude: [/[\\/]node_modules[\\/]/]
+      globs: ["src/components/**/**.{vue, md}", '!src/components/DiyEditor/components/mobile/**']
     }),
     EslintPlugin({
       cache: false,

+ 35 - 0
src/api/crm/customerLimitConfig/index.ts

@@ -0,0 +1,35 @@
+import request from '@/config/axios'
+
+export interface CustomerLimitConfigVO {
+  id?: number
+  type?: number
+  userIds?: string
+  deptIds?: string
+  maxCount?: number
+  dealCountEnabled?: boolean
+}
+
+// 查询客户限制配置列表
+export const getCustomerLimitConfigPage = async (params) => {
+  return await request.get({ url: `/crm/customer-limit-config/page`, params })
+}
+
+// 查询客户限制配置详情
+export const getCustomerLimitConfig = async (id: number) => {
+  return await request.get({ url: `/crm/customer-limit-config/get?id=` + id })
+}
+
+// 新增客户限制配置
+export const createCustomerLimitConfig = async (data: CustomerLimitConfigVO) => {
+  return await request.post({ url: `/crm/customer-limit-config/create`, data })
+}
+
+// 修改客户限制配置
+export const updateCustomerLimitConfig = async (data: CustomerLimitConfigVO) => {
+  return await request.put({ url: `/crm/customer-limit-config/update`, data })
+}
+
+// 删除客户限制配置
+export const deleteCustomerLimitConfig = async (id: number) => {
+  return await request.delete({ url: `/crm/customer-limit-config/delete?id=` + id })
+}

+ 19 - 0
src/api/crm/customerPoolConf/index.ts

@@ -0,0 +1,19 @@
+import request from '@/config/axios'
+
+export interface CustomerPoolConfigVO {
+  enabled?: boolean
+  contactExpireDays?: number
+  dealExpireDays?: number
+  notifyEnabled?: boolean
+  notifyDays: number
+}
+
+// 获取客户公海规则设置
+export const getCustomerPoolConfig = async () => {
+  return await request.get({ url: `/crm/customer-pool-config/get` })
+}
+
+// 更新客户公海规则设置
+export const updateCustomerPoolConfig = async (data: ConfigVO) => {
+  return await request.put({ url: `/crm/customer-pool-config/update`, data })
+}

+ 48 - 0
src/api/crm/permission/index.ts

@@ -0,0 +1,48 @@
+import request from '@/config/axios'
+
+export interface PermissionVO {
+  id?: number // 数据权限编号
+  userId: number | undefined // 用户编号
+  bizType: number | undefined // Crm 类型
+  bizId: number | undefined // Crm 类型数据编号
+  level: number | undefined // 权限级别
+  deptName?: string // 部门名称 // 岗位名称数组 TODO @puhui999:数组?
+  nickname?: string // 用户昵称
+  postNames?: string // 岗位名称数组 TODO @puhui999:数组?
+  createTime?: Date
+}
+
+// 查询团队成员列表
+export const getPermissionList = async (params) => {
+  return await request.get({ url: `/crm/permission/list`, params })
+}
+
+// 新增团队成员
+export const createPermission = async (data: PermissionVO) => {
+  return await request.post({ url: `/crm/permission/add`, data })
+}
+
+// 修改团队成员权限级别
+export const updatePermission = async (data) => {
+  return await request.put({ url: `/crm/permission/update`, data })
+}
+
+// 删除团队成员
+export const deletePermission = async (params) => {
+  return await request.delete({ url: '/crm/permission/delete', params })
+}
+
+// 退出团队
+export const quitTeam = async (id) => {
+  return await request.delete({ url: '/crm/permission/quit-team?id=' + id })
+}
+
+// 领取公海数据
+export const receive = async (data: { bizType: number; bizId: number }) => {
+  return await request.put({ url: `/crm/permission/receive`, data })
+}
+
+// 数据放入公海
+export const putPool = async (data: { bizType: number; bizId: number }) => {
+  return await request.put({ url: `/crm/permission/put-pool`, data })
+}

+ 5 - 0
src/api/system/user/index.ts

@@ -22,6 +22,11 @@ export const getUserPage = (params: PageParam) => {
   return request.get({ url: '/system/user/page', params })
 }
 
+// 查询所有用户列表
+export const getAllUser = () => {
+  return request.get({ url: '/system/user/all' })
+}
+
 // 查询用户详情
 export const getUser = (id: number) => {
   return request.get({ url: '/system/user/get?id=' + id })

+ 2 - 22
src/components/ColorInput/index.vue

@@ -1,38 +1,18 @@
 <template>
   <el-input v-model="color">
     <template #prepend>
-      <el-color-picker v-model="color" :predefine="COLORS" />
+      <el-color-picker v-model="color" :predefine="PREDEFINE_COLORS" />
     </template>
   </el-input>
 </template>
 
 <script setup lang="ts">
 import { propTypes } from '@/utils/propTypes'
+import { PREDEFINE_COLORS } from '@/utils/color'
 
 // 颜色输入框
 defineOptions({ name: 'ColorInput' })
 
-// 预设颜色
-const COLORS = [
-  '#ff4500',
-  '#ff8c00',
-  '#ffd700',
-  '#90ee90',
-  '#00ced1',
-  '#1e90ff',
-  '#c71585',
-  '#409EFF',
-  '#909399',
-  '#C0C4CC',
-  '#b7390b',
-  '#ff7800',
-  '#fad400',
-  '#5b8c5f',
-  '#00babd',
-  '#1f73c3',
-  '#711f57'
-]
-
 const props = defineProps({
   modelValue: propTypes.string.def('')
 })

+ 48 - 0
src/components/DiyEditor/components/mobile/MagicCube/config.ts

@@ -0,0 +1,48 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 广告魔方属性 */
+export interface MagicCubeProperty {
+  // 上圆角
+  borderRadiusTop: number
+  // 下圆角
+  borderRadiusBottom: number
+  // 间隔
+  space: number
+  // 导航菜单列表
+  list: MagicCubeItemProperty[]
+  // 组件样式
+  style: ComponentStyle
+}
+/** 广告魔方项目属性 */
+export interface MagicCubeItemProperty {
+  // 图标链接
+  imgUrl: string
+  // 链接
+  url: string
+  // 宽
+  width: number
+  // 高
+  height: number
+  // 上
+  top: number
+  // 左
+  left: number
+}
+
+// 定义组件
+export const component = {
+  id: 'MagicCube',
+  name: '广告魔方',
+  icon: 'bi:columns',
+  property: {
+    borderRadiusTop: 0,
+    borderRadiusBottom: 0,
+    space: 0,
+    list: [],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<MagicCubeProperty>

+ 73 - 0
src/components/DiyEditor/components/mobile/MagicCube/index.vue

@@ -0,0 +1,73 @@
+<template>
+  <div
+    class="relative"
+    :style="{ height: `${rowCount * CUBE_SIZE}px`, width: `${4 * CUBE_SIZE}px` }"
+  >
+    <div
+      v-for="(item, index) in property.list"
+      :key="index"
+      class="absolute"
+      :style="{
+        width: `${item.width * CUBE_SIZE - property.space * 2}px`,
+        height: `${item.height * CUBE_SIZE - property.space * 2}px`,
+        margin: `${property.space}px`,
+        top: `${item.top * CUBE_SIZE}px`,
+        left: `${item.left * CUBE_SIZE}px`
+      }"
+    >
+      <el-image
+        class="h-full w-full"
+        fit="cover"
+        :src="item.imgUrl"
+        :style="{
+          borderTopLeftRadius: `${property.borderRadiusTop}px`,
+          borderTopRightRadius: `${property.borderRadiusTop}px`,
+          borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
+          borderBottomRightRadius: `${property.borderRadiusBottom}px`
+        }"
+      >
+        <template #error>
+          <div class="image-slot">
+            <div
+              class="flex items-center justify-center"
+              :style="{
+                width: `${item.width * CUBE_SIZE}px`,
+                height: `${item.height * CUBE_SIZE}px`
+              }"
+            >
+              <Icon icon="ep-picture" color="gray" :size="CUBE_SIZE" />
+            </div>
+          </div>
+        </template>
+      </el-image>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { MagicCubeProperty } from './config'
+
+/** 广告魔方 */
+defineOptions({ name: 'MagicCube' })
+const props = defineProps<{ property: MagicCubeProperty }>()
+// 一个方块的大小
+const CUBE_SIZE = 93.75
+/**
+ * 计算方块的行数
+ * 行数用于计算魔方的总体高度,存在以下情况:
+ * 1. 没有数据时,默认就只显示一行的高度
+ * 2. 底部的空白不算高度,例如只有第一行有数据,那么就只显示一行的高度
+ * 3. 顶部及中间的空白算高度,例如一共有四行,只有最后一行有数据,那么也显示四行的高度
+ */
+const rowCount = computed(() => {
+  let count = 0
+  if (props.property.list.length > 0) {
+    // 最大行号
+    count = Math.max(...props.property.list.map((item) => item.bottom))
+  }
+  // 行号从 0 开始,所以加 1
+  return count + 1
+})
+</script>
+
+<style scoped lang="scss"></style>

+ 76 - 0
src/components/DiyEditor/components/mobile/MagicCube/property.vue

@@ -0,0 +1,76 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <!-- 表单 -->
+    <el-form label-width="80px" :model="formData" class="m-t-8px">
+      <el-text tag="p"> 魔方设置 </el-text>
+      <el-text type="info" size="small"> 每格尺寸187 * 187 </el-text>
+      <MagicCubeEditor
+        class="m-y-16px"
+        v-model="formData.list"
+        :rows="4"
+        :cols="4"
+        @hot-area-selected="handleHotAreaSelected"
+      />
+      <template v-for="(hotArea, index) in formData.list" :key="index">
+        <template v-if="selectedHotAreaIndex === index">
+          <el-form-item label="上传图片" :prop="`list[${index}].imgUrl`">
+            <UploadImg v-model="hotArea.imgUrl" height="80px" width="80px" />
+          </el-form-item>
+          <el-form-item label="链接" :prop="`list[${index}].url`">
+            <el-input v-model="hotArea.url" placeholder="请输入链接" />
+          </el-form-item>
+        </template>
+      </template>
+      <el-form-item label="上圆角" prop="borderRadiusTop">
+        <el-slider
+          v-model="formData.borderRadiusTop"
+          :max="100"
+          :min="0"
+          show-input
+          input-size="small"
+          :show-input-controls="false"
+        />
+      </el-form-item>
+      <el-form-item label="下圆角" prop="borderRadiusBottom">
+        <el-slider
+          v-model="formData.borderRadiusBottom"
+          :max="100"
+          :min="0"
+          show-input
+          input-size="small"
+          :show-input-controls="false"
+        />
+      </el-form-item>
+      <el-form-item label="间隔" prop="space">
+        <el-slider
+          v-model="formData.space"
+          :max="100"
+          :min="0"
+          show-input
+          input-size="small"
+          :show-input-controls="false"
+        />
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import { MagicCubeProperty } from '@/components/DiyEditor/components/mobile/MagicCube/config'
+
+/** 广告魔方属性面板 */
+defineOptions({ name: 'MagicCubeProperty' })
+
+const props = defineProps<{ modelValue: MagicCubeProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+// 选中的热区
+const selectedHotAreaIndex = ref(-1)
+const handleHotAreaSelected = (_: any, index: number) => {
+  selectedHotAreaIndex.value = index
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 78 - 0
src/components/DiyEditor/components/mobile/MenuGrid/config.ts

@@ -0,0 +1,78 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+import { cloneDeep } from 'lodash-es'
+
+/** 宫格导航属性 */
+export interface MenuGridProperty {
+  // 列数
+  column: number
+  // 导航菜单列表
+  list: MenuGridItemProperty[]
+  // 组件样式
+  style: ComponentStyle
+}
+/** 宫格导航项目属性 */
+export interface MenuGridItemProperty {
+  // 图标链接
+  iconUrl: string
+  // 标题
+  title: string
+  // 标题颜色
+  titleColor: string
+  // 副标题
+  subtitle: string
+  // 副标题颜色
+  subtitleColor: string
+  // 链接
+  url: string
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标文字
+    text: string
+    // 角标文字颜色
+    textColor: string
+    // 角标背景颜色
+    bgColor: string
+  }
+}
+
+export const EMPTY_MENU_GRID_ITEM_PROPERTY = {
+  title: '标题',
+  titleColor: '#333',
+  subtitle: '副标题',
+  subtitleColor: '#bbb',
+  badge: {
+    show: false,
+    textColor: '#fff',
+    bgColor: '#FF6000'
+  }
+} as MenuGridItemProperty
+
+// 定义组件
+export const component = {
+  id: 'MenuGrid',
+  name: '宫格导航',
+  icon: 'bi:grid-3x3-gap',
+  property: {
+    column: 3,
+    list: [cloneDeep(EMPTY_MENU_GRID_ITEM_PROPERTY)],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8,
+      marginLeft: 8,
+      marginRight: 8,
+      padding: 8,
+      paddingTop: 8,
+      paddingRight: 8,
+      paddingBottom: 8,
+      paddingLeft: 8,
+      borderRadius: 8,
+      borderTopLeftRadius: 8,
+      borderTopRightRadius: 8,
+      borderBottomRightRadius: 8,
+      borderBottomLeftRadius: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<MenuGridProperty>

+ 35 - 0
src/components/DiyEditor/components/mobile/MenuGrid/index.vue

@@ -0,0 +1,35 @@
+<template>
+  <div class="flex flex-row flex-wrap">
+    <div
+      v-for="(item, index) in property.list"
+      :key="index"
+      class="relative flex flex-col items-center p-b-14px p-t-20px"
+      :style="{ width: `${100 * (1 / property.column)}%` }"
+    >
+      <!-- 右上角角标 -->
+      <span
+        v-if="item.badge?.show"
+        class="absolute left-50% top-10px z-1 h-20px rounded-50% p-x-6px text-center text-12px leading-20px"
+        :style="{ color: item.badge.textColor, backgroundColor: item.badge.bgColor }"
+      >
+        {{ item.badge.text }}
+      </span>
+      <el-image v-if="item.iconUrl" class="h-28px w-28px" :src="item.iconUrl" />
+      <span class="m-t-8px h-16px text-12px leading-16px" :style="{ color: item.titleColor }">
+        {{ item.title }}
+      </span>
+      <span class="m-t-6px h-12px text-10px leading-12px" :style="{ color: item.subtitleColor }">
+        {{ item.subtitle }}
+      </span>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { MenuGridProperty } from './config'
+/** 宫格导航 */
+defineOptions({ name: 'MenuGrid' })
+defineProps<{ property: MenuGridProperty }>()
+</script>
+
+<style scoped lang="scss"></style>

+ 96 - 0
src/components/DiyEditor/components/mobile/MenuGrid/property.vue

@@ -0,0 +1,96 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <!-- 表单 -->
+    <el-form label-width="80px" :model="formData" class="m-t-8px">
+      <el-form-item label="每行数量" prop="column">
+        <el-radio-group v-model="formData.column">
+          <el-radio :label="3">3个</el-radio>
+          <el-radio :label="4">4个</el-radio>
+        </el-radio-group>
+      </el-form-item>
+
+      <el-text tag="p"> 菜单设置 </el-text>
+      <el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text>
+      <template v-if="formData.list.length">
+        <VueDraggable
+          class="m-t-8px"
+          :list="formData.list"
+          item-key="index"
+          handle=".drag-icon"
+          :forceFallback="true"
+          :animation="200"
+        >
+          <template #item="{ element, index }">
+            <div class="mb-4px flex flex-col gap-4px rounded bg-gray-100 p-8px">
+              <div class="flex flex-row justify-between">
+                <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+                <Icon icon="ep:delete" class="text-red-500" @click="handleDeleteMenu(index)" />
+              </div>
+              <el-form-item label="图标" prop="iconUrl">
+                <UploadImg v-model="element.iconUrl" height="80px" width="80px">
+                  <template #tip> 建议尺寸:44 * 44 </template>
+                </UploadImg>
+              </el-form-item>
+              <el-form-item label="标题" prop="title">
+                <InputWithColor v-model="element.title" v-model:color="element.titleColor" />
+              </el-form-item>
+              <el-form-item label="副标题" prop="subtitle">
+                <InputWithColor v-model="element.subtitle" v-model:color="element.subtitleColor" />
+              </el-form-item>
+              <el-form-item label="链接" prop="url">
+                <el-input v-model="element.url" />
+              </el-form-item>
+              <el-form-item label="显示角标" prop="badge.show">
+                <el-switch v-model="element.badge.show" />
+              </el-form-item>
+              <template v-if="element.badge.show">
+                <el-form-item label="角标内容" prop="badge.text">
+                  <InputWithColor
+                    v-model="element.badge.text"
+                    v-model:color="element.badge.textColor"
+                  />
+                </el-form-item>
+                <el-form-item label="背景颜色" prop="badge.bgColor">
+                  <ColorInput v-model="element.badge.bgColor" />
+                </el-form-item>
+              </template>
+            </div>
+          </template>
+        </VueDraggable>
+      </template>
+      <el-form-item label-width="0">
+        <el-button @click="handleAddMenu" type="primary" plain class="m-t-8px w-full">
+          <Icon icon="ep:plus" class="mr-5px" /> 添加菜单
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import VueDraggable from 'vuedraggable'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import {
+  EMPTY_MENU_GRID_ITEM_PROPERTY,
+  MenuGridProperty
+} from '@/components/DiyEditor/components/mobile/MenuGrid/config'
+import { cloneDeep } from 'lodash-es'
+
+/** 宫格导航属性面板 */
+defineOptions({ name: 'MenuGridProperty' })
+
+const props = defineProps<{ modelValue: MenuGridProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+/* 添加菜单 */
+const handleAddMenu = () => {
+  formData.value.list.push(cloneDeep(EMPTY_MENU_GRID_ITEM_PROPERTY))
+}
+/* 删除菜单 */
+const handleDeleteMenu = (index: number) => {
+  formData.value.list.splice(index, 1)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 47 - 0
src/components/DiyEditor/components/mobile/MenuList/config.ts

@@ -0,0 +1,47 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+import { cloneDeep } from 'lodash-es'
+
+/** 列表导航属性 */
+export interface MenuListProperty {
+  // 导航菜单列表
+  list: MenuListItemProperty[]
+  // 组件样式
+  style: ComponentStyle
+}
+/** 列表导航项目属性 */
+export interface MenuListItemProperty {
+  // 图标链接
+  iconUrl: string
+  // 标题
+  title: string
+  // 标题颜色
+  titleColor: string
+  // 副标题
+  subtitle: string
+  // 副标题颜色
+  subtitleColor: string
+  // 链接
+  url: string
+}
+
+export const EMPTY_MENU_LIST_ITEM_PROPERTY = {
+  title: '标题',
+  titleColor: '#333',
+  subtitle: '副标题',
+  subtitleColor: '#bbb'
+}
+
+// 定义组件
+export const component = {
+  id: 'MenuList',
+  name: '列表导航',
+  icon: 'fa-solid:list',
+  property: {
+    list: [cloneDeep(EMPTY_MENU_LIST_ITEM_PROPERTY)],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<MenuListProperty>

+ 31 - 0
src/components/DiyEditor/components/mobile/MenuList/index.vue

@@ -0,0 +1,31 @@
+<template>
+  <div class="min-h-42px flex flex-col">
+    <div
+      v-for="(item, index) in property.list"
+      :key="index"
+      class="item h-42px flex flex-row items-center justify-between gap-4px p-x-12px"
+    >
+      <div class="flex flex-1 flex-row items-center gap-8px">
+        <el-image v-if="item.iconUrl" class="h-16px w-16px" :src="item.iconUrl" />
+        <span class="text-16px" :style="{ color: item.titleColor }">{{ item.title }}</span>
+      </div>
+      <div class="item-center flex flex-row justify-center gap-4px">
+        <span class="text-12px" :style="{ color: item.subtitleColor }">{{ item.subtitle }}</span>
+        <Icon icon="ep-arrow-right" color="#000" :size="16" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { MenuListProperty } from './config'
+/** 列表导航 */
+defineOptions({ name: 'MenuList' })
+defineProps<{ property: MenuListProperty }>()
+</script>
+
+<style scoped lang="scss">
+.item + .item {
+  border-top: 1px solid #eee;
+}
+</style>

+ 75 - 0
src/components/DiyEditor/components/mobile/MenuList/property.vue

@@ -0,0 +1,75 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-text tag="p"> 菜单设置 </el-text>
+    <el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text>
+
+    <!-- 表单 -->
+    <el-form label-width="60px" :model="formData" class="m-t-8px">
+      <div v-if="formData.list.length">
+        <VueDraggable
+          :list="formData.list"
+          item-key="index"
+          handle=".drag-icon"
+          :forceFallback="true"
+          :animation="200"
+        >
+          <template #item="{ element, index }">
+            <div class="mb-4px flex flex-col gap-4px rounded bg-gray-100 p-8px">
+              <div class="flex flex-row justify-between">
+                <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+                <Icon icon="ep:delete" class="text-red-500" @click="handleDeleteMenu(index)" />
+              </div>
+              <el-form-item label="图标" prop="iconUrl">
+                <UploadImg v-model="element.iconUrl" height="80px" width="80px">
+                  <template #tip> 建议尺寸:44 * 44 </template>
+                </UploadImg>
+              </el-form-item>
+              <el-form-item label="标题" prop="title">
+                <InputWithColor v-model="element.title" v-model:color="element.titleColor" />
+              </el-form-item>
+              <el-form-item label="副标题" prop="subtitle">
+                <InputWithColor v-model="element.subtitle" v-model:color="element.subtitleColor" />
+              </el-form-item>
+              <el-form-item label="链接" prop="url">
+                <el-input v-model="element.url" />
+              </el-form-item>
+            </div>
+          </template>
+        </VueDraggable>
+      </div>
+      <el-form-item label-width="0">
+        <el-button @click="handleAddMenu" type="primary" plain class="m-t-8px w-full">
+          <Icon icon="ep:plus" class="mr-5px" /> 添加菜单
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import VueDraggable from 'vuedraggable'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import {
+  EMPTY_MENU_LIST_ITEM_PROPERTY,
+  MenuListProperty
+} from '@/components/DiyEditor/components/mobile/MenuList/config'
+import { cloneDeep } from 'lodash-es'
+
+/** 列表导航属性面板 */
+defineOptions({ name: 'MenuListProperty' })
+
+const props = defineProps<{ modelValue: MenuListProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+/* 添加菜单 */
+const handleAddMenu = () => {
+  formData.value.list.push(cloneDeep(EMPTY_MENU_LIST_ITEM_PROPERTY))
+}
+/* 删除菜单 */
+const handleDeleteMenu = (index: number) => {
+  formData.value.list.splice(index, 1)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 66 - 0
src/components/DiyEditor/components/mobile/MenuSwiper/config.ts

@@ -0,0 +1,66 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+import { cloneDeep } from 'lodash-es'
+
+/** 菜单导航属性 */
+export interface MenuSwiperProperty {
+  // 布局: 图标+文字 | 图标
+  layout: 'iconText' | 'icon'
+  // 行数
+  row: number
+  // 列数
+  column: number
+  // 导航菜单列表
+  list: MenuSwiperItemProperty[]
+  // 组件样式
+  style: ComponentStyle
+}
+/** 菜单导航项目属性 */
+export interface MenuSwiperItemProperty {
+  // 图标链接
+  iconUrl: string
+  // 标题
+  title: string
+  // 标题颜色
+  titleColor: string
+  // 链接
+  url: string
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标文字
+    text: string
+    // 角标文字颜色
+    textColor: string
+    // 角标背景颜色
+    bgColor: string
+  }
+}
+
+export const EMPTY_MENU_SWIPER_ITEM_PROPERTY = {
+  title: '标题',
+  titleColor: '#333',
+  badge: {
+    show: false,
+    textColor: '#fff',
+    bgColor: '#FF6000'
+  }
+} as MenuSwiperItemProperty
+
+// 定义组件
+export const component = {
+  id: 'MenuSwiper',
+  name: '菜单导航',
+  icon: 'bi:grid-3x2-gap',
+  property: {
+    layout: 'iconText',
+    row: 1,
+    column: 3,
+    list: [cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY)],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<MenuSwiperProperty>

+ 119 - 0
src/components/DiyEditor/components/mobile/MenuSwiper/index.vue

@@ -0,0 +1,119 @@
+<template>
+  <el-carousel
+    :height="`${carouselHeight}px`"
+    :autoplay="false"
+    arrow="hover"
+    indicator-position="outside"
+  >
+    <el-carousel-item v-for="(page, pageIndex) in pages" :key="pageIndex">
+      <div class="flex flex-row flex-wrap">
+        <div
+          v-for="(item, index) in page"
+          :key="index"
+          class="relative flex flex-col items-center justify-center"
+          :style="{ width: columnWidth, height: `${rowHeight}px` }"
+        >
+          <!-- 图标 + 角标 -->
+          <div class="relative" :class="`h-${ICON_SIZE}px w-${ICON_SIZE}px`">
+            <!-- 右上角角标 -->
+            <span
+              v-if="item.badge?.show"
+              class="absolute right--10px top--10px z-1 h-20px rounded-10px p-x-6px text-center text-12px leading-20px"
+              :style="{ color: item.badge.textColor, backgroundColor: item.badge.bgColor }"
+            >
+              {{ item.badge.text }}
+            </span>
+            <el-image v-if="item.iconUrl" :src="item.iconUrl" class="h-full w-full" />
+          </div>
+          <!-- 标题 -->
+          <span
+            v-if="property.layout === 'iconText'"
+            class="text-14px"
+            :style="{
+              color: item.titleColor,
+              height: `${TITLE_HEIGHT}px`,
+              lineHeight: `${TITLE_HEIGHT}px`
+            }"
+          >
+            {{ item.title }}
+          </span>
+        </div>
+      </div>
+    </el-carousel-item>
+  </el-carousel>
+</template>
+
+<script setup lang="ts">
+import { MenuSwiperProperty, MenuSwiperItemProperty } from './config'
+/** 菜单导航 */
+defineOptions({ name: 'MenuSwiper' })
+const props = defineProps<{ property: MenuSwiperProperty }>()
+// 标题的高度
+const TITLE_HEIGHT = 20
+// 图标的高度
+const ICON_SIZE = 50
+// 垂直间距:一行上下的间距
+const SPACE_Y = 16
+
+// 分页
+const pages = ref<MenuSwiperItemProperty[][]>([])
+// 轮播图高度
+const carouselHeight = ref(0)
+// 行高
+const rowHeight = ref(0)
+// 列宽
+const columnWidth = ref('')
+watch(
+  () => props.property,
+  () => {
+    // 计算列宽:每一列的百分比
+    columnWidth.value = `${100 * (1 / props.property.column)}%`
+    // 计算行高:图标 + 文字(仅显示图片时为0) + 垂直间距 * 2
+    rowHeight.value =
+      (props.property.layout === 'iconText' ? ICON_SIZE + TITLE_HEIGHT : ICON_SIZE) + SPACE_Y * 2
+    // 计算轮播的高度:行数 * 行高
+    carouselHeight.value = props.property.row * rowHeight.value
+
+    // 每页数量:行数 * 列数
+    const pageSize = props.property.row * props.property.column
+    // 清空分页
+    pages.value = []
+    // 每一页的菜单
+    let pageItems: MenuSwiperItemProperty[] = []
+    for (const item of props.property.list) {
+      // 本页满员,新建下一页
+      if (pageItems.length === pageSize) {
+        pageItems = []
+      }
+      // 增加一页
+      if (pageItems.length === 0) {
+        pages.value.push(pageItems)
+      }
+      // 本页增加一个
+      pageItems.push(item)
+    }
+  },
+  { immediate: true, deep: true }
+)
+</script>
+
+<style lang="scss">
+// 重写指示器样式,与 APP 保持一致
+:root {
+  .el-carousel__indicator {
+    padding-top: 0;
+    padding-bottom: 0;
+    .el-carousel__button {
+      --el-carousel-indicator-height: 6px;
+      --el-carousel-indicator-width: 6px;
+      --el-carousel-indicator-out-color: #ff6000;
+      border-radius: 6px;
+    }
+  }
+  .el-carousel__indicator.is-active {
+    .el-carousel__button {
+      --el-carousel-indicator-width: 12px;
+    }
+  }
+}
+</style>

+ 106 - 0
src/components/DiyEditor/components/mobile/MenuSwiper/property.vue

@@ -0,0 +1,106 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <!-- 表单 -->
+    <el-form label-width="80px" :model="formData" class="m-t-8px">
+      <el-form-item label="布局" prop="layout">
+        <el-radio-group v-model="formData.layout">
+          <el-radio label="iconText">图标+文字</el-radio>
+          <el-radio label="icon">仅图标</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="行数" prop="row">
+        <el-radio-group v-model="formData.row">
+          <el-radio :label="1">1行</el-radio>
+          <el-radio :label="2">2行</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="列数" prop="column">
+        <el-radio-group v-model="formData.column">
+          <el-radio :label="3">3列</el-radio>
+          <el-radio :label="4">4列</el-radio>
+          <el-radio :label="5">5列</el-radio>
+        </el-radio-group>
+      </el-form-item>
+
+      <el-text tag="p"> 菜单设置 </el-text>
+      <el-text type="info" size="small"> 拖动左侧的小圆点可以调整顺序 </el-text>
+      <template v-if="formData.list.length">
+        <VueDraggable
+          class="m-t-8px"
+          :list="formData.list"
+          item-key="index"
+          handle=".drag-icon"
+          :forceFallback="true"
+          :animation="200"
+        >
+          <template #item="{ element, index }">
+            <div class="mb-4px flex flex-col gap-4px rounded bg-gray-100 p-8px">
+              <div class="flex flex-row justify-between">
+                <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+                <Icon icon="ep:delete" class="text-red-500" @click="handleDeleteMenu(index)" />
+              </div>
+              <el-form-item label="图标" prop="iconUrl">
+                <UploadImg v-model="element.iconUrl" height="80px" width="80px">
+                  <template #tip> 建议尺寸:98 * 98 </template>
+                </UploadImg>
+              </el-form-item>
+              <el-form-item label="标题" prop="title">
+                <InputWithColor v-model="element.title" v-model:color="element.titleColor" />
+              </el-form-item>
+              <el-form-item label="链接" prop="url">
+                <el-input v-model="element.url" />
+              </el-form-item>
+              <el-form-item label="显示角标" prop="badge.show">
+                <el-switch v-model="element.badge.show" />
+              </el-form-item>
+              <template v-if="element.badge.show">
+                <el-form-item label="角标内容" prop="badge.text">
+                  <InputWithColor
+                    v-model="element.badge.text"
+                    v-model:color="element.badge.textColor"
+                  />
+                </el-form-item>
+                <el-form-item label="背景颜色" prop="badge.bgColor">
+                  <ColorInput v-model="element.badge.bgColor" />
+                </el-form-item>
+              </template>
+            </div>
+          </template>
+        </VueDraggable>
+      </template>
+      <el-form-item label-width="0">
+        <el-button @click="handleAddMenu" type="primary" plain class="m-t-8px w-full">
+          <Icon icon="ep:plus" class="mr-5px" /> 添加菜单
+        </el-button>
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import VueDraggable from 'vuedraggable'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import {
+  EMPTY_MENU_SWIPER_ITEM_PROPERTY,
+  MenuSwiperProperty
+} from '@/components/DiyEditor/components/mobile/MenuSwiper/config'
+import { cloneDeep } from 'lodash-es'
+
+/** 菜单导航属性面板 */
+defineOptions({ name: 'MenuSwiperProperty' })
+
+const props = defineProps<{ modelValue: MenuSwiperProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+/* 添加菜单 */
+const handleAddMenu = () => {
+  formData.value.list.push(cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY))
+}
+/* 删除菜单 */
+const handleDeleteMenu = (index: number) => {
+  formData.value.list.splice(index, 1)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 97 - 0
src/components/DiyEditor/components/mobile/ProductCard/config.ts

@@ -0,0 +1,97 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 商品卡片属性 */
+export interface ProductCardProperty {
+  // 布局类型:单列大图 | 单列小图 | 双列
+  layoutType: 'oneColBigImg' | 'oneColSmallImg' | 'twoCol'
+  // 商品字段
+  fields: {
+    // 商品名称
+    name: ProductCardFieldProperty
+    // 商品简介
+    introduction: ProductCardFieldProperty
+    // 商品价格
+    price: ProductCardFieldProperty
+    // 商品市场价
+    marketPrice: ProductCardFieldProperty
+    // 商品销量
+    salesCount: ProductCardFieldProperty
+    // 商品库存
+    stock: ProductCardFieldProperty
+  }
+  // 角标
+  badge: {
+    // 是否显示
+    show: boolean
+    // 角标图片
+    imgUrl: string
+  }
+  // 按钮
+  btnBuy: {
+    // 类型:文字 | 图片
+    type: 'text' | 'img'
+    // 文字
+    text: string
+    // 文字按钮:背景渐变起始颜色
+    bgBeginColor: string
+    // 文字按钮:背景渐变结束颜色
+    bgEndColor: string
+    // 图片按钮:图片地址
+    imgUrl: string
+  }
+  // 上圆角
+  borderRadiusTop: number
+  // 下圆角
+  borderRadiusBottom: number
+  // 间距
+  space: number
+  // 商品编号列表
+  spuIds: number[]
+  // 组件样式
+  style: ComponentStyle
+}
+// 商品字段
+export interface ProductCardFieldProperty {
+  // 是否显示
+  show: boolean
+  // 颜色
+  color: string
+}
+
+// 定义组件
+export const component = {
+  id: 'ProductCard',
+  name: '商品卡片',
+  icon: 'system-uicons:carousel',
+  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: '立即购买',
+      // todo: @owen 根据主题色配置
+      bgBeginColor: '#FF6000',
+      bgEndColor: '#FE832A',
+      imgUrl: ''
+    },
+    borderRadiusTop: 8,
+    borderRadiusBottom: 8,
+    space: 8,
+    spuIds: [],
+    style: {
+      bgType: 'color',
+      bgColor: '',
+      marginLeft: 8,
+      marginRight: 8,
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<ProductCardProperty>

+ 165 - 0
src/components/DiyEditor/components/mobile/ProductCard/index.vue

@@ -0,0 +1,165 @@
+<template>
+  <div :class="`box-content min-h-30px w-full flex flex-row flex-wrap`" ref="containerRef">
+    <div
+      class="relative box-content flex flex-row flex-wrap overflow-hidden bg-white"
+      :style="{
+        ...calculateSpace(index),
+        ...calculateWidth(),
+        borderTopLeftRadius: `${property.borderRadiusTop}px`,
+        borderTopRightRadius: `${property.borderRadiusTop}px`,
+        borderBottomLeftRadius: `${property.borderRadiusBottom}px`,
+        borderBottomRightRadius: `${property.borderRadiusBottom}px`
+      }"
+      v-for="(spu, index) in spuList"
+      :key="index"
+    >
+      <!-- 角标 -->
+      <div v-if="property.badge.show" class="absolute left-0 top-0 z-1 items-center justify-center">
+        <el-image fit="cover" :src="property.badge.imgUrl" class="h-26px w-38px" />
+      </div>
+      <!-- 商品封面图 -->
+      <div
+        :class="[
+          'h-140px',
+          {
+            'w-full': property.layoutType !== 'oneColSmallImg',
+            'w-140px': property.layoutType === 'oneColSmallImg'
+          }
+        ]"
+      >
+        <el-image fit="cover" class="h-full w-full" :src="spu.picUrl" />
+      </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"
+          class="truncate text-12px"
+          :style="{ color: property.fields.introduction.color }"
+        >
+          {{ spu.introduction }}
+        </div>
+        <div>
+          <!-- 价格 -->
+          <span
+            v-if="property.fields.price.show"
+            class="text-16px"
+            :style="{ color: property.fields.price.color }"
+          >
+            ¥{{ spu.price }}
+          </span>
+          <!-- 市场价 -->
+          <span
+            v-if="property.fields.marketPrice.show && spu.marketPrice"
+            class="ml-4px text-10px line-through"
+            :style="{ color: property.fields.marketPrice.color }"
+            >¥{{ spu.marketPrice }}</span
+          >
+        </div>
+        <div class="text-12px">
+          <!-- 销量 -->
+          <span
+            v-if="property.fields.salesCount.show"
+            :style="{ color: property.fields.salesCount.color }"
+          >
+            已售{{ (spu.salesCount || 0) + (spu.virtualSalesCount || 0) }}件
+          </span>
+          <!-- 库存 -->
+          <span v-if="property.fields.stock.show" :style="{ color: property.fields.stock.color }">
+            库存{{ spu.stock || 0 }}
+          </span>
+        </div>
+      </div>
+      <!-- 购买按钮 -->
+      <div class="absolute bottom-8px right-8px">
+        <!-- 文字按钮 -->
+        <span
+          v-if="property.btnBuy.type === 'text'"
+          class="rounded-full p-x-12px p-y-4px text-12px text-white"
+          :style="{
+            background: `linear-gradient(to right, ${property.btnBuy.bgBeginColor}, ${property.btnBuy.bgEndColor}`
+          }"
+        >
+          {{ property.btnBuy.text }}
+        </span>
+        <!-- 图片按钮 -->
+        <el-image
+          v-else
+          class="h-28px w-28px rounded-full"
+          fit="cover"
+          :src="property.btnBuy.imgUrl"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+import { ProductCardProperty } from './config'
+import * as ProductSpuApi from '@/api/mall/product/spu'
+
+/** 商品卡片 */
+defineOptions({ name: 'ProductCard' })
+// 定义属性
+const props = defineProps<{ property: ProductCardProperty }>()
+// 商品列表
+const spuList = ref<ProductSpuApi.Spu[]>([])
+watch(
+  () => props.property.spuIds,
+  async () => {
+    spuList.value = await ProductSpuApi.getSpuDetailList(props.property.spuIds)
+  },
+  {
+    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 scoped lang="scss"></style>

+ 149 - 0
src/components/DiyEditor/components/mobile/ProductCard/property.vue

@@ -0,0 +1,149 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-form label-width="80px" :model="formData">
+      <el-card header="商品列表" class="property-group" shadow="never">
+        <SpuShowcase v-model="formData.spuIds" />
+      </el-card>
+      <el-card header="商品样式" class="property-group" 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 label="oneColBigImg">
+                <Icon icon="fluent:text-column-one-24-filled" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="单列小图" placement="bottom">
+              <el-radio-button label="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 label="twoCol">
+                <Icon icon="fluent:text-column-two-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 header="角标" class="property-group" shadow="never">
+        <el-form-item label="角标" prop="badge.show">
+          <el-switch v-model="formData.badge.show" />
+        </el-form-item>
+        <el-form-item label="角标" prop="badge.imgUrl" v-if="formData.badge.show">
+          <UploadImg v-model="formData.badge.imgUrl" height="44px" width="72px">
+            <template #tip> 建议尺寸:36 * 22 </template>
+          </UploadImg>
+        </el-form-item>
+      </el-card>
+      <el-card header="按钮" class="property-group" shadow="never">
+        <el-form-item label="按钮类型" prop="btnBuy.type">
+          <el-radio-group v-model="formData.btnBuy.type">
+            <el-radio-button label="text">文字</el-radio-button>
+            <el-radio-button label="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 header="商品样式" class="property-group" shadow="never">
+        <el-form-item label="上圆角" prop="borderRadiusTop">
+          <el-slider
+            v-model="formData.borderRadiusTop"
+            :max="100"
+            :min="0"
+            show-input
+            input-size="small"
+            :show-input-controls="false"
+          />
+        </el-form-item>
+        <el-form-item label="下圆角" prop="borderRadiusBottom">
+          <el-slider
+            v-model="formData.borderRadiusBottom"
+            :max="100"
+            :min="0"
+            show-input
+            input-size="small"
+            :show-input-controls="false"
+          />
+        </el-form-item>
+        <el-form-item label="间隔" prop="space">
+          <el-slider
+            v-model="formData.space"
+            :max="100"
+            :min="0"
+            show-input
+            input-size="small"
+            :show-input-controls="false"
+          />
+        </el-form-item>
+      </el-card>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import { ProductCardProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import SpuShowcase from '@/views/mall/product/spu/components/SpuShowcase.vue'
+
+// 商品卡片属性面板
+defineOptions({ name: 'ProductCardProperty' })
+
+const props = defineProps<{ modelValue: ProductCardProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style scoped lang="scss"></style>

+ 6 - 9
src/components/DiyEditor/util.ts

@@ -100,16 +100,13 @@ export const PAGE_LIBS = [
   {
     name: '基础组件',
     extended: true,
-    components: [
-      'SearchBar',
-      'NoticeBar',
-      'GridNavigation',
-      'ListNavigation',
-      'Divider',
-      'TitleBar'
-    ]
+    components: ['SearchBar', 'NoticeBar', 'MenuSwiper', 'MenuGrid', 'MenuList']
+  },
+  {
+    name: '图文组件',
+    extended: true,
+    components: ['ImageBar', 'Carousel', 'TitleBar', 'VideoPlayer', 'Divider', 'MagicCube']
   },
-  { name: '图文组件', extended: true, components: ['ImageBar', 'Carousel', 'VideoPlayer'] },
   { name: '商品组件', extended: true, components: ['ProductCard'] },
   {
     name: '会员组件',

+ 59 - 0
src/components/InputWithColor/index.vue

@@ -0,0 +1,59 @@
+<template>
+  <el-input v-model="valueRef" v-bind="$attrs">
+    <template #append>
+      <el-color-picker v-model="colorRef" :predefine="PREDEFINE_COLORS" />
+    </template>
+  </el-input>
+</template>
+
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import { PREDEFINE_COLORS } from '@/utils/color'
+
+/**
+ * 带颜色选择器输入框
+ */
+defineOptions({ name: 'InputWithColor' })
+
+const props = defineProps({
+  modelValue: propTypes.string.def('').isRequired,
+  color: propTypes.string.def('').isRequired
+})
+
+watch(
+  () => props.modelValue,
+  (val: string) => {
+    if (val === unref(valueRef)) return
+    valueRef.value = val
+  }
+)
+
+const emit = defineEmits(['update:modelValue', 'update:color'])
+
+// 输入框的值
+const valueRef = ref(props.modelValue)
+watch(
+  () => valueRef.value,
+  (val: string) => {
+    emit('update:modelValue', val)
+  }
+)
+// 颜色
+const colorRef = ref(props.color)
+watch(
+  () => colorRef.value,
+  (val: string) => {
+    emit('update:color', val)
+  }
+)
+</script>
+<style scoped lang="scss">
+:deep(.el-input-group__append) {
+  padding: 0;
+  .el-color-picker__trigger {
+    padding: 0;
+    border-left: none;
+    border-radius: 0 var(--el-input-border-radius) var(--el-input-border-radius) 0;
+  }
+}
+</style>

+ 270 - 0
src/components/MagicCubeEditor/index.vue

@@ -0,0 +1,270 @@
+<template>
+  <div class="relative">
+    <table class="cube-table">
+      <!-- 底层:魔方矩阵 -->
+      <tbody>
+        <tr v-for="(rowCubes, row) in cubes" :key="row">
+          <td
+            v-for="(cube, col) in rowCubes"
+            :key="col"
+            :class="['cube', { active: cube.active }]"
+            :style="{
+              width: `${cubeSize}px`,
+              height: `${cubeSize}px`
+            }"
+            @click="handleCubeClick(row, col)"
+            @mouseenter="handleCellHover(row, col)"
+          >
+            <Icon icon="ep-plus" />
+          </td>
+        </tr>
+      </tbody>
+      <!-- 顶层:热区 -->
+      <div
+        v-for="(hotArea, index) in hotAreas"
+        :key="index"
+        class="hot-area"
+        :style="{
+          top: `${cubeSize * hotArea.top}px`,
+          left: `${cubeSize * hotArea.left}px`,
+          height: `${cubeSize * hotArea.height}px`,
+          width: `${cubeSize * hotArea.width}px`
+        }"
+        @click="handleHotAreaSelected(hotArea, index)"
+        @mouseover="exitHotAreaSelectMode"
+      >
+        <!-- 右上角热区删除按钮 -->
+        <div
+          v-if="selectedHotAreaIndex === index"
+          class="btn-delete"
+          @click="handleDeleteHotArea(index)"
+        >
+          <Icon icon="ep:circle-close-filled" />
+        </div>
+        {{ `${hotArea.width}×${hotArea.height}` }}
+      </div>
+    </table>
+  </div>
+</template>
+<script lang="ts" setup>
+import { propTypes } from '@/utils/propTypes'
+import * as vueTypes from 'vue-types'
+import { Point, Rect, isContains, isOverlap, createRect } from './util'
+
+// 魔方编辑器
+// 有两部分组成:
+// 1. 魔方矩阵:位于底层,由方块组件的二维表格,用于创建热区
+//    操作方法:
+//    1.1 点击其中一个方块就会进入热区选择模式
+//    1.2 再次点击另外一个方块时,结束热区选择模式
+//    1.3 在两个方块中间的区域创建热区
+//    如果两次点击的都是同一方块,就只创建一个格子的热区
+// 2. 热区:位于顶层,采用绝对定位,覆盖在魔方矩阵上面。
+defineOptions({ name: 'MagicCubeEditor' })
+
+/**
+ * 方块
+ * @property active 是否激活
+ */
+type Cube = Point & { active: boolean }
+
+// 定义属性
+const props = defineProps({
+  // 热区列表
+  modelValue: vueTypes.array<any>().isRequired,
+  // 行数,默认 4 行
+  rows: propTypes.number.def(4),
+  // 列数,默认 4 列
+  cols: propTypes.number.def(4),
+  // 方块大小,单位px,默认75px
+  cubeSize: propTypes.number.def(75)
+})
+
+// 魔方矩阵:所有的方块
+const cubes = ref<Cube[][]>([])
+// 监听行数、列数变化
+watch(
+  () => [props.rows, props.cols],
+  () => {
+    // 清空魔方
+    cubes.value = []
+    if (!props.rows || !props.cols) return
+
+    // 初始化魔方
+    for (let row = 0; row < props.rows; row++) {
+      cubes.value[row] = []
+      for (let col = 0; col < props.cols; col++) {
+        cubes.value[row].push({ x: col, y: row, active: false })
+      }
+    }
+  },
+  { immediate: true }
+)
+
+// 热区列表
+const hotAreas = ref<Rect[]>([])
+// 初始化热区
+watch(
+  () => props.modelValue,
+  () => (hotAreas.value = props.modelValue || []),
+  { immediate: true }
+)
+
+// 热区起始方块
+const hotAreaBeginCube = ref<Cube>()
+// 是否开启了热区选择模式
+const isHotAreaSelectMode = () => !!hotAreaBeginCube.value
+/**
+ * 处理鼠标点击方块
+ *
+ * @param currentRow 当前行号
+ * @param currentCol 当前列号
+ */
+const handleCubeClick = (currentRow: number, currentCol: number) => {
+  const currentCube = cubes.value[currentRow][currentCol]
+  // 情况1:进入热区选择模式
+  if (!isHotAreaSelectMode()) {
+    hotAreaBeginCube.value = currentCube
+    hotAreaBeginCube.value.active = true
+    return
+  }
+
+  // 情况2:结束热区选择模式
+  hotAreas.value.push(createRect(hotAreaBeginCube.value!, currentCube))
+  // 结束热区选择模式
+  exitHotAreaSelectMode()
+  // 创建后就选中热区
+  let hotAreaIndex = hotAreas.value.length - 1
+  handleHotAreaSelected(hotAreas.value[hotAreaIndex], hotAreaIndex)
+  // 发送热区变动通知
+  emitUpdateModelValue()
+}
+/**
+ * 处理鼠标经过方块
+ *
+ * @param currentRow 当前行号
+ * @param currentCol 当前列号
+ */
+const handleCellHover = (currentRow: number, currentCol: number) => {
+  // 当前没有进入热区选择模式
+  if (!isHotAreaSelectMode()) return
+
+  // 当前已选的区域
+  const currentSelectedArea = createRect(
+    hotAreaBeginCube.value!,
+    cubes.value[currentRow][currentCol]
+  )
+  // 热区不允许重叠
+  for (const hotArea of hotAreas.value) {
+    // 检查是否重叠
+    if (isOverlap(hotArea, currentSelectedArea)) {
+      // 结束热区选择模式
+      exitHotAreaSelectMode()
+
+      return
+    }
+  }
+
+  // 激活选中区域内部的方块
+  eachCube((_, __, cube) => {
+    cube.active = isContains(currentSelectedArea, cube)
+  })
+}
+/**
+ * 处理热区删除
+ *
+ * @param index 热区索引
+ */
+const handleDeleteHotArea = (index: number) => {
+  hotAreas.value.splice(index, 1)
+  // 结束热区选择模式
+  exitHotAreaSelectMode()
+  // 发送热区变动通知
+  emitUpdateModelValue()
+}
+
+// 发送模型更新
+const emit = defineEmits(['update:modelValue', 'hotAreaSelected'])
+// 发送热区变动通知
+const emitUpdateModelValue = () => emit('update:modelValue', hotAreas)
+
+// 热区选中
+const selectedHotAreaIndex = ref(-1)
+const handleHotAreaSelected = (hotArea: Rect, index: number) => {
+  selectedHotAreaIndex.value = index
+  emit('hotAreaSelected', hotArea, index)
+}
+
+/**
+ * 结束热区选择模式
+ */
+function exitHotAreaSelectMode() {
+  // 移除方块激活标记
+  eachCube((_, __, cube) => {
+    if (cube.active) {
+      cube.active = false
+    }
+  })
+
+  // 清除起点
+  hotAreaBeginCube.value = undefined
+}
+
+/**
+ * 迭代魔方矩阵
+ * @param callback 回调
+ */
+const eachCube = (callback: (x: number, y: number, cube: Cube) => void) => {
+  for (let x = 0; x < cubes.value.length; x++) {
+    for (let y = 0; y < cubes.value[x].length; y++) {
+      callback(x, y, cubes.value[x][y])
+    }
+  }
+}
+</script>
+<style lang="scss" scoped>
+.cube-table {
+  position: relative;
+  border-spacing: 0;
+  border-collapse: collapse;
+
+  .cube {
+    border: 1px solid var(--el-border-color);
+    text-align: center;
+    color: var(--el-text-color-secondary);
+    cursor: pointer;
+    box-sizing: border-box;
+    &.active {
+      background: var(--el-color-primary-light-9);
+    }
+  }
+
+  .hot-area {
+    position: absolute;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border: 1px solid var(--el-color-primary);
+    background: var(--el-color-primary-light-8);
+    color: var(--el-color-primary);
+    box-sizing: border-box;
+    border-spacing: 0;
+    border-collapse: collapse;
+    cursor: pointer;
+
+    .btn-delete {
+      z-index: 1;
+      position: absolute;
+      top: -8px;
+      right: -8px;
+      height: 16px;
+      width: 16px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      border-radius: 50%;
+      background-color: #fff;
+    }
+  }
+}
+</style>

+ 72 - 0
src/components/MagicCubeEditor/util.ts

@@ -0,0 +1,72 @@
+// 坐标点
+export interface Point {
+  x: number
+  y: number
+}
+
+// 矩形
+export interface Rect {
+  // 左上角 X 轴坐标
+  left: number
+  // 左上角 Y 轴坐标
+  top: number
+  // 右下角 X 轴坐标
+  right: number
+  // 右下角 Y 轴坐标
+  bottom: number
+  // 矩形宽度
+  width: number
+  // 矩形高度
+  height: number
+}
+
+/**
+ * 判断两个矩形是否重叠
+ * @param a 矩形 A
+ * @param b 矩形 B
+ */
+export const isOverlap = (a: Rect, b: Rect): boolean => {
+  return (
+    a.left < b.left + b.width &&
+    a.left + a.width > b.left &&
+    a.top < b.top + b.height &&
+    a.height + a.top > b.top
+  )
+}
+/**
+ * 检查坐标点是否在矩形内
+ * @param hotArea 矩形
+ * @param point 坐标
+ */
+export const isContains = (hotArea: Rect, point: Point): boolean => {
+  return (
+    point.x >= hotArea.left &&
+    point.x < hotArea.right &&
+    point.y >= hotArea.top &&
+    point.y < hotArea.bottom
+  )
+}
+
+/**
+ * 在两个坐标点中间,创建一个矩形
+ *
+ * 存在以下情况:
+ * 1. 两个坐标点是同一个位置,只占一个位置的正方形,宽高都为 1
+ * 2. X 轴坐标相同,只占一行的矩形,高度为 1
+ * 3. Y 轴坐标相同,只占一列的矩形,宽度为 1
+ * 4. 多行多列的矩形
+ *
+ * @param a 坐标点一
+ * @param b 坐标点二
+ */
+export const createRect = (a: Point, b: Point): Rect => {
+  // 计算矩形的范围
+  const [left, left2] = [a.x, b.x].sort()
+  const [top, top2] = [a.y, b.y].sort()
+  const right = left2 + 1
+  const bottom = top2 + 1
+  const height = bottom - top
+  const width = right - left
+
+  return { left, right, top, bottom, height, width }
+}

+ 4 - 0
src/config/axios/service.ts

@@ -217,6 +217,10 @@ const refreshToken = async () => {
 const handleAuthorized = () => {
   const { t } = useI18n()
   if (!isRelogin.show) {
+    // 如果已经到重新登录页面则不进行弹窗提示
+    if (window.location.href.includes('login?redirect=')) {
+      return
+    }
     isRelogin.show = true
     ElMessageBox.confirm(t('sys.api.timeoutMessage'), t('common.confirmTitle'), {
       showCancelButton: false,

+ 21 - 0
src/utils/color.ts

@@ -151,3 +151,24 @@ const subtractLight = (color: string, amount: number) => {
   const c = cc < 0 ? 0 : cc
   return c.toString(16).length > 1 ? c.toString(16) : `0${c.toString(16)}`
 }
+
+// 预设颜色
+export const PREDEFINE_COLORS = [
+  '#ff4500',
+  '#ff8c00',
+  '#ffd700',
+  '#90ee90',
+  '#00ced1',
+  '#1e90ff',
+  '#c71585',
+  '#409EFF',
+  '#909399',
+  '#C0C4CC',
+  '#b7390b',
+  '#ff7800',
+  '#fad400',
+  '#5b8c5f',
+  '#00babd',
+  '#1f73c3',
+  '#711f57'
+]

+ 115 - 0
src/views/crm/components/CrmPermissionForm.vue

@@ -0,0 +1,115 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle" width="30%">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="100px"
+    >
+      <el-form-item v-if="formType === 'create'" label="选择人员" prop="userId">
+        <el-select v-model="formData.userId">
+          <el-option
+            v-for="item in userOptions"
+            :key="item.id"
+            :label="item.nickname"
+            :value="item.id"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="权限级别" prop="level">
+        <el-radio-group v-model="formData.level">
+          <!-- TODO @puhui999:搞个字典配置?然后这里 remove 掉负责人 -->
+          <el-radio :label="CrmPermissionLevelEnum.READ">只读</el-radio>
+          <el-radio :label="CrmPermissionLevelEnum.WRITE">读写</el-radio>
+        </el-radio-group>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button :disabled="formLoading" type="primary" @click="submitForm">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script lang="ts" setup>
+import * as UserApi from '@/api/system/user'
+import * as PermissionApi from '@/api/crm/permission'
+import { CrmPermissionLevelEnum } from './index'
+
+defineOptions({ name: 'CrmPermissionForm' })
+
+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 userOptions = ref<UserApi.UserVO[]>([]) // 用户列表
+const formData = ref<PermissionApi.PermissionVO & { ids?: number[] }>({
+  userId: undefined, // 用户编号
+  bizType: undefined, // Crm 类型
+  bizId: undefined, // Crm 类型数据编号
+  level: undefined // 权限级别
+})
+const formRules = reactive({
+  userId: [{ required: true, message: '人员不能为空', trigger: 'blur' }],
+  level: [{ required: true, message: '权限级别不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async (type: 'create' | 'update', bizType: number, bizId: number, ids?: number[]) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type) + '团队成员'
+  formType.value = type
+  resetForm(bizType, bizId)
+  // 修改时,设置数据
+  if (ids) {
+    formData.value.ids = ids
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value
+    if (formType.value === 'create') {
+      await PermissionApi.createPermission(unref(data))
+      message.success(t('common.createSuccess'))
+    } else {
+      await PermissionApi.updatePermission(unref(data))
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = (bizType: number, bizId: number) => {
+  formRef.value?.resetFields()
+  formData.value = {
+    userId: undefined, // 用户编号
+    bizType, // Crm 类型
+    bizId, // Crm 类型数据编号
+    level: undefined // 权限级别
+  }
+}
+onMounted(async () => {
+  // 获得用户列表
+  // TODO 芋艿:用户列表的选择组件
+  userOptions.value = await UserApi.getSimpleUserList()
+})
+</script>

+ 156 - 0
src/views/crm/components/CrmTeamList.vue

@@ -0,0 +1,156 @@
+<template>
+  <!-- 操作栏 -->
+  <el-row justify="end">
+    <el-button type="primary" @click="handleAdd">
+      <Icon class="mr-5px" icon="ep:plus" />
+      新增
+    </el-button>
+    <el-button @click="handleEdit">
+      <Icon class="mr-5px" icon="ep:edit" />
+      编辑
+    </el-button>
+    <el-button @click="handleRemove">
+      <Icon class="mr-5px" icon="ep:delete" />
+      移除
+    </el-button>
+    <el-button type="danger" @click="handleQuit"> 退出团队</el-button>
+  </el-row>
+  <!--  团队成员展示 -->
+  <el-table
+    v-loading="loading"
+    :data="list"
+    :show-overflow-tooltip="true"
+    :stripe="true"
+    class="mt-20px"
+    @selection-change="handleSelectionChange"
+  >
+    <el-table-column type="selection" width="55" />
+    <el-table-column align="center" label="姓名" prop="nickname" />
+    <el-table-column align="center" label="部门" prop="deptName" />
+    <el-table-column align="center" label="岗位" prop="postNames" />
+    <el-table-column align="center" label="权限级别" prop="level">
+      <template #default="{ row }">
+        <el-tag>{{ getLevelName(row.level) }}</el-tag>
+      </template>
+    </el-table-column>
+    <el-table-column :formatter="dateFormatter" align="center" label="加入时间" prop="createTime" />
+  </el-table>
+  <CrmPermissionForm ref="crmPermissionFormRef" />
+</template>
+<script lang="ts" setup>
+// TODO @puhui999:改成 CrmPermissionList
+import { dateFormatter } from '@/utils/formatTime'
+import { ElTable } from 'element-plus'
+import * as PermissionApi from '@/api/crm/permission'
+import { useUserStoreWithOut } from '@/store/modules/user'
+import CrmPermissionForm from './CrmPermissionForm.vue'
+import { CrmPermissionLevelEnum } from './index'
+
+defineOptions({ name: 'CrmTeam' })
+
+const message = useMessage() // 消息
+
+const props = defineProps<{
+  bizType: number
+  bizId: number
+}>()
+const loading = ref(true) // 列表的加载中
+const list = ref<PermissionApi.PermissionVO[]>([
+  // TODO 测试数据
+  {
+    id: 1, // 数据权限编号
+    userId: 1, // 用户编号
+    bizType: 1, // Crm 类型
+    bizId: 1, // Crm 类型数据编号
+    level: 1, // 权限级别
+    deptName: '研发部门', // 部门名称
+    nickname: '芋道源码', // 用户昵称
+    postNames: '全栈开发工程师', // 岗位名称数组
+    createTime: new Date()
+  }
+]) // 列表的数据
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await PermissionApi.getPermissionList({
+      bizType: props.bizType,
+      bizId: props.bizId
+    })
+    list.value = data
+  } finally {
+    loading.value = false
+  }
+}
+
+// TODO @puhui999:字典格式化
+/**
+ * 获得权限级别名称
+ * @param level 权限级别
+ */
+const getLevelName = computed(() => (level: number) => {
+  switch (level) {
+    case CrmPermissionLevelEnum.OWNER:
+      return '负责人'
+    case CrmPermissionLevelEnum.READ:
+      return '只读'
+    case CrmPermissionLevelEnum.WRITE:
+      return '读写'
+    default:
+      break
+  }
+})
+// TODO @puhui999:空行稍微注意下哈;一些注释补齐下;
+const multipleSelection = ref<PermissionApi.PermissionVO[]>([])
+const handleSelectionChange = (val: PermissionApi.PermissionVO[]) => {
+  multipleSelection.value = val
+}
+// TODO @puhui999:一些变量命名,看看有没可能跟列表界面的 index.vue 保持他统一的风格;
+const crmPermissionFormRef = ref<InstanceType<typeof CrmPermissionForm>>()
+const handleEdit = () => {
+  if (multipleSelection.value?.length === 0) {
+    message.warning('请先选择团队成员后操作!')
+    return
+  }
+  const ids = multipleSelection.value?.map((item) => item.id)
+  crmPermissionFormRef.value?.open('update', props.bizType, props.bizId, ids)
+}
+const handleRemove = async () => {
+  if (multipleSelection.value?.length === 0) {
+    message.warning('请先选择团队成员后操作!')
+    return
+  }
+  await message.delConfirm()
+  const ids = multipleSelection.value?.map((item) => item.id)
+  await PermissionApi.deletePermission({
+    bizType: props.bizType,
+    bizId: props.bizId,
+    ids
+  })
+}
+const handleAdd = () => {
+  crmPermissionFormRef.value?.open('create', props.bizType, props.bizId)
+}
+
+const userStore = useUserStoreWithOut()
+const handleQuit = async () => {
+  const permission = list.value.find(
+    (item) => item.userId === userStore.getUser.id && item.level === CrmPermissionLevelEnum.OWNER
+  )
+  if (permission) {
+    message.warning('负责人不能退出团队!')
+    return
+  }
+  const userPermission = list.value.find((item) => item.userId === userStore.getUser.id)
+  await PermissionApi.quitTeam(userPermission?.id)
+}
+
+watch(
+  () => props.bizId,
+  () => {
+    getList()
+  },
+  { immediate: true, deep: true }
+)
+</script>

+ 17 - 0
src/views/crm/components/index.ts

@@ -0,0 +1,17 @@
+import CrmTeam from './CrmTeamList.vue'
+
+enum CrmBizTypeEnum {
+  CRM_LEADS = 1, // 线索
+  CRM_CUSTOMER = 2, // 客户
+  CRM_CONTACTS = 3, // 联系人
+  CRM_BUSINESS = 5, // 商机
+  CRM_CONTRACT = 6 // 合同
+}
+
+enum CrmPermissionLevelEnum {
+  OWNER = 1, // 负责人
+  READ = 2, // 读
+  WRITE = 3 // 写
+}
+
+export { CrmTeam, CrmBizTypeEnum, CrmPermissionLevelEnum }

+ 10 - 5
src/views/crm/contact/ContactForm.vue

@@ -28,6 +28,7 @@
           />
         </el-select>
       </el-form-item>
+      <!-- TODO 芋艿:封装成一个组件 -->
       <el-form-item label="客户名称" prop="customerName">
         <el-popover
           placement="bottom"
@@ -89,7 +90,6 @@
           />
         </el-select>
       </el-form-item>
-
       <el-form-item label="手机号" prop="mobile">
         <el-input v-model="formData.mobile" placeholder="请输入手机号" />
       </el-form-item>
@@ -212,6 +212,7 @@ const formRules = reactive({
 const formRef = ref() // 表单 Ref
 const ownerUserList = ref<any[]>([])
 const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+
 /** 打开弹窗 */
 const open = async (type: string, id?: number) => {
   dialogVisible.value = true
@@ -231,6 +232,8 @@ const open = async (type: string, id?: number) => {
     }
   }
 }
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
 /** 查询列表 */
 const getList = async () => {
   loading.value = true
@@ -242,7 +245,7 @@ const getList = async () => {
     loading.value = false
   }
 }
-defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
 const gotOwnerUser = (owerUserId: any) => {
   if (owerUserId !== null) {
     owerUserId.split(',').forEach((item: string) => {
@@ -254,6 +257,7 @@ const gotOwnerUser = (owerUserId: any) => {
     })
   }
 }
+
 /** 提交表单 */
 const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
 const submitForm = async () => {
@@ -305,12 +309,13 @@ const resetForm = () => {
   formRef.value?.resetFields()
   ownerUserList.value = []
 }
+
 /** 添加/修改操作 */
+// TODO @zyna:owner?拼写要注意哈;
 const owerRef = ref()
 const openOwerForm = (type: string) => {
   owerRef.value.open(type, ownerUserList.value)
 }
-
 const owerSelectValue = (value) => {
   ownerUserList.value = value.value
   formData.value.ownerUserId = undefined
@@ -322,7 +327,7 @@ const owerSelectValue = (value) => {
     }
   })
 }
-//选择客户
+// 选择客户
 const showCustomer = ref(false)
 const openCustomerSelect = () => {
   showCustomer.value = !showCustomer.value
@@ -341,7 +346,7 @@ const selectCustomer = () => {
   formData.value.customerName = multipleSelection.value.name
   showCustomer.value = !showCustomer.value
 }
-const allContactList = ref([]) //所有联系人列表
+const allContactList = ref([]) // 所有联系人列表
 onMounted(async () => {
   allContactList.value = await ContactApi.simpleAlllist()
 })

+ 1 - 0
src/views/crm/contact/OwerSelect.vue

@@ -18,6 +18,7 @@
   </Dialog>
 </template>
 <script setup lang="ts">
+// TODO 芋艿:统一选择框。
 import * as UserApi from '@/api/system/user'
 import { parseBigInt } from 'jsencrypt/lib/lib/jsbn/jsbn'
 const dialogVisible = ref(false) // 弹窗的是否展示

+ 2 - 1
src/views/crm/contact/detail/ContactBasicInfo.vue

@@ -3,7 +3,7 @@
  * @Date: 2023-11-11 14:50:11
  * @LastEditTime: 2023-11-11 14:52:47
  * @FilePath: \yudao-ui-admin-vue3\src\views\crm\contact\detail\ContactBasicInfo.vue
- * @Description: 
+ * @Description:
 -->
 <template>
   <el-col>
@@ -17,6 +17,7 @@
   </el-col>
 </template>
 <script setup lang="ts">
+// TODO 芋艿:后面在 review 么?
 import * as ContactApi from '@/api/crm/contact'
 
 const { contact } = defineProps<{ contact: ContactApi.ContactVO }>()

+ 1 - 0
src/views/crm/contact/detail/ContactDetails.vue

@@ -62,6 +62,7 @@
   </el-collapse>
 </template>
 <script setup lang="ts">
+// TODO 芋艿:后面在 review 么?
 import * as ContactApi from '@/api/crm/contact'
 import { DICT_TYPE } from '@/utils/dict'
 import { formatDate } from '@/utils/formatTime'

+ 2 - 0
src/views/crm/contact/detail/index.vue

@@ -1,4 +1,5 @@
 <template>
+  <!-- TODO 芋艿:要不要把 3 到 62 合并成一个组件 -->
   <div v-loading="loading">
     <div class="flex items-start justify-between">
       <div>
@@ -105,6 +106,7 @@ import ContactDetails from '@/views/crm/contact/detail/ContactDetails.vue'
 import ContactForm from '@/views/crm/contact/ContactForm.vue'
 import { formatDate } from '@/utils/formatTime'
 import * as CustomerApi from '@/api/crm/customer'
+// TODO 芋艿:后面在 review 么?
 
 defineOptions({ name: 'ContactDetail' })
 const { delView } = useTagsViewStore() // 视图操作

+ 7 - 1
src/views/crm/contact/index.vue

@@ -1,6 +1,7 @@
 <template>
   <ContentWrap>
     <!-- 搜索工作栏 -->
+    <!-- TODO zyna:筛选项,按照需求简化下 -->
     <el-form
       class="-mb-15px"
       :model="queryParams"
@@ -214,6 +215,7 @@ import * as UserApi from '@/api/system/user'
 import * as CustomerApi from '@/api/crm/customer'
 
 defineOptions({ name: 'CrmContact' })
+
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 
@@ -244,6 +246,7 @@ const queryParams = reactive({
 const queryFormRef = ref() // 搜索的表单
 const exportLoading = ref(false) // 导出的加载中
 const userList = ref<UserApi.UserVO[]>([]) // 用户列表
+
 /** 查询列表 */
 const getList = async () => {
   loading.value = true
@@ -301,6 +304,8 @@ const handleExport = async () => {
     exportLoading.value = false
   }
 }
+
+// TODO @zyna:这个负责人的读取,放在后端好点
 const gotOwnerUser = (owerUserId: string) => {
   let ownerName = ''
   if (owerUserId !== null) {
@@ -315,13 +320,14 @@ const gotOwnerUser = (owerUserId: string) => {
   }
   return ownerName
 }
-/** 详情页面 */
+
 /** 打开客户详情 */
 const { push } = useRouter()
 const openDetail = (id: number) => {
   push({ name: 'CrmContactDetail', params: { id } })
 }
 
+// TODO @zyna:这个上级的读取,放在后端读取,更合适;因为可能数据量比较大
 const allContactList = ref([]) //所有联系人列表
 const allCustomerList = ref([]) //客户列表
 /** 初始化 **/

+ 82 - 79
src/views/crm/customer/detail/CustomerDetails.vue

@@ -1,83 +1,86 @@
 <template>
-  <el-collapse v-model="activeNames">
-    <el-collapse-item name="basicInfo">
-      <template #title>
-        <span class="text-base font-bold">基本信息</span>
-      </template>
-      <el-descriptions :column="4">
-        <el-descriptions-item label="客户名称">
-          {{ customer.name }}
-        </el-descriptions-item>
-        <el-descriptions-item label="所属行业">
-          <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="customer.industryId" />
-        </el-descriptions-item>
-        <el-descriptions-item label="客户来源">
-          <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="customer.source" />
-        </el-descriptions-item>
-        <el-descriptions-item label="客户等级">
-          <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="customer.level" />
-        </el-descriptions-item>
-        <el-descriptions-item label="手机">
-          {{ customer.mobile }}
-        </el-descriptions-item>
-        <el-descriptions-item label="电话">
-          {{ customer.telephone }}
-        </el-descriptions-item>
-        <el-descriptions-item label="邮箱">
-          {{ customer.email }}
-        </el-descriptions-item>
-        <el-descriptions-item label="QQ">
-          {{ customer.qq }}
-        </el-descriptions-item>
-        <el-descriptions-item label="微信">
-          {{ customer.wechat }}
-        </el-descriptions-item>
-        <el-descriptions-item label="网址">
-          {{ customer.website }}
-        </el-descriptions-item>
-        <el-descriptions-item label="所在地">
-          {{ customer.areaName }}
-        </el-descriptions-item>
-        <el-descriptions-item label="详细地址">
-          {{ customer.detailAddress }}
-        </el-descriptions-item>
-        <el-descriptions-item label="下次联系时间">
-          {{ customer.contactNextTime ? formatDate(customer.contactNextTime, 'YYYY-MM-DD') : '空' }}
-        </el-descriptions-item>
-      </el-descriptions>
-      <el-descriptions :column="1">
-        <el-descriptions-item label="客户描述">
-          {{ customer.description }}
-        </el-descriptions-item>
-        <el-descriptions-item label="备注">
-          {{ customer.remark }}
-        </el-descriptions-item>
-      </el-descriptions>
-    </el-collapse-item>
-    <el-collapse-item name="systemInfo">
-      <template #title>
-        <span class="text-base font-bold">系统信息</span>
-      </template>
-      <el-descriptions :column="2">
-        <el-descriptions-item label="负责人">
-          {{ customer.ownerUserName }}
-        </el-descriptions-item>
-        <el-descriptions-item label="创建人">
-          {{ customer.creatorName }}
-        </el-descriptions-item>
-        <el-descriptions-item label="创建时间">
-          {{ customer.createTime ? formatDate(customer.createTime) : '空' }}
-        </el-descriptions-item>
-        <el-descriptions-item label="更新时间">
-          {{ customer.updateTime ? formatDate(customer.updateTime) : '空' }}
-        </el-descriptions-item>
-        <!-- TODO wanwan:要不把“最后跟进时间”放到“下次联系时间”后面 -->
-        <el-descriptions-item label="最后跟进时间">
-          {{ customer.contactLastTime ? formatDate(customer.contactLastTime) : '空' }}
-        </el-descriptions-item>
-      </el-descriptions>
-    </el-collapse-item>
-  </el-collapse>
+  <ContentWrap>
+    <el-collapse class="" v-model="activeNames">
+      <el-collapse-item name="basicInfo">
+        <template #title>
+          <span class="text-base font-bold">基本信息</span>
+        </template>
+        <el-descriptions :column="4">
+          <el-descriptions-item label="客户名称">
+            {{ customer.name }}
+          </el-descriptions-item>
+          <el-descriptions-item label="所属行业">
+            <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="customer.industryId" />
+          </el-descriptions-item>
+          <el-descriptions-item label="客户来源">
+            <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="customer.source" />
+          </el-descriptions-item>
+          <el-descriptions-item label="客户等级">
+            <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="customer.level" />
+          </el-descriptions-item>
+          <el-descriptions-item label="手机">
+            {{ customer.mobile }}
+          </el-descriptions-item>
+          <el-descriptions-item label="电话">
+            {{ customer.telephone }}
+          </el-descriptions-item>
+          <el-descriptions-item label="邮箱">
+            {{ customer.email }}
+          </el-descriptions-item>
+          <el-descriptions-item label="QQ">
+            {{ customer.qq }}
+          </el-descriptions-item>
+          <el-descriptions-item label="微信">
+            {{ customer.wechat }}
+          </el-descriptions-item>
+          <el-descriptions-item label="网址">
+            {{ customer.website }}
+          </el-descriptions-item>
+          <el-descriptions-item label="所在地">
+            {{ customer.areaName }}
+          </el-descriptions-item>
+          <el-descriptions-item label="详细地址">
+            {{ customer.detailAddress }}
+          </el-descriptions-item>
+          <el-descriptions-item label="下次联系时间">
+            {{
+              customer.contactNextTime ? formatDate(customer.contactNextTime, 'YYYY-MM-DD') : '空'
+            }}
+          </el-descriptions-item>
+          <el-descriptions-item label="最后跟进时间">
+            {{ customer.contactLastTime ? formatDate(customer.contactLastTime) : '空' }}
+          </el-descriptions-item>
+        </el-descriptions>
+        <el-descriptions :column="1">
+          <el-descriptions-item label="客户描述">
+            {{ customer.description }}
+          </el-descriptions-item>
+          <el-descriptions-item label="备注">
+            {{ customer.remark }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </el-collapse-item>
+      <el-collapse-item name="systemInfo">
+        <template #title>
+          <span class="text-base font-bold">系统信息</span>
+        </template>
+        <el-descriptions :column="2">
+          <el-descriptions-item label="负责人">
+            {{ customer.ownerUserName }}
+          </el-descriptions-item>
+          <el-descriptions-item label="创建人">
+            {{ customer.creatorName }}
+          </el-descriptions-item>
+          <el-descriptions-item label="创建时间">
+            {{ customer.createTime ? formatDate(customer.createTime) : '空' }}
+          </el-descriptions-item>
+          <el-descriptions-item label="更新时间">
+            {{ customer.updateTime ? formatDate(customer.updateTime) : '空' }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </el-collapse-item>
+    </el-collapse>
+  </ContentWrap>
 </template>
 <script setup lang="ts">
 import * as CustomerApi from '@/api/crm/customer'

+ 8 - 11
src/views/crm/customer/detail/index.vue

@@ -1,4 +1,5 @@
 <template>
+  <!-- TODO @wanwan:要不要把上面这一整块,搞成一个组件,就是把 下面 + Details + BasitcInfo 合并成一个 -->
   <div v-loading="loading">
     <div class="flex items-start justify-between">
       <div>
@@ -63,12 +64,10 @@
       </el-descriptions-item>
     </el-descriptions>
   </ContentWrap>
-  <!-- TODO wanwan:这个 tab 拉满哈,可以更好看; -->
-  <el-col :span="18">
+  <el-col>
     <el-tabs>
       <el-tab-pane label="详细资料">
-        <!-- TODO wanwan:这个 ml-2 是不是可以优化下,不要整个左移,而是里面的内容有个几 px 的偏移,不顶在框里 -->
-        <CustomerDetails class="ml-2" :customer="customer" />
+        <CustomerDetails :customer="customer" />
       </el-tab-pane>
       <el-tab-pane label="活动" lazy> 活动</el-tab-pane>
       <el-tab-pane label="邮件" lazy> 邮件</el-tab-pane>
@@ -76,20 +75,20 @@
       <el-tab-pane label="客户关系" lazy> 客户关系</el-tab-pane>
       <!-- TODO wanwan 以下标签上的数量需要接口统计返回 -->
       <el-tab-pane label="联系人" lazy>
-        <template #label> 联系人<el-badge :value="12" class="item" type="primary" /> </template>
+        <template #label> 联系人<el-badge class="item" type="primary" /> </template>
         联系人
       </el-tab-pane>
       <el-tab-pane label="团队成员" lazy>
-        <template #label> 团队成员<el-badge :value="2" class="item" type="primary" /> </template>
+        <template #label> 团队成员<el-badge class="item" type="primary" /> </template>
         团队成员
       </el-tab-pane>
       <el-tab-pane label="商机" lazy> 商机</el-tab-pane>
       <el-tab-pane label="合同" lazy>
-        <template #label> 合同<el-badge :value="3" class="item" type="primary" /> </template>
+        <template #label> 合同<el-badge class="item" type="primary" /> </template>
         合同
       </el-tab-pane>
       <el-tab-pane label="回款" lazy>
-        <template #label> 回款<el-badge :value="4" class="item" type="primary" /> </template>
+        <template #label> 回款<el-badge class="item" type="primary" /> </template>
         回款
       </el-tab-pane>
       <el-tab-pane label="回访" lazy> 回访</el-tab-pane>
@@ -118,14 +117,12 @@ const { currentRoute } = useRouter() // 路由
 const id = Number(route.params.id)
 const loading = ref(true) // 加载中
 
-// 客户详情
-const customer = ref<CustomerApi.CustomerVO>({} as CustomerApi.CustomerVO)
-
 /**
  * 获取详情
  *
  * @param id
  */
+const customer = ref<CustomerApi.CustomerVO>({} as CustomerApi.CustomerVO) // 客户详情
 const getCustomerData = async (id: number) => {
   loading.value = true
   try {

+ 57 - 46
src/views/crm/customer/index.vue

@@ -2,36 +2,36 @@
   <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="mobile">
         <el-input
           v-model="queryParams.mobile"
-          placeholder="请输入手机"
+          class="!w-240px"
           clearable
+          placeholder="请输入手机"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </el-form-item>
       <el-form-item label="所属行业" prop="industryId">
         <el-select
           v-model="queryParams.industryId"
-          placeholder="请选择所属行业"
-          clearable
           class="!w-240px"
+          clearable
+          placeholder="请选择所属行业"
         >
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_INDUSTRY)"
@@ -44,9 +44,9 @@
       <el-form-item label="客户等级" prop="level">
         <el-select
           v-model="queryParams.level"
-          placeholder="请选择客户等级"
-          clearable
           class="!w-240px"
+          clearable
+          placeholder="请选择客户等级"
         >
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_LEVEL)"
@@ -59,9 +59,9 @@
       <el-form-item label="客户来源" prop="source">
         <el-select
           v-model="queryParams.source"
-          placeholder="请选择客户来源"
-          clearable
           class="!w-240px"
+          clearable
+          placeholder="请选择客户来源"
         >
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.CRM_CUSTOMER_SOURCE)"
@@ -72,19 +72,27 @@
         </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 type="primary" @click="openForm('create')" v-hasPermi="['crm:customer:create']">
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
+        <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="['crm:customer:create']" type="primary" @click="openForm('create')">
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
         </el-button>
         <el-button
-          type="success"
+          v-hasPermi="['crm:customer:export']"
+          :loading="exportLoading"
           plain
+          type="success"
           @click="handleExport"
-          :loading="exportLoading"
-          v-hasPermi="['crm:customer:export']"
         >
-          <Icon icon="ep:download" class="mr-5px" /> 导出
+          <Icon class="mr-5px" icon="ep:download" />
+          导出
         </el-button>
       </el-form-item>
     </el-form>
@@ -92,77 +100,77 @@
 
   <!-- 列表 -->
   <ContentWrap>
-    <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
-      <el-table-column label="编号" align="center" prop="id" />
-      <el-table-column label="客户名称" align="center" prop="name" width="160" />
-      <el-table-column label="所属行业" align="center" prop="industryId" width="120">
+    <el-table v-loading="loading" :data="list" :show-overflow-tooltip="true" :stripe="true">
+      <el-table-column align="center" label="编号" prop="id" />
+      <el-table-column align="center" label="客户名称" prop="name" width="160" />
+      <el-table-column align="center" label="所属行业" prop="industryId" width="120">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_INDUSTRY" :value="scope.row.industryId" />
         </template>
       </el-table-column>
-      <el-table-column label="客户来源" align="center" prop="source" width="100">
+      <el-table-column align="center" label="客户来源" prop="source" width="100">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_SOURCE" :value="scope.row.source" />
         </template>
       </el-table-column>
-      <el-table-column label="客户等级" align="center" prop="level" width="120">
+      <el-table-column align="center" label="客户等级" prop="level" width="120">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.CRM_CUSTOMER_LEVEL" :value="scope.row.level" />
         </template>
       </el-table-column>
-      <el-table-column label="手机" align="center" prop="mobile" width="120" />
-      <el-table-column label="详细地址" align="center" prop="detailAddress" width="200" />
-      <el-table-column label="负责人" align="center" prop="ownerUserName" />
-      <el-table-column label="所属部门" align="center" prop="ownerUserDept" />
-      <el-table-column label="创建人" align="center" prop="creatorName" />
+      <el-table-column align="center" label="手机" prop="mobile" width="120" />
+      <el-table-column align="center" label="详细地址" prop="detailAddress" width="200" />
+      <el-table-column align="center" label="负责人" prop="ownerUserName" />
+      <el-table-column align="center" label="所属部门" prop="ownerUserDept" />
+      <el-table-column align="center" label="创建人" prop="creatorName" />
       <el-table-column
-        label="创建时间"
+        :formatter="dateFormatter"
         align="center"
+        label="创建时间"
         prop="createTime"
-        :formatter="dateFormatter"
         width="180px"
       />
-      <el-table-column label="成交状态" align="center" prop="dealStatus">
+      <el-table-column align="center" label="成交状态" prop="dealStatus">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealStatus" />
         </template>
       </el-table-column>
       <el-table-column
-        label="下次联系时间"
+        :formatter="dateFormatter"
         align="center"
+        label="下次联系时间"
         prop="contactNextTime"
-        :formatter="dateFormatter"
         width="180px"
       />
       <el-table-column
-        label="最后跟进时间"
+        :formatter="dateFormatter"
         align="center"
+        label="最后跟进时间"
         prop="contactLastTime"
-        :formatter="dateFormatter"
         width="180px"
       />
-      <el-table-column label="锁定状态" align="center" prop="lockStatus">
+      <el-table-column align="center" label="锁定状态" prop="lockStatus">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.lockStatus" />
         </template>
       </el-table-column>
       <!--  TODO @wanwan 距进入公海天数    -->
-      <el-table-column label="操作" align="center" min-width="150" fixed="right">
+      <el-table-column align="center" fixed="right" label="操作" min-width="150">
         <template #default="scope">
           <el-button link type="primary" @click="openDetail(scope.row.id)">详情</el-button>
           <el-button
+            v-hasPermi="['crm:customer:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
-            v-hasPermi="['crm:customer:update']"
           >
             编辑
           </el-button>
           <el-button
+            v-hasPermi="['crm:customer:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-hasPermi="['crm:customer:delete']"
           >
             删除
           </el-button>
@@ -171,23 +179,26 @@
     </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>
+  <!-- TODO 方便查看效果 TODO 芋艿:先注释了,避免演示环境报错 -->
+  <!--  <CrmTeam :biz-id="1" :biz-type="CrmBizTypeEnum.CRM_CUSTOMER" />-->
 
   <!-- 表单弹窗:添加/修改 -->
   <CustomerForm ref="formRef" @success="getList" />
 </template>
 
-<script setup lang="ts">
-import { DICT_TYPE, getBoolDictOptions, getIntDictOptions } from '@/utils/dict'
+<script lang="ts" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import download from '@/utils/download'
 import * as CustomerApi from '@/api/crm/customer'
 import CustomerForm from './CustomerForm.vue'
+import { CrmBizTypeEnum, CrmTeam } from '@/views/crm/components'
 
 defineOptions({ name: 'CrmCustomer' })
 

+ 146 - 0
src/views/crm/customerLimitConfig/CustomerLimitConfDetails.vue

@@ -0,0 +1,146 @@
+<template>
+  <el-button type="primary" plain @click="handleQuery">
+    <Icon icon="ep:refresh" class="mr-5px" /> 刷新
+  </el-button>
+  <el-button
+    type="primary"
+    plain
+    @click="openForm('create')"
+    v-hasPermi="['crm:customer-limit-config:create']"
+  >
+    <Icon icon="ep:plus" class="mr-5px" /> 新增
+  </el-button>
+  <el-table
+    v-loading="loading"
+    :data="list"
+    :stripe="true"
+    :show-overflow-tooltip="true"
+    class="mt-4"
+  >
+    <el-table-column label="编号" align="center" prop="id" />
+    <el-table-column label="规则类型" align="center" prop="type" />
+    <el-table-column label="规则适用人群" align="center" prop="userNames" />
+    <el-table-column label="规则适用部门" align="center" prop="deptNames" />
+    <el-table-column
+      :label="
+        confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT ? '拥有客户数上限' : '锁定客户数上限'
+      "
+      align="center"
+      prop="maxCount"
+    />
+    <el-table-column
+      v-if="confType === LimitConfType.CUSTOMER_QUANTITY_LIMIT"
+      label="成交客户是否占用拥有客户数"
+      align="center"
+      prop="dealCountEnabled"
+    >
+      <template #default="scope">
+        <dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="scope.row.dealCountEnabled" />
+      </template>
+    </el-table-column>
+    <el-table-column
+      label="创建时间"
+      align="center"
+      prop="createTime"
+      :formatter="dateFormatter"
+      width="180px"
+    />
+    <el-table-column label="操作" align="center" min-width="110" fixed="right">
+      <template #default="scope">
+        <el-button
+          link
+          type="primary"
+          @click="openForm('update', scope.row.id)"
+          v-hasPermi="['crm:customer-limit-config:update']"
+        >
+          编辑
+        </el-button>
+        <el-button
+          link
+          type="danger"
+          @click="handleDelete(scope.row.id)"
+          v-hasPermi="['crm:customer-limit-config:delete']"
+        >
+          删除
+        </el-button>
+      </template>
+    </el-table-column>
+  </el-table>
+  <!-- 分页 -->
+  <Pagination
+    :total="total"
+    v-model:page="queryParams.pageNo"
+    v-model:limit="queryParams.pageSize"
+    @pagination="getList"
+  />
+
+  <!-- 表单弹窗:添加/修改 -->
+  <CustomerLimitConfigForm ref="formRef" @success="getList" />
+</template>
+<script setup lang="ts">
+import { dateFormatter } from '@/utils/formatTime'
+import * as CustomerLimitConfigApi from '@/api/crm/customerLimitConfig'
+import CustomerLimitConfigForm from '@/views/crm/customerLimitConfig/CustomerLimitConfigForm.vue'
+import { LimitConfType } from '@/views/crm/customerLimitConfig/customerLimitConf'
+import { DICT_TYPE } from '@/utils/dict'
+
+defineOptions({ name: 'CustomerLimitConfDetails' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const { confType } = defineProps<{ confType: LimitConfType }>()
+
+const loading = ref(true) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref([]) // 列表的数据
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  type: confType
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await CustomerLimitConfigApi.getCustomerLimitConfigPage(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id, confType)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await CustomerLimitConfigApi.deleteCustomerLimitConfig(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>

+ 224 - 0
src/views/crm/customerLimitConfig/CustomerLimitConfigForm.vue

@@ -0,0 +1,224 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="200px"
+      v-loading="formLoading"
+    >
+      <el-form-item label="规则适用人群" prop="userIds">
+        <el-tree-select
+          v-model="formData.userIds"
+          :data="userTree"
+          :props="defaultProps"
+          check-on-click-node
+          multiple
+          filterable
+          node-key="id"
+          placeholder="请选择规则适用人群"
+        />
+      </el-form-item>
+      <el-form-item label="规则适用部门" prop="deptIds">
+        <el-tree-select
+          v-model="formData.deptIds"
+          :data="deptTree"
+          :props="defaultProps"
+          multiple
+          check-strictly
+          filterable
+          node-key="id"
+          placeholder="请选择规则适用部门"
+        />
+      </el-form-item>
+      <el-form-item
+        :label="
+          formData.type === LimitConfType.CUSTOMER_QUANTITY_LIMIT
+            ? '拥有客户数上限'
+            : '锁定客户数上限'
+        "
+        prop="maxCount"
+      >
+        <el-input-number v-model="formData.maxCount" placeholder="请输入数量上限" />
+      </el-form-item>
+      <el-form-item
+        label="成交客户是否占用拥有客户数"
+        v-if="formData.type === LimitConfType.CUSTOMER_QUANTITY_LIMIT"
+        prop="dealCountEnabled"
+      >
+        <el-switch v-model="formData.dealCountEnabled" />
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+<script setup lang="ts">
+import * as CustomerLimitConfigApi from '@/api/crm/customerLimitConfig'
+import { LimitConfType } from '@/views/crm/customerLimitConfig/customerLimitConf'
+import * as DeptApi from '@/api/system/dept'
+import { defaultProps, handleTree } from '@/utils/tree'
+import * as UserApi from '@/api/system/user'
+import { cloneDeep } from 'lodash-es'
+
+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 formData = ref({
+  id: undefined,
+  type: undefined,
+  userIds: undefined,
+  deptIds: undefined,
+  maxCount: undefined,
+  dealCountEnabled: false
+})
+const formRules = reactive({
+  type: [{ required: true, message: '规则类型不能为空', trigger: 'change' }],
+  maxCount: [{ required: true, message: '数量上限不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+// TODO @芋艿:看看怎么搞个部门选择组件
+const deptTree = ref() // 部门树形结构
+const userTree = ref() // 用户树形结构
+
+/** 打开弹窗 */
+const open = async (type: string, id?: number, limitConfType?: LimitConfType) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  // 获得部门树
+  await getDeptTree()
+  // 获得用户
+  await getUserTree()
+  // 修改时,设置数据
+  if (id) {
+    formLoading.value = true
+    try {
+      formData.value = await CustomerLimitConfigApi.getCustomerLimitConfig(id)
+    } finally {
+      formLoading.value = false
+    }
+  } else {
+    formData.value.type = limitConfType
+  }
+}
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as CustomerLimitConfigApi.CustomerLimitConfigVO
+    if (formType.value === 'create') {
+      await CustomerLimitConfigApi.createCustomerLimitConfig(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await CustomerLimitConfigApi.updateCustomerLimitConfig(data)
+      message.success(t('common.updateSuccess'))
+    }
+    dialogVisible.value = false
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    type: undefined,
+    userIds: undefined,
+    deptIds: undefined,
+    maxCount: undefined,
+    dealCountEnabled: false
+  }
+  formRef.value?.resetFields()
+}
+
+/**
+ * 获取部门树
+ */
+const getDeptTree = async () => {
+  const res = await DeptApi.getSimpleDeptList()
+  deptTree.value = []
+  deptTree.value.push(...handleTree(res))
+}
+
+/**
+ * 获取用户树
+ */
+const getUserTree = async () => {
+  const res = await UserApi.getAllUser()
+  userTree.value = []
+  userTree.value = cloneDeep(unref(deptTree))
+
+  const deptUserMap = {}
+  res.forEach((user) => {
+    if (user.dept) {
+      if (!deptUserMap[user.deptId]) {
+        deptUserMap[user.deptId] = []
+      }
+      deptUserMap[user.deptId].push(user)
+    }
+  })
+
+  handleUserData(userTree.value, deptUserMap)
+}
+
+// TODO @芋艿:看看怎么搞个用户选择的组件
+/**
+ * 处理用户树
+ *
+ * @param deptTree
+ * @param deptUserMap
+ */
+const handleUserData = (deptTree, deptUserMap) => {
+  for (let i = 0; i < deptTree.length; i++) {
+    // 如果是用户,就不用继续找部门下的用户
+    if (deptTree[i].isUser) {
+      continue
+    }
+    const users = deptUserMap[deptTree[i].id]
+    if (users) {
+      if (!deptTree[i].children) {
+        deptTree[i].children = []
+      }
+      deptTree[i].children.push(
+        ...users.map((user) => {
+          return {
+            id: user.id,
+            name: user.username + '-' + user.nickname,
+            isUser: true,
+            // 用户状态为关闭
+            disabled: user.status === 1
+          }
+        })
+      )
+    }
+
+    if (deptTree[i].children && deptTree[i].children.length !== 0) {
+      handleUserData(deptTree[i].children, deptUserMap)
+    }
+
+    // 非人员选项禁用
+    deptTree[i].disabled = true
+    // 将非人员的 id 置为空
+    deptTree[i].id = 'null'
+  }
+}
+</script>

+ 14 - 0
src/views/crm/customerLimitConfig/customerLimitConf.ts

@@ -0,0 +1,14 @@
+// TODO 可以挪到它对应的 api.ts 文件里哈
+/**
+ * 客户限制配置类型
+ */
+export enum LimitConfType {
+  /**
+   * 拥有客户数限制
+   */
+  CUSTOMER_QUANTITY_LIMIT = 1,
+  /**
+   * 锁定客户数限制
+   */
+  CUSTOMER_LOCK_LIMIT = 2
+}

+ 20 - 0
src/views/crm/customerLimitConfig/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-tabs>
+      <el-tab-pane label="拥有客户数限制">
+        <!-- TODO @wanwan:CustomerLimitConfigList,因为它是列表哈 -->
+        <CustomerLimitConfDetails :confType="LimitConfType.CUSTOMER_QUANTITY_LIMIT" />
+      </el-tab-pane>
+      <el-tab-pane label="锁定客户数限制">
+        <CustomerLimitConfDetails :confType="LimitConfType.CUSTOMER_LOCK_LIMIT" />
+      </el-tab-pane>
+    </el-tabs>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import CustomerLimitConfDetails from '@/views/crm/customerLimitConfig/CustomerLimitConfDetails.vue'
+import { LimitConfType } from '@/views/crm/customerLimitConfig/customerLimitConf'
+
+defineOptions({ name: 'CrmCustomerLimitConfig' })
+</script>

+ 135 - 0
src/views/crm/customerPoolConf/index.vue

@@ -0,0 +1,135 @@
+<template>
+  <ContentWrap>
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="160px"
+      v-loading="formLoading"
+    >
+      <el-card shadow="never">
+        <!-- 操作 -->
+        <template #header>
+          <div class="flex items-center justify-between">
+            <CardTitle title="客户公海规则设置" />
+            <el-button
+              type="primary"
+              @click="onSubmit"
+              v-hasPermi="['crm:customer-pool-config:update']"
+            >
+              保存
+            </el-button>
+          </div>
+        </template>
+        <!-- 表单 -->
+        <el-form-item label="客户公海规则设置" prop="enabled">
+          <el-radio-group v-model="formData.enabled" class="ml-4">
+            <el-radio :label="false" size="large">不启用</el-radio>
+            <el-radio :label="true" size="large">启用</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <div v-if="formData.enabled">
+          <el-form-item>
+            <el-input-number class="mr-2" v-model="formData.contactExpireDays" />
+            天不跟进或
+            <el-input-number class="mx-2" v-model="formData.dealExpireDays" />
+            天未成交
+          </el-form-item>
+          <el-form-item label="提前提醒设置" prop="notifyEnabled">
+            <el-radio-group v-model="formData.notifyEnabled" class="ml-4">
+              <el-radio :label="false" size="large">不提醒</el-radio>
+              <el-radio :label="true" size="large">提醒</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <div v-if="formData.notifyEnabled">
+            <el-form-item>
+              提前 <el-input-number class="mx-2" v-model="formData.notifyDays" /> 天提醒
+            </el-form-item>
+          </div>
+        </div>
+      </el-card>
+    </el-form>
+  </ContentWrap>
+</template>
+<script setup lang="ts">
+import * as CustomerPoolConfApi from '@/api/crm/customerPoolConf'
+import { CardTitle } from '@/components/Card'
+
+// TODO @wanwan:CustomerPoolConf =》 CustomerPoolConfig;另外,我们在 crm 目录下,新建一个 config 目录,然后把 customerPoolConfig 和 customerLimitConfig 都挪进
+defineOptions({ name: 'CustomerPoolConf' })
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const formLoading = ref(false)
+const formData = ref({
+  enabled: false,
+  contactExpireDays: 0,
+  dealExpireDays: 0,
+  notifyEnabled: false,
+  notifyDays: 0
+})
+const formRules = reactive({
+  enabled: [{ required: true, message: '是否启用客户公海不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 获取配置 */
+const getConfig = async () => {
+  try {
+    formLoading.value = true
+    const data = await CustomerPoolConfApi.getCustomerPoolConfig()
+    if (data === null) {
+      return
+    }
+    formData.value = data
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 提交配置 */
+const onSubmit = async () => {
+  // 校验表单
+  if (!formRef) return
+  const valid = await formRef.value.validate()
+  if (!valid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    const data = formData.value as unknown as CustomerPoolConfApi.CustomerPoolConfigVO
+    await CustomerPoolConfApi.updateCustomerPoolConfig(data)
+    message.success(t('common.updateSuccess'))
+    await getConfig()
+    formLoading.value = false
+  } finally {
+    formLoading.value = false
+  }
+}
+
+// TODO @wanwan:el-radio-group 选择后,触发会不会更好哈;
+watch(
+  () => formData.value.enabled,
+  (val: boolean) => {
+    if (!val) {
+      formData.value.contactExpireDays = undefined
+      formData.value.dealExpireDays = undefined
+      formData.value.notifyEnabled = false
+      formData.value.notifyDays = undefined
+    }
+  }
+)
+// TODO @wanwan:el-radio-group 选择后,触发会不会更好哈;
+watch(
+  () => formData.value.notifyEnabled,
+  (val: boolean) => {
+    if (!val) {
+      formData.value.notifyDays = undefined
+    }
+  }
+)
+
+onMounted(() => {
+  getConfig()
+})
+</script>

+ 126 - 0
src/views/mall/product/spu/components/SpuShowcase.vue

@@ -0,0 +1,126 @@
+<template>
+  <div class="flex flex-wrap items-center gap-8px">
+    <div v-for="(spu, index) in productSpus" :key="spu.id" class="select-box spu-pic">
+      <el-tooltip :content="spu.name">
+        <div class="relative h-full w-full">
+          <el-image :src="spu.picUrl" class="h-full w-full" />
+          <Icon
+            v-show="!disabled"
+            class="del-icon"
+            icon="ep:circle-close-filled"
+            @click="handleRemoveSpu(index)"
+          />
+        </div>
+      </el-tooltip>
+    </div>
+    <el-tooltip content="选择商品">
+      <div
+        v-show="!disabled"
+        v-if="!limit || limit <= productSpus.length"
+        class="select-box"
+        @click="openSpuTableSelect"
+      >
+        <Icon icon="ep:plus" />
+      </div>
+    </el-tooltip>
+  </div>
+  <!-- 商品选择对话框(表格形式) -->
+  <SpuTableSelect ref="spuTableSelectRef" multiple @change="handleSpuSelected" />
+</template>
+<script lang="ts" setup>
+import * as ProductSpuApi from '@/api/mall/product/spu'
+import SpuTableSelect from '@/views/mall/product/spu/components/SpuTableSelect.vue'
+import { propTypes } from '@/utils/propTypes'
+import { array } from 'vue-types'
+
+// 商品橱窗,一般用于与商品建立关系时使用
+// 提供功能:展示商品列表、添加商品、移除商品
+defineOptions({ name: 'SpuShowcase' })
+
+const props = defineProps({
+  modelValue: array<number>().def([]).isRequired,
+  // 限制数量:默认不限制
+  limit: propTypes.number.def(0),
+  disabled: propTypes.bool.def(false)
+})
+
+// 商品列表
+const productSpus = ref<ProductSpuApi.Spu[]>([])
+
+watch(
+  () => props.modelValue,
+  async () => {
+    if (props.modelValue.length === 0) {
+      productSpus.value = []
+      return
+    }
+    // 只有商品发生变化之后,才去查询商品
+    if (
+      productSpus.value.length === 0 ||
+      productSpus.value.some((spu) => !props.modelValue.includes(spu.id))
+    ) {
+      debugger
+      productSpus.value = await ProductSpuApi.getSpuDetailList(props.modelValue)
+    }
+  },
+  { immediate: true }
+)
+
+/** 商品表格选择对话框 */
+const spuTableSelectRef = ref()
+// 打开对话框
+const openSpuTableSelect = () => {
+  spuTableSelectRef.value.open(productSpus.value)
+}
+
+/**
+ * 选择商品后触发
+ * @param spus 选中的商品列表
+ */
+const handleSpuSelected = (spus: ProductSpuApi.Spu[]) => {
+  productSpus.value = spus
+  emitSpuChange()
+}
+
+/**
+ * 删除商品
+ * @param index 商品索引
+ */
+const handleRemoveSpu = (index: number) => {
+  productSpus.value.splice(index, 1)
+  emitSpuChange()
+}
+const emit = defineEmits(['update:modelValue', 'change'])
+const emitSpuChange = () => {
+  emit(
+    'update:modelValue',
+    productSpus.value.map((spu) => spu.id)
+  )
+  emit('change', productSpus.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;
+}
+
+.spu-pic {
+  position: relative;
+}
+
+.del-icon {
+  position: absolute;
+  top: -10px;
+  right: -10px;
+  z-index: 1;
+  width: 20px !important;
+  height: 20px !important;
+}
+</style>

+ 0 - 1
src/views/mall/promotion/diy/template/decorate.vue

@@ -78,7 +78,6 @@ const handleTemplateItemChange = () => {
   currentFormData.value = formData.value!.pages.find(
     (page: DiyPageApi.DiyPageVO) => page.name === templateItems[selectedTemplateItem.value].name
   )
-  console.log(currentFormData.value)
 }
 
 // 提交表单