Преглед на файлове

!152 合并最新的 Vue3 重构
Merge pull request !152 from 芋道源码/dev

芋道源码 преди 1 година
родител
ревизия
47f20e6d4c
променени са 47 файла, в които са добавени 2531 реда и са изтрити 557 реда
  1. 5 5
      src/api/mall/product/category.ts
  2. 39 0
      src/api/mall/product/management/spu.ts
  3. 79 0
      src/api/mall/product/management/type/skuType.ts
  4. 25 0
      src/api/mall/product/management/type/spuType.ts
  5. 2 2
      src/api/mall/product/property.ts
  6. 1 1
      src/api/mp/account/index.ts
  7. 4 4
      src/components/Form/src/helper.ts
  8. 1 1
      src/components/Form/src/types.ts
  9. 27 4
      src/router/modules/remaining.ts
  10. 6 0
      src/styles/index.scss
  11. 18 0
      src/utils/constants.ts
  12. 5 1
      src/utils/dict.ts
  13. 18 0
      src/utils/object.ts
  14. 188 92
      src/views/infra/redis/index.vue
  15. 22 31
      src/views/mall/product/category/CategoryForm.vue
  16. 2 2
      src/views/mall/product/category/index.vue
  17. 31 23
      src/views/mall/product/property/index.vue
  18. 240 0
      src/views/mall/product/spu/addForm.vue
  19. 238 0
      src/views/mall/product/spu/components/BasicInfoForm.vue
  20. 84 0
      src/views/mall/product/spu/components/DescriptionForm.vue
  21. 156 0
      src/views/mall/product/spu/components/OtherSettingsForm.vue
  22. 102 0
      src/views/mall/product/spu/components/ProductAttributes.vue
  23. 85 0
      src/views/mall/product/spu/components/ProductAttributesAddForm.vue
  24. 309 0
      src/views/mall/product/spu/components/SkuList.vue
  25. 15 0
      src/views/mall/product/spu/components/index.ts
  26. 388 0
      src/views/mall/product/spu/index.vue
  27. 78 0
      src/views/mp/autoReply/components/ReplyForm.vue
  28. 26 61
      src/views/mp/autoReply/index.vue
  29. 4 3
      src/views/mp/components/wx-account-select/main.vue
  30. 67 0
      src/views/mp/components/wx-msg/components/Msg.vue
  31. 49 0
      src/views/mp/components/wx-msg/components/MsgEvent.vue
  32. 60 0
      src/views/mp/components/wx-msg/components/MsgList.vue
  33. 41 186
      src/views/mp/components/wx-msg/main.vue
  34. 6 0
      src/views/mp/components/wx-msg/types.ts
  35. 2 2
      src/views/mp/components/wx-music/main.vue
  36. 3 3
      src/views/mp/draft/components/CoverSelect.vue
  37. 1 1
      src/views/mp/draft/components/NewsForm.vue
  38. 10 30
      src/views/mp/draft/index.vue
  39. 3 7
      src/views/mp/freePublish/index.vue
  40. 3 8
      src/views/mp/material/index.vue
  41. 28 20
      src/views/mp/menu/components/MenuPreviewer.vue
  42. 2 2
      src/views/mp/menu/index.vue
  43. 5 12
      src/views/mp/message/index.vue
  44. 2 2
      src/views/mp/statistics/index.vue
  45. 3 8
      src/views/mp/tag/index.vue
  46. 5 12
      src/views/mp/user/index.vue
  47. 43 34
      src/views/system/dict/index.vue

+ 5 - 5
src/api/mall/product/category.ts

@@ -17,17 +17,17 @@ export interface CategoryVO {
    */
   name: string
   /**
-   * 分类图
+   * 移动端分类图
    */
   picUrl: string
   /**
-   * 分类排序
+   * PC 端分类图
    */
-  sort?: number
+  bigPicUrl?: string
   /**
-   * 分类描述
+   * 分类排序
    */
-  description?: string
+  sort: number
   /**
    * 开启状态
    */

+ 39 - 0
src/api/mall/product/management/spu.ts

@@ -0,0 +1,39 @@
+import request from '@/config/axios'
+import type { SpuType } from './type/spuType' // TODO  @puhui999: type 和 api 一起放,简单一点哈~
+
+// TODO @puhui999:中英文之间有空格
+
+// 获得spu列表 TODO @puhui999:这个是 getSpuPage 哈
+export const getSpuList = (params: PageParam) => {
+  return request.get({ url: '/product/spu/page', params })
+}
+
+// 获得spu列表tabsCount
+export const getTabsCount = () => {
+  return request.get({ url: '/product/spu/tabsCount' })
+}
+
+// 创建商品spu
+export const createSpu = (data: SpuType) => {
+  return request.post({ url: '/product/spu/create', data })
+}
+
+// 更新商品spu
+export const updateSpu = (data: SpuType) => {
+  return request.put({ url: '/product/spu/update', data })
+}
+
+// 更新商品spu status
+export const updateStatus = (data: { id: number; status: number }) => {
+  return request.put({ url: '/product/spu/updateStatus', data })
+}
+
+// 获得商品 spu
+export const getSpu = (id: number) => {
+  return request.get({ url: `/product/spu/get-detail?id=${id}` })
+}
+
+// 删除商品Spu
+export const deleteSpu = (id: number) => {
+  return request.delete({ url: `/product/spu/delete?id=${id}` })
+}

+ 79 - 0
src/api/mall/product/management/type/skuType.ts

@@ -0,0 +1,79 @@
+export interface Property {
+  /**
+   * 属性编号
+   *
+   * 关联 {@link ProductPropertyDO#getId()}
+   */
+  propertyId?: number
+  /**
+   * 属性值编号
+   *
+   * 关联 {@link ProductPropertyValueDO#getId()}
+   */
+  valueId?: number
+  /**
+   * 属性值名称
+   */
+  valueName?: string
+}
+
+export interface SkuType {
+  /**
+   * 商品 SKU 编号,自增
+   */
+  id?: number
+  /**
+   * SPU 编号
+   */
+  spuId?: number
+  /**
+   * 属性数组,JSON 格式
+   */
+  properties?: Property[]
+  /**
+   * 商品价格,单位:分
+   */
+  price?: number
+  /**
+   * 市场价,单位:分
+   */
+  marketPrice?: number
+  /**
+   * 成本价,单位:分
+   */
+  costPrice?: number
+  /**
+   * 商品条码
+   */
+  barCode?: string
+  /**
+   * 图片地址
+   */
+  picUrl?: string
+  /**
+   * 库存
+   */
+  stock?: number
+  /**
+   * 商品重量,单位:kg 千克
+   */
+  weight?: number
+  /**
+   * 商品体积,单位:m^3 平米
+   */
+  volume?: number
+
+  /**
+   * 一级分销的佣金,单位:分
+   */
+  subCommissionFirstPrice?: number
+  /**
+   * 二级分销的佣金,单位:分
+   */
+  subCommissionSecondPrice?: number
+
+  /**
+   * 商品销量
+   */
+  salesCount?: number
+}

+ 25 - 0
src/api/mall/product/management/type/spuType.ts

@@ -0,0 +1,25 @@
+import { SkuType } from './skuType'
+
+export interface SpuType {
+  id?: number
+  name?: string // 商品名称
+  categoryId?: number | null // 商品分类
+  keyword?: string // 关键字
+  unit?: number | null // 单位
+  picUrl?: string // 商品封面图
+  sliderPicUrls?: string[] // 商品轮播图
+  introduction?: string // 商品简介
+  deliveryTemplateId?: number // 运费模版
+  specType?: boolean // 商品规格
+  subCommissionType?: boolean // 分销类型
+  skus: SkuType[] // sku数组
+  description?: string // 商品详情
+  sort?: string // 商品排序
+  giveIntegral?: number // 赠送积分
+  virtualSalesCount?: number // 虚拟销量
+  recommendHot?: boolean // 是否热卖
+  recommendBenefit?: boolean // 是否优惠
+  recommendBest?: boolean // 是否精品
+  recommendNew?: boolean // 是否新品
+  recommendGood?: boolean // 是否优品
+}

+ 2 - 2
src/api/mall/product/property.ts

@@ -71,8 +71,8 @@ export const getPropertyList = (params: any) => {
 }
 
 // 获得属性项列表
-export const getPropertyListAndValue = (params: any) => {
-  return request.get({ url: '/product/property/get-value-list', params })
+export const getPropertyListAndValue = (data: any) => {
+  return request.post({ url: '/product/property/get-value-list', data })
 }
 
 // ------------------------ 属性值 -------------------

+ 1 - 1
src/api/mp/account/index.ts

@@ -1,7 +1,7 @@
 import request from '@/config/axios'
 
 export interface AccountVO {
-  id?: number
+  id: number
   name: string
 }
 

+ 4 - 4
src/components/Form/src/helper.ts

@@ -1,6 +1,6 @@
 import type { Slots } from 'vue'
 import { getSlot } from '@/utils/tsxHelper'
-import { PlaceholderMoel } from './types'
+import { PlaceholderModel } from './types'
 import { FormSchema } from '@/types/form'
 import { ColProps } from '@/types/components'
 
@@ -10,7 +10,7 @@ import { ColProps } from '@/types/components'
  * @returns 返回提示信息对象
  * @description 用于自动设置placeholder
  */
-export const setTextPlaceholder = (schema: FormSchema): PlaceholderMoel => {
+export const setTextPlaceholder = (schema: FormSchema): PlaceholderModel => {
   const { t } = useI18n()
   const textMap = ['Input', 'Autocomplete', 'InputNumber', 'InputPassword']
   const selectMap = ['Select', 'SelectV2', 'TimePicker', 'DatePicker', 'TimeSelect', 'TimeSelect']
@@ -108,8 +108,8 @@ export const setItemComponentSlots = (
 /**
  *
  * @param schema Form表单结构化数组
- * @param formModel FormMoel
- * @returns FormMoel
+ * @param formModel FormModel
+ * @returns FormModel
  * @description 生成对应的formModel
  */
 export const initModel = (schema: FormSchema[], formModel: Recordable) => {

+ 1 - 1
src/components/Form/src/types.ts

@@ -1,6 +1,6 @@
 import { FormSchema } from '@/types/form'
 
-export interface PlaceholderMoel {
+export interface PlaceholderModel {
   placeholder?: string
   startPlaceholder?: string
   endPlaceholder?: string

+ 27 - 4
src/router/modules/remaining.ts

@@ -2,9 +2,9 @@ import { Layout } from '@/utils/routerHelper'
 
 const { t } = useI18n()
 /**
-* redirect: noredirect        当设置 noredirect 的时候该路由在面包屑导航中不可被点击
-* name:'router-name'          设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
-* meta : {
+ * redirect: noredirect        当设置 noredirect 的时候该路由在面包屑导航中不可被点击
+ * name:'router-name'          设定路由的名字,一定要填写不然使用<keep-alive>时会出现各种问题
+ * meta : {
     hidden: true              当设置 true 的时候该路由不会再侧边栏出现 如404,login等页面(默认 false)
 
     alwaysShow: true          当你一个路由下面的 children 声明的路由大于1个时,自动会变成嵌套的模式,
@@ -31,7 +31,7 @@ const { t } = useI18n()
 
     canTo: true               设置为true即使hidden为true,也依然可以进行路由跳转(默认 false)
   }
-**/
+ **/
 const remainingRouter: AppRouteRecordRaw[] = [
   {
     path: '/redirect',
@@ -345,6 +345,29 @@ const remainingRouter: AppRouteRecordRaw[] = [
         meta: { title: '商品属性值', icon: '', activeMenu: '/product/property' }
       }
     ]
+  },
+  {
+    path: '/product',
+    component: Layout,
+    name: 'ProductManagementEdit',
+    meta: {
+      hidden: true
+    },
+    children: [
+      {
+        path: 'productManagementAdd', // TODO @puhui999:最好拆成 add 和 edit 两个路由;添加商品;修改商品
+        component: () => import('@/views/mall/product/spu/addForm.vue'),
+        name: 'ProductManagementAdd',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'ep:edit',
+          title: '添加商品',
+          activeMenu: '/product/product-management'
+        }
+      }
+    ]
   }
 ]
 

+ 6 - 0
src/styles/index.scss

@@ -10,6 +10,12 @@
   width: 100% !important;
 }
 
+// 解决表格内容超过表格总宽度后,横向滚动条前端顶不到表格边缘的问题
+.el-scrollbar__bar {
+  display: flex;
+  justify-content: flex-start;
+}
+
 /* nprogress 适配 element-plus 的主题色 */
 #nprogress {
   & .bar {

+ 18 - 0
src/utils/constants.ts

@@ -220,3 +220,21 @@ export const PayRefundStatusEnum = {
     name: '退款关闭'
   }
 }
+
+/**
+ * 商品SPU枚举类
+ */
+export const ProductSpuStatusEnum = {
+  RECYCLE: {
+    status: -1,
+    name: '回收站'
+  },
+  DISABLE: {
+    status: 0,
+    name: '下架'
+  },
+  ENABLE: {
+    status: 1,
+    name: '上架'
+  }
+}

+ 5 - 1
src/utils/dict.ts

@@ -144,5 +144,9 @@ export enum DICT_TYPE {
 
   // ========== MP 模块 ==========
   MP_AUTO_REPLY_REQUEST_MATCH = 'mp_auto_reply_request_match', // 自动回复请求匹配类型
-  MP_MESSAGE_TYPE = 'mp_message_type' // 消息类型
+  MP_MESSAGE_TYPE = 'mp_message_type', // 消息类型
+
+  // ========== MALL 模块 ==========
+  PRODUCT_UNIT = 'product_unit', // 商品单位
+  PRODUCT_SPU_STATUS = 'product_spu_status' //商品状态
 }

+ 18 - 0
src/utils/object.ts

@@ -0,0 +1,18 @@
+// TODO @puhui999:这个方法,可以考虑放到 index.js
+/**
+ * 将值复制到目标对象,且以目标对象属性为准,例:target: {a:1} source:{a:2,b:3} 结果为:{a:2}
+ * @param target 目标对象
+ * @param source 源对象
+ */
+export const copyValueToTarget = (target, source) => {
+  const newObj = Object.assign({}, target, source)
+  // 删除多余属性
+  Object.keys(newObj).forEach((key) => {
+    // 如果不是target中的属性则删除
+    if (Object.keys(target).indexOf(key) === -1) {
+      delete newObj[key]
+    }
+  })
+  // 更新目标对象值
+  Object.assign(target, newObj)
+}

+ 188 - 92
src/views/infra/redis/index.vue

@@ -1,7 +1,6 @@
 <template>
   <doc-alert title="Redis 缓存" url="https://doc.iocoder.cn/redis-cache/" />
   <doc-alert title="本地缓存" url="https://doc.iocoder.cn/local-cache/" />
-
   <el-scrollbar height="calc(100vh - 88px - 40px - 50px)">
     <el-row>
       <!-- 基本信息 -->
@@ -51,127 +50,224 @@
       <!-- 命令统计 -->
       <el-col :span="12" class="mt-3">
         <el-card :gutter="12" shadow="hover">
-          <div ref="commandStatsRef" class="h-88"></div>
+          <Echart :options="commandStatsRefChika" :height="420" />
         </el-card>
       </el-col>
       <!-- 内存使用量统计 -->
       <el-col :span="12" class="mt-3">
         <el-card class="ml-3" :gutter="12" shadow="hover">
-          <div ref="usedmemory" class="h-88"></div>
+          <Echart :options="usedmemoryEchartChika" :height="420" />
         </el-card>
       </el-col>
     </el-row>
   </el-scrollbar>
 </template>
-<script setup lang="ts" name="InfraRedis">
-import * as echarts from 'echarts'
+<script setup lang="ts">
+import echarts from '@/plugins/echarts'
+import { GaugeChart } from 'echarts/charts'
+import { ToolboxComponent } from 'echarts/components'
 import * as RedisApi from '@/api/infra/redis'
 import { RedisMonitorInfoVO } from '@/api/infra/redis/types'
-
 const cache = ref<RedisMonitorInfoVO>()
 
 // 基本信息
 const readRedisInfo = async () => {
   const data = await RedisApi.getCache()
   cache.value = data
-  loadEchartOptions(data.commandStats)
 }
-// 图表
-const commandStatsRef = ref<HTMLElement>()
-const usedmemory = ref<HTMLDivElement>()
-
-const loadEchartOptions = (stats) => {
-  const commandStats = [] as any[]
-  const nameList = [] as string[]
-  stats.forEach((row) => {
-    commandStats.push({
-      name: row.command,
-      value: row.calls
-    })
-    nameList.push(row.command)
-  })
-
-  const commandStatsInstance = echarts.init(commandStatsRef.value!, 'macarons')
 
-  commandStatsInstance.setOption({
-    title: {
-      text: '命令统计',
-      left: 'center'
-    },
-    tooltip: {
-      trigger: 'item',
-      formatter: '{a} <br/>{b} : {c} ({d}%)'
-    },
-    legend: {
-      type: 'scroll',
-      orient: 'vertical',
-      right: 30,
-      top: 10,
-      bottom: 20,
-      data: nameList,
-      textStyle: {
-        color: '#a1a1a1'
+// 内存使用情况
+const usedmemoryEchartChika = reactive({
+  title: {
+    // 仪表盘标题。
+    text: '内存使用情况',
+    left: 'center',
+    show: true, // 是否显示标题,默认 true。
+    offsetCenter: [0, '20%'], //相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。
+    color: 'yellow', // 文字的颜色,默认 #333。
+    fontSize: 20 // 文字的字体大小,默认 15。
+  },
+  toolbox: {
+    show: false,
+    feature: {
+      restore: { show: true },
+      saveAsImage: { show: true }
+    }
+  },
+  series: [
+    {
+      name: '峰值',
+      type: 'gauge',
+      min: 0,
+      max: 50,
+      splitNumber: 10,
+      //这是指针的颜色
+      color: '#F5C74E',
+      radius: '85%',
+      center: ['50%', '50%'],
+      startAngle: 225,
+      endAngle: -45,
+      axisLine: {
+        // 坐标轴线
+        lineStyle: {
+          // 属性lineStyle控制线条样式
+          color: [
+            [0.2, '#7FFF00'],
+            [0.8, '#00FFFF'],
+            [1, '#FF0000']
+          ],
+          //width: 6 外框的大小(环的宽度)
+          width: 10
+        }
+      },
+      axisTick: {
+        // 坐标轴小标记
+        //里面的线长是5(短线)
+        length: 5, // 属性length控制线长
+        lineStyle: {
+          // 属性lineStyle控制线条样式
+          color: '#76D9D7'
+        }
+      },
+      splitLine: {
+        // 分隔线
+        length: 20, // 属性length控制线长
+        lineStyle: {
+          // 属性lineStyle(详见lineStyle)控制线条样式
+          color: '#76D9D7'
+        }
+      },
+      axisLabel: {
+        color: '#76D9D7',
+        distance: 15,
+        fontSize: 15
+      },
+      pointer: {
+        // 指针的大小
+        width: 7,
+        show: true
+      },
+      detail: {
+        textStyle: {
+          fontWeight: 'normal',
+          // 里面文字下的数值大小(50)
+          fontSize: 15,
+          color: '#FFFFFF'
+        },
+        valueAnimation: true
+      },
+      progress: {
+        show: true
       }
-    },
-    series: [
-      {
-        name: '命令',
-        type: 'pie',
-        radius: [20, 120],
-        center: ['40%', '60%'],
-        data: commandStats,
-        roseType: 'radius',
+    }
+  ]
+})
+
+// 指令使用情况
+const commandStatsRefChika = reactive({
+  title: {
+    text: '命令统计',
+    left: 'center'
+  },
+  tooltip: {
+    trigger: 'item',
+    formatter: '{a} <br/>{b} : {c} ({d}%)'
+  },
+  legend: {
+    type: 'scroll',
+    orient: 'vertical',
+    right: 30,
+    top: 10,
+    bottom: 20,
+    data: [] as any[],
+    textStyle: {
+      color: '#a1a1a1'
+    }
+  },
+  series: [
+    {
+      name: '命令',
+      type: 'pie',
+      radius: [20, 120],
+      center: ['40%', '60%'],
+      data: [] as any[],
+      roseType: 'radius',
+      label: {
+        show: true
+      },
+      emphasis: {
         label: {
           show: true
         },
-        emphasis: {
-          label: {
-            show: true
-          },
-          itemStyle: {
-            shadowBlur: 10,
-            shadowOffsetX: 0,
-            shadowColor: 'rgba(0, 0, 0, 0.5)'
-          }
+        itemStyle: {
+          shadowBlur: 10,
+          shadowOffsetX: 0,
+          shadowColor: 'rgba(0, 0, 0, 0.5)'
         }
       }
-    ]
-  })
+    }
+  ]
+})
+
+/** 加载数据 */
+const getSummary = () => {
+  // 初始化命令图表
+  initCommandStatsChart()
+  usedMemoryInstance()
+}
+
+/** 命令使用情况 */
+const initCommandStatsChart = async () => {
+  usedmemoryEchartChika.series[0].data = []
+  // 发起请求
+  try {
+    const data = await RedisApi.getCache()
+    cache.value = data
+    // 处理数据
+    const commandStats = [] as any[]
+    const nameList = [] as string[]
+    data.commandStats.forEach((row) => {
+      commandStats.push({
+        name: row.command,
+        value: row.calls
+      })
+      nameList.push(row.command)
+    })
+    commandStatsRefChika.legend.data = nameList
+    commandStatsRefChika.series[0].data = commandStats
+  } catch {}
+}
+const usedMemoryInstance = async () => {
+  try {
+    const data = await RedisApi.getCache()
+    cache.value = data
+    // 仪表盘详情,用于显示数据。
+    usedmemoryEchartChika.series[0].detail = {
+      show: true, // 是否显示详情,默认 true。
+      offsetCenter: [0, '50%'], // 相对于仪表盘中心的偏移位置,数组第一项是水平方向的偏移,第二项是垂直方向的偏移。可以是绝对的数值,也可以是相对于仪表盘半径的百分比。
+      color: 'auto', // 文字的颜色,默认 auto。
+      fontSize: 30, // 文字的字体大小,默认 15。
+      formatter: cache.value!.info.used_memory_human // 格式化函数或者字符串
+    }
 
-  const usedMemoryInstance = echarts.init(usedmemory.value!, 'macarons')
-  usedMemoryInstance.setOption({
-    title: {
-      text: '内存使用情况',
-      left: 'center'
-    },
-    tooltip: {
+    usedmemoryEchartChika.series[0].data[0] = {
+      value: cache.value!.info.used_memory_human,
+      name: '内存消耗'
+    }
+    console.log(cache.value!.info)
+    usedmemoryEchartChika.tooltip = {
       formatter: '{b} <br/>{a} : ' + cache.value!.info.used_memory_human
-    },
-    series: [
-      {
-        name: '峰值',
-        type: 'gauge',
-        min: 0,
-        max: 100,
-        progress: {
-          show: true
-        },
-        detail: {
-          formatter: cache.value!.info.used_memory_human
-        },
-        data: [
-          {
-            value: parseFloat(cache.value!.info.used_memory_human),
-            name: '内存消耗'
-          }
-        ]
-      }
-    ]
-  })
+    }
+  } catch {}
 }
 
-onBeforeMount(() => {
-  // TODO @hiiwbs 微信,优化使用 Echart 组件
+/** 初始化 **/
+onMounted(() => {
+  echarts.use([ToolboxComponent])
+  echarts.use([GaugeChart])
+  // 读取 redis 信息
   readRedisInfo()
+  // 加载数据
+  getSummary()
 })
 </script>

+ 22 - 31
src/views/mall/product/category/CategoryForm.vue

@@ -4,27 +4,30 @@
       ref="formRef"
       :model="formData"
       :rules="formRules"
-      label-width="80px"
+      label-width="120px"
       v-loading="formLoading"
     >
       <el-form-item label="上级分类" prop="parentId">
-        <el-tree-select
-          v-model="formData.parentId"
-          :data="categoryTree"
-          :props="{ label: 'name', value: 'id' }"
-          :render-after-expand="false"
-          placeholder="请选择上级分类"
-          check-strictly
-          default-expand-all
-        />
+        <el-select v-model="formData.parentId" placeholder="请选择上级分类">
+          <el-option :key="0" label="顶级分类" :value="0" />
+          <el-option
+            v-for="item in categoryList"
+            :key="item.id"
+            :label="item.name"
+            :value="item.id"
+          />
+        </el-select>
       </el-form-item>
       <el-form-item label="分类名称" prop="name">
         <el-input v-model="formData.name" placeholder="请输入分类名称" />
       </el-form-item>
-      <el-form-item label="分类图" prop="picUrl">
+      <el-form-item label="移动端分类图" prop="picUrl">
         <UploadImg v-model="formData.picUrl" :limit="1" :is-show-tip="false" />
-        <div v-if="formData.parentId === 0" style="font-size: 10px">推荐 200x100 图片分辨率</div>
-        <div v-else style="font-size: 10px">推荐 100x100 图片分辨率</div>
+        <div style="font-size: 10px" class="pl-10px">推荐 180x180 图片分辨率</div>
+      </el-form-item>
+      <el-form-item label="PC 端分类图" prop="bigPicUrl">
+        <UploadImg v-model="formData.bigPicUrl" :limit="1" :is-show-tip="false" />
+        <div style="font-size: 10px" class="pl-10px">推荐 468x340 图片分辨率</div>
       </el-form-item>
       <el-form-item label="分类排序" prop="sort">
         <el-input-number v-model="formData.sort" controls-position="right" :min="0" />
@@ -40,9 +43,6 @@
           </el-radio>
         </el-radio-group>
       </el-form-item>
-      <el-form-item label="分类描述">
-        <el-input v-model="formData.description" type="textarea" placeholder="请输入分类描述" />
-      </el-form-item>
     </el-form>
     <template #footer>
       <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
@@ -53,7 +53,6 @@
 <script setup lang="ts" name="ProductCategory">
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { CommonStatusEnum } from '@/utils/constants'
-import { handleTree } from '@/utils/tree'
 import * as ProductCategoryApi from '@/api/mall/product/category'
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
@@ -66,8 +65,8 @@ const formData = ref({
   id: undefined,
   name: '',
   picUrl: '',
-  status: CommonStatusEnum.ENABLE,
-  description: ''
+  bigPicUrl: '',
+  status: CommonStatusEnum.ENABLE
 })
 const formRules = reactive({
   parentId: [{ required: true, message: '请选择上级分类', trigger: 'blur' }],
@@ -77,7 +76,7 @@ const formRules = reactive({
   status: [{ required: true, message: '开启状态不能为空', trigger: 'blur' }]
 })
 const formRef = ref() // 表单 Ref
-const categoryTree = ref<any[]>([]) // 分类树
+const categoryList = ref<any[]>([]) // 分类树
 
 /** 打开弹窗 */
 const open = async (type: string, id?: number) => {
@@ -95,7 +94,7 @@ const open = async (type: string, id?: number) => {
     }
   }
   // 获得分类树
-  await getTree()
+  categoryList.value = await ProductCategoryApi.getCategoryList({ parentId: 0 })
 }
 defineExpose({ open }) // 提供 open 方法,用于打开弹窗
 
@@ -131,17 +130,9 @@ const resetForm = () => {
     id: undefined,
     name: '',
     picUrl: '',
-    status: CommonStatusEnum.ENABLE,
-    description: ''
+    bigPicUrl: '',
+    status: CommonStatusEnum.ENABLE
   }
   formRef.value?.resetFields()
 }
-
-/** 获得分类树 */
-const getTree = async () => {
-  const data = await ProductCategoryApi.getCategoryList({})
-  const tree = handleTree(data, 'id', 'parentId')
-  const menu = { id: 0, name: '顶级分类', children: tree }
-  categoryTree.value = [menu]
-}
 </script>

+ 2 - 2
src/views/mall/product/category/index.vue

@@ -36,9 +36,9 @@
   <ContentWrap>
     <el-table v-loading="loading" :data="list" row-key="id" default-expand-all>
       <el-table-column label="分类名称" prop="name" sortable />
-      <el-table-column label="分类图" align="center" prop="picUrl">
+      <el-table-column label="移动端分类图" align="center" prop="picUrl">
         <template #default="scope">
-          <img v-if="scope.row.picUrl" :src="scope.row.picUrl" alt="分类图" class="h-100px" />
+          <img v-if="scope.row.picUrl" :src="scope.row.picUrl" alt="移动端分类图" class="h-100px" />
         </template>
       </el-table-column>
       <el-table-column label="分类排序" align="center" prop="sort" />

+ 31 - 23
src/views/mall/product/property/index.vue

@@ -2,42 +2,49 @@
   <!-- 搜索工作栏 -->
   <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="createTime">
         <el-date-picker
           v-model="queryParams.createTime"
-          value-format="YYYY-MM-DD HH:mm:ss"
-          type="daterange"
-          start-placeholder="开始日期"
-          end-placeholder="结束日期"
           :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
           class="!w-240px"
+          end-placeholder="结束日期"
+          start-placeholder="开始日期"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
         />
       </el-form-item>
       <el-form-item>
-        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
         <el-button
+          v-hasPermi="['product:property:create']"
           plain
           type="primary"
           @click="openForm('create')"
-          v-hasPermi="['product:property:create']"
         >
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
         </el-button>
       </el-form-item>
     </el-form>
@@ -46,23 +53,23 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="编号" align="center" prop="id" />
-      <el-table-column label="名称" align="center" />
-      <el-table-column label="备注" align="center" prop="remark" :show-overflow-tooltip="true" />
+      <el-table-column align="center" label="编号" prop="id" />
+      <el-table-column align="center" label="名称" prop="name" />
+      <el-table-column :show-overflow-tooltip="true" align="center" label="备注" prop="remark" />
       <el-table-column
-        label="创建时间"
+        :formatter="dateFormatter"
         align="center"
+        label="创建时间"
         prop="createTime"
         width="180"
-        :formatter="dateFormatter"
       />
-      <el-table-column label="操作" align="center">
+      <el-table-column align="center" label="操作">
         <template #default="scope">
           <el-button
+            v-hasPermi="['product:property:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
-            v-hasPermi="['product:property:update']"
           >
             编辑
           </el-button>
@@ -70,10 +77,10 @@
             <router-link :to="'/property/value/' + scope.row.id">属性值</router-link>
           </el-button>
           <el-button
+            v-hasPermi="['product:property:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-hasPermi="['product:property:delete']"
           >
             删除
           </el-button>
@@ -82,9 +89,9 @@
     </el-table>
     <!-- 分页 -->
     <Pagination
-      :total="total"
-      v-model:page="queryParams.pageNo"
       v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
       @pagination="getList"
     />
   </ContentWrap>
@@ -92,10 +99,11 @@
   <!-- 表单弹窗:添加/修改 -->
   <PropertyForm ref="formRef" @success="getList" />
 </template>
-<script setup lang="ts" name="ProductProperty">
+<script lang="ts" name="ProductProperty" setup>
 import { dateFormatter } from '@/utils/formatTime'
 import * as PropertyApi from '@/api/mall/product/property'
 import PropertyForm from './PropertyForm.vue'
+
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化
 

+ 240 - 0
src/views/mall/product/spu/addForm.vue

@@ -0,0 +1,240 @@
+<template>
+  <ContentWrap v-loading="formLoading">
+    <el-tabs v-model="activeName">
+      <el-tab-pane label="商品信息" name="basicInfo">
+        <BasicInfoForm
+          ref="BasicInfoRef"
+          v-model:activeName="activeName"
+          :propFormData="formData"
+        />
+      </el-tab-pane>
+      <el-tab-pane label="商品详情" name="description">
+        <DescriptionForm
+          ref="DescriptionRef"
+          v-model:activeName="activeName"
+          :propFormData="formData"
+        />
+      </el-tab-pane>
+      <el-tab-pane label="其他设置" name="otherSettings">
+        <OtherSettingsForm
+          ref="OtherSettingsRef"
+          v-model:activeName="activeName"
+          :propFormData="formData"
+        />
+      </el-tab-pane>
+    </el-tabs>
+    <el-form>
+      <el-form-item style="float: right">
+        <el-button :loading="formLoading" type="primary" @click="submitForm">保存</el-button>
+        <el-button @click="close">返回</el-button>
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+</template>
+<script lang="ts" name="ProductManagementForm" setup>
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { BasicInfoForm, DescriptionForm, OtherSettingsForm } from './components'
+import type { SpuType } from '@/api/mall/product/management/type/spuType' // 业务api
+import * as managementApi from '@/api/mall/product/management/spu'
+import * as PropertyApi from '@/api/mall/product/property'
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+const { push, currentRoute } = useRouter() // 路由
+const { query } = useRoute() // 查询参数
+const { delView } = useTagsViewStore() // 视图操作
+
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const activeName = ref('basicInfo') // Tag 激活的窗口
+const BasicInfoRef = ref<ComponentRef<typeof BasicInfoForm>>() // 商品信息Ref
+const DescriptionRef = ref<ComponentRef<typeof DescriptionForm>>() // 商品详情Ref
+const OtherSettingsRef = ref<ComponentRef<typeof OtherSettingsForm>>() // 其他设置Ref
+const formData = ref<SpuType>({
+  name: '213', // 商品名称
+  categoryId: null, // 商品分类
+  keyword: '213', // 关键字
+  unit: null, // 单位
+  picUrl:
+    'http://127.0.0.1:48080/admin-api/infra/file/4/get/6ffdf8f5dfc03f7ceec1ff1ebc138adb8b721a057d505ccfb0e61a8783af1371.png', // 商品封面图
+  sliderPicUrls: [
+    {
+      name: 'http://127.0.0.1:48080/admin-api/infra/file/4/get/6ffdf8f5dfc03f7ceec1ff1ebc138adb8b721a057d505ccfb0e61a8783af1371.png',
+      url: 'http://127.0.0.1:48080/admin-api/infra/file/4/get/6ffdf8f5dfc03f7ceec1ff1ebc138adb8b721a057d505ccfb0e61a8783af1371.png'
+    }
+  ], // 商品轮播图
+  introduction: '213', // 商品简介
+  deliveryTemplateId: 0, // 运费模版
+  specType: false, // 商品规格
+  subCommissionType: false, // 分销类型
+  skus: [
+    {
+      /**
+       * 商品价格,单位:分 TODO @puhui999:注释放在尾巴哈,简洁一点~
+       */
+      price: 0,
+      /**
+       * 市场价,单位:分
+       */
+      marketPrice: 0,
+      /**
+       * 成本价,单位:分
+       */
+      costPrice: 0,
+      /**
+       * 商品条码
+       */
+      barCode: '',
+      /**
+       * 图片地址
+       */
+      picUrl: '',
+      /**
+       * 库存
+       */
+      stock: 0,
+      /**
+       * 商品重量,单位:kg 千克
+       */
+      weight: 0,
+      /**
+       * 商品体积,单位:m^3 平米
+       */
+      volume: 0,
+      /**
+       * 一级分销的佣金,单位:分
+       */
+      subCommissionFirstPrice: 0,
+      /**
+       * 二级分销的佣金,单位:分
+       */
+      subCommissionSecondPrice: 0
+    }
+  ],
+  description: '5425', // 商品详情
+  sort: 1, // 商品排序
+  giveIntegral: 1, // 赠送积分
+  virtualSalesCount: 1, // 虚拟销量
+  recommendHot: false, // 是否热卖
+  recommendBenefit: false, // 是否优惠
+  recommendBest: false, // 是否精品
+  recommendNew: false, // 是否新品
+  recommendGood: false // 是否优品
+})
+
+/** 获得详情 */
+const getDetail = async () => {
+  const id = query.id as unknown as number
+  if (id) {
+    formLoading.value = true
+    try {
+      const res = (await managementApi.getSpu(id)) as SpuType
+      formData.value = res
+      // 直接取第一个值就能得到所有属性的id
+      // TODO @puhui999:可以直接拿 propertyName 拼接处规格 id + 属性,可以看下商品 uniapp 详情的做法
+      const propertyIds = res.skus[0]?.properties.map((item) => item.propertyId)
+      const PropertyS = await PropertyApi.getPropertyListAndValue({ propertyIds })
+      await nextTick()
+      // 回显商品属性
+      BasicInfoRef.value.addAttribute(PropertyS)
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+
+/** 提交按钮 */
+const submitForm = async () => {
+  // 提交请求
+  formLoading.value = true
+  const newSkus = JSON.parse(JSON.stringify(formData.value.skus)) //深拷贝一份skus保存失败时使用
+  // TODO 三个表单逐一校验,如果有一个表单校验不通过则切换到对应表单,如果有两个及以上的情况则切换到最前面的一个并弹出提示消息
+  // 校验各表单
+  try {
+    await unref(BasicInfoRef)?.validate()
+    await unref(DescriptionRef)?.validate()
+    await unref(OtherSettingsRef)?.validate()
+    // TODO @puhui:直接做深拷贝?这样最终 server 端不满足,不需要恢复
+    // 处理掉一些无关数据
+    formData.value.skus.forEach((item) => {
+      // 给sku name赋值
+      item.name = formData.value.name
+      // 多规格情况移除skus相关属性值value
+      if (formData.value.specType) {
+        item.properties.forEach((item2) => {
+          delete item2.valueName
+        })
+      }
+    })
+    // 处理轮播图列表
+    const newSliderPicUrls = []
+    formData.value.sliderPicUrls.forEach((item) => {
+      // 如果是前端选的图
+      // TODO @puhui999:疑问哈,为啥会是 object 呀?
+      if (typeof item === 'object') {
+        newSliderPicUrls.push(item.url)
+      } else {
+        newSliderPicUrls.push(item)
+      }
+    })
+    formData.value.sliderPicUrls = newSliderPicUrls
+    // 校验都通过后提交表单
+    const data = formData.value as SpuType
+    // 移除skus.
+    const id = query.id as unknown as number
+    if (!id) {
+      await managementApi.createSpu(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await managementApi.updateSpu(data)
+      message.success(t('common.updateSuccess'))
+    }
+    close()
+  } catch (e) {
+    // 如果是后端校验失败,恢复skus数据
+    if (typeof e === 'string') {
+      formData.value.skus = newSkus
+    }
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/**
+ * 重置表单
+ */
+const resetForm = async () => {
+  formData.value = {
+    name: '', // 商品名称
+    categoryId: 0, // 商品分类
+    keyword: '', // 关键字
+    unit: '', // 单位
+    picUrl: '', // 商品封面图
+    sliderPicUrls: [], // 商品轮播图
+    introduction: '', // 商品简介
+    deliveryTemplateId: 0, // 运费模版
+    selectRule: '',
+    specType: false, // 商品规格
+    subCommissionType: false, // 分销类型
+    description: '', // 商品详情
+    sort: 1, // 商品排序
+    giveIntegral: 1, // 赠送积分
+    virtualSalesCount: 1, // 虚拟销量
+    recommendHot: false, // 是否热卖
+    recommendBenefit: false, // 是否优惠
+    recommendBest: false, // 是否精品
+    recommendNew: false, // 是否新品
+    recommendGood: false // 是否优品
+  }
+}
+/** 关闭按钮 */
+const close = () => {
+  // TODO @puhui999:是不是不用 reset 呀?close 默认销毁
+  resetForm()
+  delView(unref(currentRoute))
+  push('/product/product-management')
+}
+
+/** 初始化 */
+onMounted(() => {
+  getDetail()
+})
+</script>

+ 238 - 0
src/views/mall/product/spu/components/BasicInfoForm.vue

@@ -0,0 +1,238 @@
+<template>
+  <el-form ref="ProductManagementBasicInfoRef" :model="formData" :rules="rules" label-width="120px">
+    <el-row>
+      <el-col :span="12">
+        <el-form-item label="商品名称" prop="name">
+          <el-input v-model="formData.name" placeholder="请输入商品名称" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <!-- TODO @puhui999:只能选根节点 -->
+        <el-form-item label="商品分类" prop="categoryId">
+          <el-tree-select
+            v-model="formData.categoryId"
+            :data="categoryList"
+            :props="defaultProps"
+            check-strictly
+            node-key="id"
+            placeholder="请选择商品分类"
+            class="w-1/1"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="商品关键字" prop="keyword">
+          <el-input v-model="formData.keyword" placeholder="请输入商品关键字" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="单位" prop="unit">
+          <el-select v-model="formData.unit" placeholder="请选择单位" class="w-1/1">
+            <el-option
+              v-for="dict in getIntDictOptions(DICT_TYPE.PRODUCT_UNIT)"
+              :key="dict.value"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </el-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="商品简介" prop="introduction">
+          <el-input
+            v-model="formData.introduction"
+            :rows="3"
+            placeholder="请输入商品简介"
+            type="textarea"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="商品封面图" prop="picUrl">
+          <UploadImg v-model="formData.picUrl" height="80px" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="24">
+        <el-form-item label="商品轮播图" prop="sliderPicUrls">
+          <UploadImgs v-model="formData.sliderPicUrls" />
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="运费模板" prop="deliveryTemplateId">
+          <el-select v-model="formData.deliveryTemplateId" placeholder="请选择" class="w-1/1">
+            <el-option v-for="item in []" :key="item.id" :label="item.name" :value="item.id" />
+          </el-select>
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-button class="ml-20px">运费模板</el-button>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="商品规格" props="specType">
+          <el-radio-group v-model="formData.specType" @change="onChangeSpec">
+            <el-radio :label="false" class="radio">单规格</el-radio>
+            <el-radio :label="true">多规格</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-col>
+      <el-col :span="12">
+        <el-form-item label="分销类型" props="subCommissionType">
+          <el-radio-group v-model="formData.subCommissionType" @change="changeSubCommissionType">
+            <el-radio :label="false">默认设置</el-radio>
+            <el-radio :label="true" class="radio">自行设置</el-radio>
+          </el-radio-group>
+        </el-form-item>
+      </el-col>
+      <!-- 多规格添加-->
+      <el-col :span="24">
+        <el-form-item v-if="formData.specType" label="商品属性">
+          <!-- TODO @puhui999:参考 https://admin.java.crmeb.net/store/list/creatProduct 添加规格好做么?添加的时候,不用输入备注哈 -->
+          <el-button class="mr-15px mb-10px" @click="AttributesAddFormRef.open">添加规格</el-button>
+          <ProductAttributes :attribute-data="attributeList" />
+        </el-form-item>
+        <template v-if="formData.specType && attributeList.length > 0">
+          <el-form-item label="批量设置">
+            <SkuList :attributeList="attributeList" :is-batch="true" :prop-form-data="formData" />
+          </el-form-item>
+          <el-form-item label="属性列表">
+            <SkuList :attributeList="attributeList" :prop-form-data="formData" />
+          </el-form-item>
+        </template>
+        <el-form-item v-if="!formData.specType">
+          <SkuList :attributeList="attributeList" :prop-form-data="formData" />
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-form>
+  <ProductAttributesAddForm ref="AttributesAddFormRef" @success="addAttribute" />
+</template>
+<script lang="ts" name="ProductManagementBasicInfoForm" setup>
+import { PropType } from 'vue'
+import { defaultProps, handleTree } from '@/utils/tree'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import type { SpuType } from '@/api/mall/product/management/type/spuType'
+import { UploadImg, UploadImgs } from '@/components/UploadFile'
+import { copyValueToTarget } from '@/utils/object'
+import { ProductAttributes, ProductAttributesAddForm, SkuList } from './index'
+import * as ProductCategoryApi from '@/api/mall/product/category'
+import { propTypes } from '@/utils/propTypes'
+const message = useMessage() // 消息弹窗
+
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<SpuType>,
+    default: () => {}
+  },
+  activeName: propTypes.string.def('')
+})
+const AttributesAddFormRef = ref() // 添加商品属性表单 TODO @puhui999:小写开头哈
+const ProductManagementBasicInfoRef = ref() // 表单Ref TODO @puhui999:小写开头哈
+// TODO @puhui999:attributeList 改成 propertyList,会更统一一点
+const attributeList = ref([]) // 商品属性列表
+/** 添加商品属性 */ // TODO @puhui999:propFormData 算出来
+const addAttribute = (property: any) => {
+  if (Array.isArray(property)) {
+    attributeList.value = property
+    return
+  }
+  attributeList.value.push(property)
+}
+const formData = reactive<SpuType>({
+  name: '', // 商品名称
+  categoryId: undefined, // 商品分类
+  keyword: '', // 关键字
+  unit: '', // 单位
+  picUrl: '', // 商品封面图
+  sliderPicUrls: [], // 商品轮播图
+  introduction: '', // 商品简介
+  deliveryTemplateId: 1, // 运费模版
+  specType: false, // 商品规格
+  subCommissionType: false, // 分销类型
+  skus: []
+})
+const rules = reactive({
+  name: [required],
+  categoryId: [required],
+  keyword: [required],
+  unit: [required],
+  introduction: [required],
+  picUrl: [required],
+  sliderPicUrls: [required],
+  // deliveryTemplateId: [required],
+  specType: [required],
+  subCommissionType: [required]
+})
+
+/**
+ * 将传进来的值赋值给 formData
+ */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) return
+    copyValueToTarget(formData, data)
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+/**
+ * 表单校验
+ */
+const emit = defineEmits(['update:activeName'])
+const validate = async () => {
+  // 校验表单
+  if (!ProductManagementBasicInfoRef) return
+  return await unref(ProductManagementBasicInfoRef).validate((valid) => {
+    if (!valid) {
+      message.warning('商品信息未完善!!')
+      emit('update:activeName', 'basicInfo')
+      // 目的截断之后的校验
+      throw new Error('商品信息未完善!!')
+    } else {
+      // 校验通过更新数据
+      Object.assign(props.propFormData, formData)
+    }
+  })
+}
+defineExpose({ validate, addAttribute })
+
+/** 分销类型 */
+const changeSubCommissionType = () => {
+  // 默认为零,类型切换后也要重置为零
+  for (const item of formData.skus) {
+    item.subCommissionFirstPrice = 0
+    item.subCommissionSecondPrice = 0
+  }
+}
+
+/** 选择规格 */
+const onChangeSpec = () => {
+  // 重置商品属性列表
+  attributeList.value = []
+  // 重置sku列表
+  formData.skus = [
+    {
+      price: 0,
+      marketPrice: 0,
+      costPrice: 0,
+      barCode: '',
+      picUrl: '',
+      stock: 0,
+      weight: 0,
+      volume: 0,
+      subCommissionFirstPrice: 0,
+      subCommissionSecondPrice: 0
+    }
+  ]
+}
+
+const categoryList = ref() // 分类树
+onMounted(async () => {
+  // 获得分类树
+  const data = await ProductCategoryApi.getCategoryList({})
+  categoryList.value = handleTree(data, 'id', 'parentId')
+})
+</script>

+ 84 - 0
src/views/mall/product/spu/components/DescriptionForm.vue

@@ -0,0 +1,84 @@
+<template>
+  <el-form ref="DescriptionFormRef" :model="formData" :rules="rules" label-width="120px">
+    <!--富文本编辑器组件-->
+    <el-form-item label="商品详情" prop="description">
+      <Editor v-model:modelValue="formData.description" />
+    </el-form-item>
+  </el-form>
+</template>
+<script lang="ts" name="DescriptionForm" setup>
+import type { SpuType } from '@/api/mall/product/management/type/spuType'
+import { Editor } from '@/components/Editor'
+import { PropType } from 'vue'
+import { copyValueToTarget } from '@/utils/object'
+import { propTypes } from '@/utils/propTypes'
+
+const message = useMessage() // 消息弹窗
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<SpuType>,
+    default: () => {}
+  },
+  activeName: propTypes.string.def('')
+})
+const DescriptionFormRef = ref() // 表单Ref
+const formData = ref<SpuType>({
+  description: '' // 商品详情
+})
+// 表单规则
+const rules = reactive({
+  description: [required]
+})
+
+/**
+ * 富文本编辑器如果输入过再清空会有残留,需再重置一次
+ */
+watch(
+  () => formData.value.description,
+  (newValue) => {
+    if ('<p><br></p>' === newValue) {
+      formData.value.description = ''
+    }
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+/**
+ * 将传进来的值赋值给formData
+ */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) return
+    copyValueToTarget(formData.value, data)
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+/**
+ * 表单校验
+ */
+const emit = defineEmits(['update:activeName'])
+const validate = async () => {
+  // 校验表单
+  if (!DescriptionFormRef) return
+  return unref(DescriptionFormRef).validate((valid) => {
+    if (!valid) {
+      message.warning('商品详情为完善!!')
+      emit('update:activeName', 'description')
+      // 目的截断之后的校验
+      throw new Error('商品详情为完善!!')
+    } else {
+      // 校验通过更新数据
+      Object.assign(props.propFormData, formData.value)
+    }
+  })
+}
+defineExpose({ validate })
+</script>

+ 156 - 0
src/views/mall/product/spu/components/OtherSettingsForm.vue

@@ -0,0 +1,156 @@
+<template>
+  <el-form ref="OtherSettingsFormRef" :model="formData" :rules="rules" label-width="120px">
+    <el-row>
+      <!-- TODO @puhui999:横着三个哈 -->
+      <el-col :span="24">
+        <el-col :span="8">
+          <el-form-item label="商品排序" prop="sort">
+            <el-input-number v-model="formData.sort" :min="0" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="赠送积分" prop="giveIntegral">
+            <el-input-number v-model="formData.giveIntegral" :min="0" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="8">
+          <el-form-item label="虚拟销量" prop="virtualSalesCount">
+            <el-input-number
+              v-model="formData.virtualSalesCount"
+              :min="0"
+              placeholder="请输入虚拟销量"
+            />
+          </el-form-item>
+        </el-col>
+      </el-col>
+      <el-col :span="24">
+        <el-form-item label="商品推荐">
+          <el-checkbox-group v-model="checkboxGroup" @change="onChangeGroup">
+            <el-checkbox v-for="(item, index) in recommend" :key="index" :label="item.value">
+              {{ item.name }}
+            </el-checkbox>
+          </el-checkbox-group>
+        </el-form-item>
+      </el-col>
+      <el-col :span="24">
+        <!--   TODO tag展示暂时不考虑排序     -->
+        <el-form-item label="活动优先级">
+          <el-tag>默认</el-tag>
+          <el-tag class="ml-2" type="success">秒杀</el-tag>
+          <el-tag class="ml-2" type="info">砍价</el-tag>
+          <el-tag class="ml-2" type="warning">拼团</el-tag>
+        </el-form-item>
+      </el-col>
+      <!-- TODO @puhui999:等优惠劵 ok 在搞 -->
+      <el-col :span="24">
+        <el-form-item label="赠送优惠劵">
+          <el-button>选择优惠券</el-button>
+        </el-form-item>
+      </el-col>
+    </el-row>
+  </el-form>
+</template>
+<script lang="ts" name="OtherSettingsForm" setup>
+import type { SpuType } from '@/api/mall/product/management/type/spuType'
+import { PropType } from 'vue'
+import { copyValueToTarget } from '@/utils/object'
+import { propTypes } from '@/utils/propTypes'
+const message = useMessage() // 消息弹窗
+
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<SpuType>,
+    default: () => {}
+  },
+  activeName: propTypes.string.def('')
+})
+// 商品推荐选项 TODO @puhui999:这种叫 recommendOptions 会更合适哈
+const recommend = [
+  { name: '是否热卖', value: 'recommendHot' },
+  { name: '是否优惠', value: 'recommendBenefit' },
+  { name: '是否精品', value: 'recommendBest' },
+  { name: '是否新品', value: 'recommendNew' },
+  { name: '是否优品', value: 'recommendGood' }
+]
+const checkboxGroup = ref<string[]>(['recommendHot']) // 选中推荐选项
+/** 选择商品后赋值 */
+const onChangeGroup = () => {
+  // TODO @puhui999:是不是可以遍历 recommend,然后进行是否选中;
+  checkboxGroup.value.includes('recommendHot')
+    ? (formData.value.recommendHot = true)
+    : (formData.value.recommendHot = false)
+  checkboxGroup.value.includes('recommendBenefit')
+    ? (formData.value.recommendBenefit = true)
+    : (formData.value.recommendBenefit = false)
+  checkboxGroup.value.includes('recommendBest')
+    ? (formData.value.recommendBest = true)
+    : (formData.value.recommendBest = false)
+  checkboxGroup.value.includes('recommendNew')
+    ? (formData.value.recommendNew = true)
+    : (formData.value.recommendNew = false)
+  checkboxGroup.value.includes('recommendGood')
+    ? (formData.value.recommendGood = true)
+    : (formData.value.recommendGood = false)
+}
+const OtherSettingsFormRef = ref() // 表单Ref
+// 表单数据
+const formData = ref<SpuType>({
+  sort: 1, // 商品排序
+  giveIntegral: 1, // 赠送积分
+  virtualSalesCount: 1, // 虚拟销量
+  recommendHot: false, // 是否热卖
+  recommendBenefit: false, // 是否优惠
+  recommendBest: false, // 是否精品
+  recommendNew: false, // 是否新品
+  recommendGood: false // 是否优品
+})
+// 表单规则
+const rules = reactive({
+  sort: [required],
+  giveIntegral: [required],
+  virtualSalesCount: [required]
+})
+
+/**
+ * 将传进来的值赋值给formData
+ */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) return
+    copyValueToTarget(formData.value, data)
+    // TODO 如果先修改其他设置的值,再改变商品详情或是商品信息会重置其他设置页面中的相关值 下一个版本修复
+    checkboxGroup.value = []
+    formData.value.recommendHot ? checkboxGroup.value.push('recommendHot') : ''
+    formData.value.recommendBenefit ? checkboxGroup.value.push('recommendBenefit') : ''
+    formData.value.recommendBest ? checkboxGroup.value.push('recommendBest') : ''
+    formData.value.recommendNew ? checkboxGroup.value.push('recommendNew') : ''
+    formData.value.recommendGood ? checkboxGroup.value.push('recommendGood') : ''
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+/**
+ * 表单校验
+ */
+const emit = defineEmits(['update:activeName'])
+const validate = async () => {
+  // 校验表单
+  if (!OtherSettingsFormRef) return
+  return await unref(OtherSettingsFormRef).validate((valid) => {
+    if (!valid) {
+      message.warning('商品其他设置未完善!!')
+      emit('update:activeName', 'otherSettings')
+      // 目的截断之后的校验
+      throw new Error('商品其他设置未完善!!')
+    } else {
+      // 校验通过更新数据
+      Object.assign(props.propFormData, formData.value)
+    }
+  })
+}
+defineExpose({ validate })
+</script>

+ 102 - 0
src/views/mall/product/spu/components/ProductAttributes.vue

@@ -0,0 +1,102 @@
+<template>
+  <el-col v-for="(item, index) in attributeList" :key="index">
+    <div>
+      <el-text class="mx-1">属性名:</el-text>
+      <el-text class="mx-1">{{ item.name }}</el-text>
+    </div>
+    <div>
+      <el-text class="mx-1">属性值:</el-text>
+      <el-tag
+        v-for="(value, valueIndex) in item.values"
+        :key="value.id"
+        :disable-transitions="false"
+        class="mx-1"
+        closable
+        @close="handleClose(index, valueIndex)"
+      >
+        {{ value.name }}
+      </el-tag>
+      <el-input
+        v-show="inputVisible(index)"
+        ref="InputRef"
+        v-model="inputValue"
+        class="!w-20"
+        size="small"
+        @blur="handleInputConfirm(index, item.id)"
+        @keyup.enter="handleInputConfirm(index, item.id)"
+      />
+      <el-button
+        v-show="!inputVisible(index)"
+        class="button-new-tag ml-1"
+        size="small"
+        @click="showInput(index)"
+      >
+        + 添加
+      </el-button>
+    </div>
+    <el-divider class="my-10px" />
+  </el-col>
+</template>
+
+<script lang="ts" name="ProductAttributes" setup>
+import { ElInput } from 'element-plus'
+import * as PropertyApi from '@/api/mall/product/property'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+const inputValue = ref('') // 输入框值
+const attributeIndex = ref<number | null>(null) // 获取焦点时记录当前属性项的index
+// 输入框显隐控制
+const inputVisible = computed(() => (index) => {
+  if (attributeIndex.value === null) return false
+  if (attributeIndex.value === index) return true
+})
+const InputRef = ref() //标签输入框Ref
+const attributeList = ref([]) // 商品属性列表
+const props = defineProps({
+  attributeData: {
+    type: Array,
+    default: () => {}
+  }
+})
+
+watch(
+  () => props.attributeData,
+  (data) => {
+    if (!data) return
+    attributeList.value = data
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+/** 删除标签 tagValue 标签值*/
+const handleClose = (index, valueIndex) => {
+  attributeList.value[index].values?.splice(valueIndex, 1)
+}
+
+/** 显示输入框并获取焦点 */
+const showInput = async (index) => {
+  attributeIndex.value = index
+  // 因为组件在ref中所以需要用索引获取对应的Ref
+  InputRef.value[index]!.input!.focus()
+}
+
+/** 输入框失去焦点或点击回车时触发 */
+const handleInputConfirm = async (index, propertyId) => {
+  if (inputValue.value) {
+    // 保存属性值
+    try {
+      const id = await PropertyApi.createPropertyValue({ propertyId, name: inputValue.value })
+      attributeList.value[index].values.push({ id, name: inputValue.value })
+      message.success(t('common.createSuccess'))
+    } catch {
+      message.error('添加失败,请重试') // TODO 缺少国际化
+    }
+  }
+  attributeIndex.value = null
+  inputValue.value = ''
+}
+</script>

+ 85 - 0
src/views/mall/product/spu/components/ProductAttributesAddForm.vue

@@ -0,0 +1,85 @@
+<template>
+  <Dialog v-model="dialogVisible" :title="dialogTitle">
+    <el-form
+      ref="formRef"
+      v-loading="formLoading"
+      :model="formData"
+      :rules="formRules"
+      label-width="80px"
+    >
+      <el-form-item label="名称" prop="name">
+        <el-input v-model="formData.name" placeholder="请输入名称" />
+      </el-form-item>
+      <el-form-item label="备注" prop="remark">
+        <el-input v-model="formData.remark" placeholder="请输入内容" type="textarea" />
+      </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" name="ProductPropertyForm" setup>
+import * as PropertyApi from '@/api/mall/product/property'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const dialogVisible = ref(false) // 弹窗的是否展示
+const dialogTitle = ref('添加商品属性') // 弹窗的标题
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formData = ref({
+  name: '',
+  remark: ''
+})
+const formRules = reactive({
+  name: [{ required: true, message: '名称不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+/** 打开弹窗 */
+const open = async () => {
+  dialogVisible.value = true
+  resetForm()
+}
+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 PropertyApi.PropertyVO
+    // 检查属性是否已存在,如果有则返回属性和其下属性值
+    const res = await PropertyApi.getPropertyListAndValue({ name: data.name })
+    if (res.length === 0) {
+      const propertyId = await PropertyApi.createProperty(data)
+      emit('success', { id: propertyId, ...formData.value, values: [] })
+    } else {
+      if (res[0].values === null) {
+        res[0].values = []
+      }
+      emit('success', res[0]) // 因为只用一个
+    }
+    message.success(t('common.createSuccess'))
+    dialogVisible.value = false
+  } finally {
+    formLoading.value = false
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    name: '',
+    remark: ''
+  }
+  formRef.value?.resetFields()
+}
+</script>

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

@@ -0,0 +1,309 @@
+<template>
+  <el-table
+    :data="isBatch ? SkuData : formData.skus"
+    border
+    class="tabNumWidth"
+    max-height="500"
+    size="small"
+  >
+    <el-table-column align="center" fixed="left" label="图片" min-width="100">
+      <template #default="{ row }">
+        <UploadImg v-model="row.picUrl" height="80px" width="100%" />
+      </template>
+    </el-table-column>
+    <template v-if="formData.specType && !isBatch">
+      <!--  根据商品属性动态添加  -->
+      <el-table-column
+        v-for="(item, index) in tableHeaderList"
+        :key="index"
+        :label="item.label"
+        align="center"
+        min-width="120"
+      >
+        <template #default="{ row }">
+          {{ row.properties[index]?.valueName }}
+        </template>
+      </el-table-column>
+    </template>
+    <!-- TODO @puhui999: controls-position="right" 可以去掉哈,不然太长了,手动输入更方便 -->
+    <el-table-column align="center" label="商品条码" min-width="168">
+      <template #default="{ row }">
+        <el-input v-model="row.barCode" class="w-100%" />
+      </template>
+    </el-table-column>
+    <!-- TODO @puhui999:用户输入的时候,是按照元;分主要是我们自己用; -->
+    <el-table-column align="center" label="销售价(分)" min-width="168">
+      <template #default="{ row }">
+        <el-input-number v-model="row.price" :min="0" class="w-100%" controls-position="right" />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="市场价(分)" min-width="168">
+      <template #default="{ row }">
+        <el-input-number
+          v-model="row.marketPrice"
+          :min="0"
+          class="w-100%"
+          controls-position="right"
+        />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="成本价(分)" min-width="168">
+      <template #default="{ row }">
+        <el-input-number
+          v-model="row.costPrice"
+          :min="0"
+          class="w-100%"
+          controls-position="right"
+        />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="库存" min-width="168">
+      <template #default="{ row }">
+        <el-input-number v-model="row.stock" :min="0" class="w-100%" controls-position="right" />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="重量(kg)" min-width="168">
+      <template #default="{ row }">
+        <el-input-number v-model="row.weight" :min="0" class="w-100%" controls-position="right" />
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="体积(m^3)" min-width="168">
+      <template #default="{ row }">
+        <el-input-number v-model="row.volume" :min="0" class="w-100%" controls-position="right" />
+      </template>
+    </el-table-column>
+    <template v-if="formData.subCommissionType">
+      <el-table-column align="center" label="一级返佣(分)" min-width="168">
+        <template #default="{ row }">
+          <el-input-number
+            v-model="row.subCommissionFirstPrice"
+            :min="0"
+            class="w-100%"
+            controls-position="right"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="二级返佣(分)" min-width="168">
+        <template #default="{ row }">
+          <el-input-number
+            v-model="row.subCommissionSecondPrice"
+            :min="0"
+            class="w-100%"
+            controls-position="right"
+          />
+        </template>
+      </el-table-column>
+    </template>
+    <el-table-column v-if="formData.specType" align="center" fixed="right" label="操作" width="80">
+      <template #default>
+        <el-button v-if="isBatch" link size="small" type="primary" @click="batchAdd">
+          批量添加
+        </el-button>
+        <el-button v-else link size="small" type="primary">删除</el-button>
+      </template>
+    </el-table-column>
+  </el-table>
+</template>
+<script lang="ts" name="SkuList" setup>
+import { UploadImg } from '@/components/UploadFile'
+import { PropType } from 'vue'
+import { SpuType } from '@/api/mall/product/management/type/spuType'
+import { propTypes } from '@/utils/propTypes'
+import { SkuType } from '@/api/mall/product/management/type/skuType'
+import { copyValueToTarget } from '@/utils/object'
+
+const props = defineProps({
+  propFormData: {
+    type: Object as PropType<SpuType>,
+    default: () => {}
+  },
+  attributeList: {
+    type: Array,
+    default: () => []
+  },
+  isBatch: propTypes.bool.def(false) // 是否批量操作
+})
+const formData = ref<SpuType>() // 表单数据
+// 批量添加时的零时数据 TODO @puhui999:小写开头哈;然后变量都尾注释
+const SkuData = ref<SkuType[]>([
+  {
+    /**
+     * 商品价格,单位:分
+     */
+    price: 0,
+    /**
+     * 市场价,单位:分
+     */
+    marketPrice: 0,
+    /**
+     * 成本价,单位:分
+     */
+    costPrice: 0,
+    /**
+     * 商品条码
+     */
+    barCode: '',
+    /**
+     * 图片地址
+     */
+    picUrl: '',
+    /**
+     * 库存
+     */
+    stock: 0,
+    /**
+     * 商品重量,单位:kg 千克
+     */
+    weight: 0,
+    /**
+     * 商品体积,单位:m^3 平米
+     */
+    volume: 0,
+    /**
+     * 一级分销的佣金,单位:分
+     */
+    subCommissionFirstPrice: 0,
+    /**
+     * 二级分销的佣金,单位:分
+     */
+    subCommissionSecondPrice: 0
+  }
+])
+
+/** 批量添加 */
+const batchAdd = () => {
+  formData.value.skus.forEach((item) => {
+    copyValueToTarget(item, SkuData.value[0])
+  })
+}
+
+const tableHeaderList = ref<{ prop: string; label: string }[]>([])
+
+/**
+ * 将传进来的值赋值给SkuData
+ */
+watch(
+  () => props.propFormData,
+  (data) => {
+    if (!data) return
+    formData.value = data
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+// TODO @芋艿:看看 chatgpt 可以进一步下面几个方法的实现不
+/** 生成表数据 */
+const generateTableData = (data: any[]) => {
+  // 构建数据结构
+  const propertiesItemList = []
+  for (const item of data) {
+    const objList = []
+    for (const v of item.values) {
+      const obj = { propertyId: 0, valueId: 0, valueName: '' }
+      obj.propertyId = item.id
+      obj.valueId = v.id
+      obj.valueName = v.name
+      objList.push(obj)
+    }
+    propertiesItemList.push(objList)
+  }
+  const buildList = build(propertiesItemList)
+  // 如果构建后的组合数跟sku数量一样的话则不用处理,添加新属性没有属性值也不做处理 (解决编辑表单时或查看详情时数据回显问题)
+  if (
+    buildList.length === formData.value.skus.length ||
+    data.some((item) => item.values.length === 0)
+  ) {
+    return
+  }
+  // 重置表数据
+  formData.value!.skus = []
+  buildList.forEach((item) => {
+    const row = {
+      properties: [],
+      price: 0,
+      marketPrice: 0,
+      costPrice: 0,
+      barCode: '',
+      picUrl: '',
+      stock: 0,
+      weight: 0,
+      volume: 0,
+      subCommissionFirstPrice: 0,
+      subCommissionSecondPrice: 0
+    }
+    // 判断是否是单一属性的情况
+    if (Array.isArray(item)) {
+      row.properties = item
+    } else {
+      row.properties.push(item)
+    }
+    formData.value.skus.push(row)
+  })
+}
+
+/** 构建所有排列组合 */
+const build = (list: any[]) => {
+  if (list.length === 0) {
+    return []
+  } else if (list.length === 1) {
+    return list[0]
+  } else {
+    const result = []
+    const rest = build(list.slice(1))
+    for (let i = 0; i < list[0].length; i++) {
+      for (let j = 0; j < rest.length; j++) {
+        // 第一次不是数组结构,后面的都是数组结构
+        if (Array.isArray(rest[j])) {
+          result.push([list[0][i], ...rest[j]])
+        } else {
+          result.push([list[0][i], rest[j]])
+        }
+      }
+    }
+    return result
+  }
+}
+
+/** 监听属性列表生成相关参数和表头 */
+watch(
+  () => props.attributeList,
+  (data) => {
+    // 如果不是多规格则结束
+    if (!formData.value.specType) return
+    // 如果当前组件作为批量添加数据使用则重置表数据
+    if (props.isBatch) {
+      SkuData.value = [
+        {
+          price: 0,
+          marketPrice: 0,
+          costPrice: 0,
+          barCode: '',
+          picUrl: '',
+          stock: 0,
+          weight: 0,
+          volume: 0,
+          subCommissionFirstPrice: 0,
+          subCommissionSecondPrice: 0
+        }
+      ]
+    }
+    // 判断代理对象是否为空
+    if (JSON.stringify(data) === '[]') return
+    // 重置表头
+    tableHeaderList.value = []
+    // 生成表头
+    data.forEach((item, index) => {
+      // name加属性项index区分属性值
+      tableHeaderList.value.push({ prop: `name${index}`, label: item.name })
+    })
+    generateTableData(data)
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+</script>

+ 15 - 0
src/views/mall/product/spu/components/index.ts

@@ -0,0 +1,15 @@
+import BasicInfoForm from './BasicInfoForm.vue'
+import DescriptionForm from './DescriptionForm.vue'
+import OtherSettingsForm from './OtherSettingsForm.vue'
+import ProductAttributes from './ProductAttributes.vue'
+import ProductAttributesAddForm from './ProductAttributesAddForm.vue'
+import SkuList from './SkuList.vue'
+
+export {
+  BasicInfoForm,
+  DescriptionForm,
+  OtherSettingsForm,
+  ProductAttributes,
+  ProductAttributesAddForm,
+  SkuList
+}

+ 388 - 0
src/views/mall/product/spu/index.vue

@@ -0,0 +1,388 @@
+<template>
+  <!-- 搜索工作栏 -->
+  <ContentWrap>
+    <el-form
+      ref="queryFormRef"
+      :inline="true"
+      :model="queryParams"
+      class="-mb-15px"
+      label-width="68px"
+    >
+      <!-- TODO @puhui999:https://admin.java.crmeb.net/store/index,参考,使用分类 + 标题搜索 -->
+      <el-form-item label="品牌名称" prop="name">
+        <el-input
+          v-model="queryParams.name"
+          class="!w-240px"
+          clearable
+          placeholder="请输入品牌名称"
+          @keyup.enter="handleQuery"
+        />
+      </el-form-item>
+      <el-form-item label="状态" prop="status">
+        <el-select v-model="queryParams.status" class="!w-240px" clearable placeholder="请选择状态">
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="创建时间" prop="createTime">
+        <el-date-picker
+          v-model="queryParams.createTime"
+          :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+          class="!w-240px"
+          end-placeholder="结束日期"
+          start-placeholder="开始日期"
+          type="daterange"
+          value-format="YYYY-MM-DD HH:mm:ss"
+        />
+      </el-form-item>
+      <el-form-item>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
+        <el-button v-hasPermi="['product:brand:create']" plain type="primary" @click="openForm">
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
+        </el-button>
+        <!-- TODO @puhui999:增加一个【导出】操作 -->
+      </el-form-item>
+    </el-form>
+  </ContentWrap>
+
+  <!-- 列表 -->
+  <ContentWrap>
+    <el-tabs v-model="queryParams.tabType" @tab-click="handleClick">
+      <el-tab-pane
+        v-for="item in tabsData"
+        :key="item.type"
+        :label="item.name + '(' + item.count + ')'"
+        :name="item.type"
+      />
+    </el-tabs>
+    <el-table v-loading="loading" :data="list">
+      <!-- TODO puhui999: ID 编号的展示 -->
+      <!--   TODO 暂时不做折叠数据   -->
+      <!--      <el-table-column type="expand">-->
+      <!--        <template #default="{ row }">-->
+      <!--          <el-form inline label-position="left">-->
+      <!--            <el-form-item label="市场价:">-->
+      <!--              <span>{{ row.marketPrice }}</span>-->
+      <!--            </el-form-item>-->
+      <!--            <el-form-item label="成本价:">-->
+      <!--              <span>{{ row.costPrice }}</span>-->
+      <!--            </el-form-item>-->
+      <!--            <el-form-item label="虚拟销量:">-->
+      <!--              <span>{{ row.virtualSalesCount }}</span>-->
+      <!--            </el-form-item>-->
+      <!--          </el-form>-->
+      <!--        </template>-->
+      <!--      </el-table-column>-->
+      <el-table-column label="商品图" min-width="80">
+        <template #default="{ row }">
+          <el-image
+            :src="row.picUrl"
+            style="width: 36px; height: 36px"
+            @click="imagePreview(row.picUrl)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" />
+      <!-- TODO 价格 / 100.0 -->
+      <el-table-column align="center" label="商品售价" min-width="90" prop="price" />
+      <el-table-column align="center" label="销量" min-width="90" prop="salesCount" />
+      <el-table-column align="center" label="库存" min-width="90" prop="stock" />
+      <el-table-column align="center" label="排序" min-width="70" prop="sort" />
+      <el-table-column
+        :formatter="dateFormatter"
+        align="center"
+        label="创建时间"
+        prop="createTime"
+        width="180"
+      />
+      <el-table-column fixed="right" label="状态" min-width="80">
+        <template #default="{ row }">
+          <!-- TODO @puhui:是不是不用 Number(row.status) 去比较哈,直接 row.status < 0 -->
+          <el-switch
+            v-model="row.status"
+            :active-value="1"
+            :disabled="Number(row.status) < 0"
+            :inactive-value="0"
+            active-text="上架"
+            inactive-text="下架"
+            inline-prompt
+            @change="changeStatus(row)"
+          />
+        </template>
+      </el-table-column>
+      <el-table-column align="center" fixed="right" label="操作" min-width="150">
+        <template #default="{ row }">
+          <!-- TODO @puhui999:【详情】,可以后面点做哈 -->
+          <template v-if="queryParams.tabType === 4">
+            <el-button
+              v-hasPermi="['product:spu:delete']"
+              link
+              type="danger"
+              @click="handleDelete(row.id)"
+            >
+              删除
+            </el-button>
+            <el-button
+              v-hasPermi="['product:spu:update']"
+              link
+              type="primary"
+              @click="addToTrash(row, ProductSpuStatusEnum.DISABLE.status)"
+            >
+              恢复到仓库
+            </el-button>
+          </template>
+          <template v-else>
+            <el-button
+              v-hasPermi="['product:spu:update']"
+              link
+              type="primary"
+              @click="openForm(row.id)"
+            >
+              修改
+            </el-button>
+            <el-button
+              v-hasPermi="['product:spu:update']"
+              link
+              type="primary"
+              @click="addToTrash(row, ProductSpuStatusEnum.RECYCLE.status)"
+            >
+              加入回收站
+            </el-button>
+          </template>
+        </template>
+      </el-table-column>
+    </el-table>
+    <!-- 分页 -->
+    <Pagination
+      v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
+      @pagination="getList"
+    />
+  </ContentWrap>
+  <!-- https://kailong110120130.gitee.io/vue-element-plus-admin-doc/components/image-viewer.html,可以用这个么? -->
+  <!-- 必须在表格外面展示。不然单元格会遮挡图层 -->
+  <el-image-viewer
+    v-if="imgViewVisible"
+    :url-list="imageViewerList"
+    @close="imgViewVisible = false"
+  />
+</template>
+<script lang="ts" name="ProductList" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
+import { dateFormatter } from '@/utils/formatTime'
+// TODO @puhui999:managementApi=》ProductSpuApi
+import * as managementApi from '@/api/mall/product/management/spu'
+import { ProductSpuStatusEnum } from '@/utils/constants'
+import { TabsPaneContext } from 'element-plus'
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+const { currentRoute, push } = useRouter() // 路由跳转
+
+const loading = ref(false) // 列表的加载中
+const total = ref(0) // 列表的总页数
+const list = ref<any[]>([]) // 列表的数据
+// tabs 数据
+const tabsData = ref([
+  {
+    count: 0,
+    name: '出售中商品',
+    type: 0
+  },
+  {
+    count: 0,
+    name: '仓库中商品',
+    type: 1
+  },
+  {
+    count: 0,
+    name: '已经售空商品',
+    type: 2
+  },
+  {
+    count: 0,
+    name: '警戒库存',
+    type: 3
+  },
+  {
+    count: 0,
+    name: '商品回收站',
+    type: 4
+  }
+])
+
+/** 获得每个 Tab 的数量 */
+const getTabsCount = async () => {
+  // TODO @puhui999:这里是不是可以不要 try catch 哈
+  try {
+    const res = await managementApi.getTabsCount()
+    for (let objName in res) {
+      tabsData.value[Number(objName)].count = res[objName]
+    }
+  } catch {}
+}
+
+const imgViewVisible = ref(false) // 商品图预览
+const imageViewerList = ref<string[]>([]) // 商品图预览列表
+const queryParams = ref({
+  pageNo: 1,
+  pageSize: 10,
+  tabType: 0
+})
+const queryFormRef = ref() // 搜索的表单
+
+// TODO @puhui999:可以改成 handleTabClick:更准确一点;
+const handleClick = (tab: TabsPaneContext) => {
+  queryParams.value.tabType = tab.paneName
+  getList()
+}
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await managementApi.getSpuList(queryParams.value)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+// TODO @puhui999:是不是 changeStatus 和 addToTrash 调用一个统一的方法,去更新状态。这样逻辑会更干净一些。
+/**
+ * 更改 SPU 状态
+ *
+ * @param row
+ * @param status 更改前的值
+ */
+const changeStatus = async (row, status?: number) => {
+  // TODO 测试过程中似乎有点问题,下一版修复
+  try {
+    let text = ''
+    switch (row.status) {
+      case ProductSpuStatusEnum.DISABLE.status:
+        text = ProductSpuStatusEnum.DISABLE.name
+        break
+      case ProductSpuStatusEnum.ENABLE.status:
+        text = ProductSpuStatusEnum.ENABLE.name
+        break
+      case ProductSpuStatusEnum.RECYCLE.status:
+        text = `加入${ProductSpuStatusEnum.RECYCLE.name}`
+        break
+    }
+    await message.confirm(
+      row.status === -1 ? `确认要将[${row.name}]${text}吗?` : `确认要${text}[${row.name}]吗?`
+    )
+    await managementApi.updateStatus({ id: row.id, status: row.status })
+    message.success('更新状态成功')
+    // 刷新 tabs 数据
+    await getTabsCount()
+    // 刷新列表
+    await getList()
+  } catch {
+    // 取消加入回收站时回显数据
+    if (typeof status !== 'undefined') {
+      row.status = status
+      return
+    }
+    // 取消更改状态时回显数据
+    row.status =
+      row.status === ProductSpuStatusEnum.DISABLE.status
+        ? ProductSpuStatusEnum.ENABLE.status
+        : ProductSpuStatusEnum.DISABLE.status
+  }
+}
+
+/**
+ * 加入回收站
+ *
+ * @param row
+ * @param status
+ */
+const addToTrash = (row, status) => {
+  // 复制一份原值
+  const num = Number(`${row.status}`)
+  row.status = status
+  changeStatus(row, num)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await managementApi.deleteSpu(id)
+    message.success(t('common.delSuccess'))
+    // 刷新tabs数据
+    await getTabsCount()
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/**
+ * 商品图预览
+ * @param imgUrl
+ */
+const imagePreview = (imgUrl: string) => {
+  imageViewerList.value = [imgUrl]
+  imgViewVisible.value = true
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  getList()
+}
+
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/**
+ * 新增或修改
+ *
+ * @param id 商品 SPU 编号
+ */
+const openForm = (id?: number) => {
+  // 修改
+  if (typeof id === 'number') {
+    push('/product/productManagementAdd?id=' + id)
+    return
+  }
+  // 新增
+  push('/product/productManagementAdd')
+}
+
+// 监听路由变化更新列表 TODO @puhui999:这个是必须加的么?
+watch(
+  () => currentRoute.value,
+  () => {
+    getList()
+  },
+  {
+    immediate: true
+  }
+)
+
+/** 初始化 **/
+onMounted(() => {
+  getTabsCount()
+  getList()
+})
+</script>

+ 78 - 0
src/views/mp/autoReply/components/ReplyForm.vue

@@ -0,0 +1,78 @@
+<template>
+  <div>
+    <el-form ref="formRef" :model="replyForm" :rules="rules" label-width="80px">
+      <el-form-item label="消息类型" prop="requestMessageType" v-if="msgType === MsgType.Message">
+        <el-select v-model="replyForm.requestMessageType" placeholder="请选择">
+          <template v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)" :key="dict.value">
+            <el-option
+              v-if="RequestMessageTypes.includes(dict.value)"
+              :label="dict.label"
+              :value="dict.value"
+            />
+          </template>
+        </el-select>
+      </el-form-item>
+      <el-form-item label="匹配类型" prop="requestMatch" v-if="msgType === MsgType.Keyword">
+        <el-select v-model="replyForm.requestMatch" placeholder="请选择匹配类型" clearable>
+          <el-option
+            v-for="dict in getIntDictOptions(DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH)"
+            :key="dict.value"
+            :label="dict.label"
+            :value="dict.value"
+          />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="关键词" prop="requestKeyword" v-if="msgType === MsgType.Keyword">
+        <el-input v-model="replyForm.requestKeyword" placeholder="请输入内容" clearable />
+      </el-form-item>
+      <el-form-item label="回复消息">
+        <WxReplySelect v-model="reply" />
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script setup lang="ts" name="ReplyForm">
+import WxReplySelect, { type Reply } from '@/views/mp/components/wx-reply'
+import type { FormInstance } from 'element-plus'
+import { MsgType } from './types'
+import { DICT_TYPE, getDictOptions, getIntDictOptions } from '@/utils/dict'
+
+const props = defineProps<{
+  modelValue: any
+  reply: Reply
+  msgType: MsgType
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:reply', v: Reply)
+  (e: 'update:modelValue', v: any)
+}>()
+
+const reply = computed<Reply>({
+  get: () => props.reply,
+  set: (val) => emit('update:reply', val)
+})
+
+const replyForm = computed<any>({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+const formRef = ref<FormInstance | null>(null) // 表单 ref
+
+const RequestMessageTypes = ['text', 'image', 'voice', 'video', 'shortvideo', 'location', 'link'] // 允许选择的请求消息类型
+
+// 表单校验
+const rules = {
+  requestKeyword: [{ required: true, message: '请求的关键字不能为空', trigger: 'blur' }],
+  requestMatch: [{ required: true, message: '请求的关键字的匹配不能为空', trigger: 'blur' }]
+}
+
+defineExpose({
+  resetFields: () => formRef.value?.resetFields(),
+  validate: async () => formRef.value?.validate()
+})
+</script>
+
+<style scoped></style>

+ 26 - 61
src/views/mp/autoReply/index.vue

@@ -53,38 +53,13 @@
       @on-delete="onDelete"
     />
 
-    <!-- 添加或修改自动回复的对话框 -->
-    <!-- TODO @Dhb52 -->
-    <el-dialog :title="dialogTitle" v-model="showFormDialog" width="800px" destroy-on-close>
-      <el-form ref="formRef" :model="replyForm" :rules="rules" label-width="80px">
-        <el-form-item label="消息类型" prop="requestMessageType" v-if="msgType === MsgType.Message">
-          <el-select v-model="replyForm.requestMessageType" placeholder="请选择">
-            <template v-for="dict in getDictOptions(DICT_TYPE.MP_MESSAGE_TYPE)" :key="dict.value">
-              <el-option
-                v-if="RequestMessageTypes.includes(dict.value)"
-                :label="dict.label"
-                :value="dict.value"
-              />
-            </template>
-          </el-select>
-        </el-form-item>
-        <el-form-item label="匹配类型" prop="requestMatch" v-if="msgType === MsgType.Keyword">
-          <el-select v-model="replyForm.requestMatch" placeholder="请选择匹配类型" clearable>
-            <el-option
-              v-for="dict in getIntDictOptions(DICT_TYPE.MP_AUTO_REPLY_REQUEST_MATCH)"
-              :key="dict.value"
-              :label="dict.label"
-              :value="dict.value"
-            />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="关键词" prop="requestKeyword" v-if="msgType === MsgType.Keyword">
-          <el-input v-model="replyForm.requestKeyword" placeholder="请输入内容" clearable />
-        </el-form-item>
-        <el-form-item label="回复消息">
-          <WxReplySelect v-model="reply" />
-        </el-form-item>
-      </el-form>
+    <el-dialog
+      :title="isCreating ? '新增自动回复' : '修改自动回复'"
+      v-model="showDialog"
+      width="800px"
+      destroy-on-close
+    >
+      <ReplyForm v-model="replyForm" v-model:reply="reply" :msg-type="msgType" ref="formRef" />
       <template #footer>
         <el-button @click="cancel">取 消</el-button>
         <el-button type="primary" @click="onSubmit">确 定</el-button>
@@ -93,52 +68,43 @@
   </ContentWrap>
 </template>
 <script setup lang="ts" name="MpAutoReply">
-import WxReplySelect, { type Reply, ReplyType } from '@/views/mp/components/wx-reply'
+import ReplyForm from '@/views/mp/autoReply/components/ReplyForm.vue'
+import { type Reply, ReplyType } from '@/views/mp/components/wx-reply'
 import WxAccountSelect from '@/views/mp/components/wx-account-select'
 import * as MpAutoReplyApi from '@/api/mp/autoReply'
-import { DICT_TYPE, getDictOptions, getIntDictOptions } from '@/utils/dict'
 import { ContentWrap } from '@/components/ContentWrap'
-import type { FormInstance, TabPaneName } from 'element-plus'
+import type { TabPaneName } from 'element-plus'
 import ReplyTable from './components/ReplyTable.vue'
 import { MsgType } from './components/types'
 const message = useMessage() // 消息
 
+const accountId = ref(-1) // 公众号ID
 const msgType = ref<MsgType>(MsgType.Keyword) // 消息类型
-const RequestMessageTypes = ['text', 'image', 'voice', 'video', 'shortvideo', 'location', 'link'] // 允许选择的请求消息类型
 const loading = ref(true) // 遮罩层
 const total = ref(0) // 总条数
 const list = ref<any[]>([]) // 自动回复列表
-const formRef = ref<FormInstance | null>(null) // 表单 ref
+const formRef = ref<InstanceType<typeof ReplyForm> | null>(null) // 表单 ref
 // 查询参数
-interface QueryParams {
-  pageNo: number
-  pageSize: number
-  accountId: number
-}
-const queryParams: QueryParams = reactive({
+const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: 0
+  accountId: accountId
 })
 
-const dialogTitle = ref('') // 弹出层标题
-const showFormDialog = ref(false) // 是否显示弹出层
+const isCreating = ref(false) // 是否新建(否则编辑)
+const showDialog = ref(false) // 是否显示弹出层
 const replyForm = ref<any>({}) // 表单参数
 // 回复消息
 const reply = ref<Reply>({
   type: ReplyType.Text,
-  accountId: 0
+  accountId: -1
 })
-// 表单校验
-const rules = {
-  requestKeyword: [{ required: true, message: '请求的关键字不能为空', trigger: 'blur' }],
-  requestMatch: [{ required: true, message: '请求的关键字的匹配不能为空', trigger: 'blur' }]
-}
 
 /** 侦听账号变化 */
 const onAccountChanged = (id: number) => {
-  queryParams.accountId = id
+  accountId.value = id
   reply.value.accountId = id
+  queryParams.pageNo = 1
   getList()
 }
 
@@ -177,8 +143,8 @@ const onCreate = () => {
     accountId: queryParams.accountId
   }
 
-  dialogTitle.value = '新增自动回复'
-  showFormDialog.value = true
+  isCreating.value = true
+  showDialog.value = true
 }
 
 /** 修改按钮操作 */
@@ -210,8 +176,8 @@ const onUpdate = async (id: number) => {
   }
 
   // 打开表单
-  dialogTitle.value = '修改自动回复'
-  showFormDialog.value = true
+  isCreating.value = false
+  showDialog.value = true
 }
 
 /** 删除按钮操作 */
@@ -223,8 +189,7 @@ const onDelete = async (id: number) => {
 }
 
 const onSubmit = async () => {
-  const valid = await formRef.value?.validate()
-  if (!valid) return
+  await formRef.value?.validate()
 
   // 处理回复消息
   const submitForm: any = { ...replyForm.value }
@@ -248,7 +213,7 @@ const onSubmit = async () => {
     message.success('新增成功')
   }
 
-  showFormDialog.value = false
+  showDialog.value = false
   await getList()
 }
 
@@ -267,7 +232,7 @@ const reset = () => {
 
 // 取消按钮
 const cancel = () => {
-  showFormDialog.value = false
+  showDialog.value = false
   reset()
 }
 </script>

+ 4 - 3
src/views/mp/components/wx-account-select/main.vue

@@ -8,13 +8,14 @@
 import * as MpAccountApi from '@/api/mp/account'
 
 const account: MpAccountApi.AccountVO = reactive({
-  id: undefined,
+  id: -1,
   name: ''
 })
-const accountList: Ref<MpAccountApi.AccountVO[]> = ref([])
+
+const accountList = ref<MpAccountApi.AccountVO[]>([])
 
 const emit = defineEmits<{
-  (e: 'change', id: number, name: string): void
+  (e: 'change', id: number, name: string)
 }>()
 
 const handleQuery = async () => {

+ 67 - 0
src/views/mp/components/wx-msg/components/Msg.vue

@@ -0,0 +1,67 @@
+<template>
+  <div>
+    <MsgEvent v-if="item.type === MsgType.Event" :item="item" />
+
+    <div v-else-if="item.type === MsgType.Text">{{ item.content }}</div>
+
+    <div v-else-if="item.type === MsgType.Voice">
+      <WxVoicePlayer :url="item.mediaUrl" :content="item.recognition" />
+    </div>
+
+    <div v-else-if="item.type === MsgType.Image">
+      <a target="_blank" :href="item.mediaUrl">
+        <img :src="item.mediaUrl" style="width: 100px" />
+      </a>
+    </div>
+
+    <div
+      v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'"
+      style="text-align: center"
+    >
+      <WxVideoPlayer :url="item.mediaUrl" />
+    </div>
+
+    <div v-else-if="item.type === MsgType.Link" class="avue-card__detail">
+      <el-link type="success" :underline="false" target="_blank" :href="item.url">
+        <div class="avue-card__title"><i class="el-icon-link"></i>{{ item.title }}</div>
+      </el-link>
+      <div class="avue-card__info" style="height: unset">{{ item.description }}</div>
+    </div>
+
+    <div v-else-if="item.type === MsgType.Location">
+      <WxLocation :label="item.label" :location-y="item.locationY" :location-x="item.locationX" />
+    </div>
+
+    <div v-else-if="item.type === MsgType.News" style="width: 300px">
+      <WxNews :articles="item.articles" />
+    </div>
+
+    <div v-else-if="item.type === MsgType.Music">
+      <WxMusic
+        :title="item.title"
+        :description="item.description"
+        :thumb-media-url="item.thumbMediaUrl"
+        :music-url="item.musicUrl"
+        :hq-music-url="item.hqMusicUrl"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts" name="Msg">
+import WxVideoPlayer from '@/views/mp/components/wx-video-play'
+import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
+import WxNews from '@/views/mp/components/wx-news'
+import WxLocation from '@/views/mp/components/wx-location'
+import WxMusic from '@/views/mp/components/wx-music'
+import MsgEvent from './MsgEvent.vue'
+import { MsgType } from '../types'
+
+const props = defineProps<{
+  item: any
+}>()
+
+const item = ref<any>(props.item)
+</script>
+
+<style scoped></style>

+ 49 - 0
src/views/mp/components/wx-msg/components/MsgEvent.vue

@@ -0,0 +1,49 @@
+<template>
+  <div>
+    <div v-if="item.event === 'subscribe'">
+      <el-tag type="success">关注</el-tag>
+    </div>
+    <div v-else-if="item.event === 'unsubscribe'">
+      <el-tag type="danger">取消关注</el-tag>
+    </div>
+    <div v-else-if="item.event === 'CLICK'">
+      <el-tag>点击菜单</el-tag>
+      【{{ item.eventKey }}】
+    </div>
+    <div v-else-if="item.event === 'VIEW'">
+      <el-tag>点击菜单链接</el-tag>
+      【{{ item.eventKey }}】
+    </div>
+    <div v-else-if="item.event === 'scancode_waitmsg'">
+      <el-tag>扫码结果</el-tag>
+      【{{ item.eventKey }}】
+    </div>
+    <div v-else-if="item.event === 'scancode_push'">
+      <el-tag>扫码结果</el-tag>
+      【{{ item.eventKey }}】
+    </div>
+    <div v-else-if="item.event === 'pic_sysphoto'">
+      <el-tag>系统拍照发图</el-tag>
+    </div>
+    <div v-else-if="item.event === 'pic_photo_or_album'">
+      <el-tag>拍照或者相册</el-tag>
+    </div>
+    <div v-else-if="item.event === 'pic_weixin'">
+      <el-tag>微信相册</el-tag>
+    </div>
+    <div v-else-if="item.event === 'location_select'">
+      <el-tag>选择地理位置</el-tag>
+    </div>
+    <div v-else>
+      <el-tag type="danger">未知事件类型</el-tag>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+const props = defineProps<{
+  item: any
+}>()
+
+const item = ref(props.item)
+</script>

+ 60 - 0
src/views/mp/components/wx-msg/components/MsgList.vue

@@ -0,0 +1,60 @@
+<template>
+  <div class="execution" v-for="item in props.list" :key="item.id">
+    <div
+      class="avue-comment"
+      :class="{ 'avue-comment--reverse': item.sendFrom === SendFrom.MpBot }"
+    >
+      <div class="avatar-div">
+        <img :src="getAvatar(item.sendFrom)" class="avue-comment__avatar" />
+        <div class="avue-comment__author">
+          {{ getNickname(item.sendFrom) }}
+        </div>
+      </div>
+      <div class="avue-comment__main">
+        <div class="avue-comment__header">
+          <div class="avue-comment__create_time">{{ formatDate(item.createTime) }}</div>
+        </div>
+        <div
+          class="avue-comment__body"
+          :style="item.sendFrom === SendFrom.MpBot ? 'background: #6BED72;' : ''"
+        >
+          <Msg :item="item" />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+<script setup lang="ts" name="MsgList">
+import Msg from './Msg.vue'
+import { formatDate } from '@/utils/formatTime'
+import { User } from '../types'
+import avatarWechat from '@/assets/imgs/wechat.png'
+
+const props = defineProps<{
+  list: any[]
+  accountId: number
+  user: User
+}>()
+
+enum SendFrom {
+  User = 1,
+  MpBot = 2
+}
+
+const getAvatar = (sendFrom: SendFrom) =>
+  sendFrom === SendFrom.User ? props.user.avatar : avatarWechat
+
+const getNickname = (sendFrom: SendFrom) =>
+  sendFrom === SendFrom.User ? props.user.nickname : '公众号'
+</script>
+
+<style lang="scss" scoped>
+/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc  */
+@import '../comment.scss';
+@import '../card.scss';
+
+.avatar-div {
+  text-align: center;
+  width: 80px;
+}
+</style>

+ 41 - 186
src/views/mp/components/wx-msg/main.vue

@@ -7,123 +7,22 @@
 -->
 <template>
   <ContentWrap>
-    <div class="msg-div" :id="'msg-div' + nowStr">
+    <div class="msg-div" ref="msgDivRef">
       <!-- 加载更多 -->
       <div v-loading="loading"></div>
       <div v-if="!loading">
-        <div class="el-table__empty-block" v-if="loadMore" @click="loadingMore"
+        <div class="el-table__empty-block" v-if="hasMore" @click="loadMore"
           ><span class="el-table__empty-text">点击加载更多</span></div
         >
-        <div class="el-table__empty-block" v-if="!loadMore"
+        <div class="el-table__empty-block" v-if="!hasMore"
           ><span class="el-table__empty-text">没有更多了</span></div
         >
       </div>
+
       <!-- 消息列表 -->
-      <div class="execution" v-for="item in list" :key="item.id">
-        <div class="avue-comment" :class="item.sendFrom === 2 ? 'avue-comment--reverse' : ''">
-          <div class="avatar-div">
-            <img
-              :src="item.sendFrom === 1 ? user.avatar : mp.avatar"
-              class="avue-comment__avatar"
-            />
-            <div class="avue-comment__author"
-              >{{ item.sendFrom === 1 ? user.nickname : mp.nickname }}
-            </div>
-          </div>
-          <div class="avue-comment__main">
-            <div class="avue-comment__header">
-              <div class="avue-comment__create_time">{{ formatDate(item.createTime) }}</div>
-            </div>
-            <div
-              class="avue-comment__body"
-              :style="item.sendFrom === 2 ? 'background: #6BED72;' : ''"
-            >
-              <!-- 【事件】区域 -->
-              <div v-if="item.type === MsgType.Event && item.event === 'subscribe'">
-                <el-tag type="success">关注</el-tag>
-              </div>
-              <div v-else-if="item.type === MsgType.Event && item.event === 'unsubscribe'">
-                <el-tag type="danger">取消关注</el-tag>
-              </div>
-              <div v-else-if="item.type === MsgType.Event && item.event === 'CLICK'">
-                <el-tag>点击菜单</el-tag>
-                【{{ item.eventKey }}】
-              </div>
-              <div v-else-if="item.type === MsgType.Event && item.event === 'VIEW'">
-                <el-tag>点击菜单链接</el-tag>
-                【{{ item.eventKey }}】
-              </div>
-              <div v-else-if="item.type === MsgType.Event && item.event === 'scancode_waitmsg'">
-                <el-tag>扫码结果</el-tag>
-                【{{ item.eventKey }}】
-              </div>
-              <div v-else-if="item.type === MsgType.Event && item.event === 'scancode_push'">
-                <el-tag>扫码结果</el-tag>
-                【{{ item.eventKey }}】
-              </div>
-              <div v-else-if="item.type === MsgType.Event && item.event === 'pic_sysphoto'">
-                <el-tag>系统拍照发图</el-tag>
-              </div>
-              <div v-else-if="item.type === MsgType.Event && item.event === 'pic_photo_or_album'">
-                <el-tag>拍照或者相册</el-tag>
-              </div>
-              <div v-else-if="item.type === MsgType.Event && item.event === 'pic_weixin'">
-                <el-tag>微信相册</el-tag>
-              </div>
-              <div v-else-if="item.type === MsgType.Event && item.event === 'location_select'">
-                <el-tag>选择地理位置</el-tag>
-              </div>
-              <div v-else-if="item.type === MsgType.Event">
-                <el-tag type="danger">未知事件类型</el-tag>
-              </div>
-              <!-- 【消息】区域 -->
-              <div v-else-if="item.type === MsgType.Text">{{ item.content }}</div>
-              <div v-else-if="item.type === MsgType.Voice">
-                <WxVoicePlayer :url="item.mediaUrl" :content="item.recognition" />
-              </div>
-              <div v-else-if="item.type === MsgType.Image">
-                <a target="_blank" :href="item.mediaUrl">
-                  <img :src="item.mediaUrl" style="width: 100px" />
-                </a>
-              </div>
-              <div
-                v-else-if="item.type === MsgType.Video || item.type === 'shortvideo'"
-                style="text-align: center"
-              >
-                <WxVideoPlayer :url="item.mediaUrl" />
-              </div>
-              <div v-else-if="item.type === MsgType.Link" class="avue-card__detail">
-                <el-link type="success" :underline="false" target="_blank" :href="item.url">
-                  <div class="avue-card__title"><i class="el-icon-link"></i>{{ item.title }}</div>
-                </el-link>
-                <div class="avue-card__info" style="height: unset">{{ item.description }}</div>
-              </div>
-              <!-- TODO 芋艿:待完善 -->
-              <div v-else-if="item.type === MsgType.Location">
-                <WxLocation
-                  :label="item.label"
-                  :location-y="item.locationY"
-                  :location-x="item.locationX"
-                />
-              </div>
-              <div v-else-if="item.type === MsgType.News" style="width: 300px">
-                <!-- TODO 芋艿:待测试;详情页也存在类似的情况 -->
-                <WxNews :articles="item.articles" />
-              </div>
-              <div v-else-if="item.type === MsgType.Music">
-                <WxMusic
-                  :title="item.title"
-                  :description="item.description"
-                  :thumb-media-url="item.thumbMediaUrl"
-                  :music-url="item.musicUrl"
-                  :hq-music-url="item.hqMusicUrl"
-                />
-              </div>
-            </div>
-          </div>
-        </div>
-      </div>
+      <MsgList :list="list" :account-id="accountId" :user="user" />
     </div>
+
     <div class="msg-send" v-loading="sendLoading">
       <WxReplySelect ref="replySelectRef" v-model="reply" />
       <el-button type="success" class="send-but" @click="sendMsg">发送(S)</el-button>
@@ -132,18 +31,12 @@
 </template>
 
 <script setup lang="ts" name="WxMsg">
-import WxReplySelect from '@/views/mp/components/wx-reply'
-import WxVideoPlayer from '@/views/mp/components/wx-video-play'
-import WxVoicePlayer from '@/views/mp/components/wx-voice-play'
-import WxNews from '@/views/mp/components/wx-news'
-import WxLocation from '@/views/mp/components/wx-location'
-import WxMusic from '@/views/mp/components/wx-music'
+import WxReplySelect, { Reply, ReplyType } from '@/views/mp/components/wx-reply'
+import MsgList from './components/MsgList.vue'
 import { getMessagePage, sendMessage } from '@/api/mp/message'
 import { getUser } from '@/api/mp/user'
-import { formatDate } from '@/utils/formatTime'
 import profile from '@/assets/imgs/profile.jpg'
-import wechat from '@/assets/imgs/wechat.png'
-import { MsgType } from './types'
+import { User } from './types'
 
 const message = useMessage() // 消息弹窗
 
@@ -154,61 +47,41 @@ const props = defineProps({
   }
 })
 
-const nowStr = ref(new Date().getTime()) // 当前的时间戳,用于每次消息加载后,回到原位置;具体见 :id="'msg-div' + nowStr" 处
+const accountId = ref(-1) // 公众号ID,需要通过userId初始化
 const loading = ref(false) // 消息列表是否正在加载中
-const loadMore = ref(true) // 是否可以加载更多
+const hasMore = ref(true) // 是否可以加载更多
 const list = ref<any[]>([]) // 消息列表
 const queryParams = reactive({
   pageNo: 1, // 当前页数
   pageSize: 14, // 每页显示多少条
-  accountId: undefined
+  accountId: accountId
 })
 
-interface User {
-  nickname: string
-  avatar: string
-  accountId: number
-}
 // 由于微信不再提供昵称,直接使用“用户”展示
 const user: User = reactive({
   nickname: '用户',
   avatar: profile,
-  accountId: 0 // 公众号账号编号
-})
-
-interface Mp {
-  nickname: string
-  avatar: string
-}
-const mp: Mp = reactive({
-  nickname: '公众号',
-  avatar: wechat
+  accountId: accountId // 公众号账号编号
 })
 
 // ========= 消息发送 =========
 const sendLoading = ref(false) // 发送消息是否加载中
-interface Reply {
-  type: MsgType
-  accountId: number | null
-  articles: any[]
-}
-
 // 微信发送消息
 const reply = ref<Reply>({
-  type: MsgType.Text,
-  accountId: null,
+  type: ReplyType.Text,
+  accountId: -1,
   articles: []
 })
 
-const replySelectRef = ref<InstanceType<typeof WxReplySelect> | null>(null)
+const replySelectRef = ref<InstanceType<typeof WxReplySelect> | null>(null) // WxReplySelect组件ref,用于消息发送成功后清除内容
+const msgDivRef = ref<HTMLDivElement | null>(null) // 消息显示窗口ref,用于滚动到底部
 
 /** 完成加载 */
 onMounted(async () => {
   const data = await getUser(props.userId)
   user.nickname = data.nickname?.length > 0 ? data.nickname : user.nickname
   user.avatar = user.avatar?.length > 0 ? data.avatar : user.avatar
-  user.accountId = data.accountId
-  queryParams.accountId = data.accountId
+  accountId.value = data.accountId
   reply.value.accountId = data.accountId
 
   refreshChange()
@@ -216,11 +89,15 @@ onMounted(async () => {
 
 // 执行发送
 const sendMsg = async () => {
-  if (!reply) {
+  if (!unref(reply)) {
     return
   }
   // 公众号限制:客服消息,公众号只允许发送一条
-  if (reply.value.type === MsgType.News && reply.value.articles.length > 1) {
+  if (
+    reply.value.type === ReplyType.News &&
+    reply.value.articles &&
+    reply.value.articles.length > 1
+  ) {
     reply.value.articles = [reply.value.articles[0]]
     message.success('图文消息条数限制在 1 条以内,已默认发送第一条')
   }
@@ -229,18 +106,18 @@ const sendMsg = async () => {
   sendLoading.value = false
 
   list.value = [...list.value, ...[data]]
-  scrollToBottom()
+  await scrollToBottom()
 
   // 发送后清空数据
   replySelectRef.value?.clear()
 }
 
-const loadingMore = () => {
+const loadMore = () => {
   queryParams.pageNo++
   getPage(queryParams, null)
 }
 
-const getPage = async (page, params) => {
+const getPage = async (page: any, params: any = null) => {
   loading.value = true
   let dataTemp = await getMessagePage(
     Object.assign(
@@ -254,62 +131,45 @@ const getPage = async (page, params) => {
     )
   )
 
-  const msgDiv = document.getElementById('msg-div' + nowStr.value)
-  let scrollHeight = 0
-  if (msgDiv) {
-    scrollHeight = msgDiv.scrollHeight
-  }
+  const scrollHeight = msgDivRef.value?.scrollHeight ?? 0
   // 处理数据
   const data = dataTemp.list.reverse()
   list.value = [...data, ...list.value]
   loading.value = false
   if (data.length < queryParams.pageSize || data.length === 0) {
-    loadMore.value = false
+    hasMore.value = false
   }
   queryParams.pageNo = page.pageNo
   queryParams.pageSize = page.pageSize
   // 滚动到原来的位置
   if (queryParams.pageNo === 1) {
     // 定位到消息底部
-    scrollToBottom()
+    await scrollToBottom()
   } else if (data.length !== 0) {
     // 定位滚动条
-    await nextTick(() => {
-      if (scrollHeight !== 0) {
-        let div = document.getElementById('msg-div' + nowStr.value)
-        if (div && msgDiv) {
-          msgDiv.scrollTop = div.scrollHeight - scrollHeight - 100
-        }
+    await nextTick()
+    if (scrollHeight !== 0) {
+      if (msgDivRef.value) {
+        msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight - scrollHeight - 100
       }
-    })
+    }
   }
 }
 
 const refreshChange = () => {
-  getPage(queryParams, null)
+  getPage(queryParams)
 }
 
 /** 定位到消息底部 */
-const scrollToBottom = () => {
-  nextTick(() => {
-    let div = document.getElementById('msg-div' + nowStr.value)
-    if (div) {
-      div.scrollTop = div.scrollHeight
-    }
-  })
+const scrollToBottom = async () => {
+  await nextTick()
+  if (msgDivRef.value) {
+    msgDivRef.value.scrollTop = msgDivRef.value.scrollHeight
+  }
 }
 </script>
 
 <style lang="scss" scoped>
-/* 因为 joolun 实现依赖 avue 组件,该页面使用了 comment.scss、card.scc  */
-@import './comment.scss';
-@import './card.scss';
-
-.msg-main {
-  margin-top: -30px;
-  padding: 10px;
-}
-
 .msg-div {
   height: 50vh;
   overflow: auto;
@@ -322,11 +182,6 @@ const scrollToBottom = () => {
   padding: 10px;
 }
 
-.avatar-div {
-  text-align: center;
-  width: 80px;
-}
-
 .send-but {
   float: right;
   margin-top: 8px;

+ 6 - 0
src/views/mp/components/wx-msg/types.ts

@@ -9,3 +9,9 @@ export enum MsgType {
   Music = 'music',
   News = 'news'
 }
+
+export interface User {
+  nickname: string
+  avatar: string
+  accountId: number
+}

+ 2 - 2
src/views/mp/components/wx-music/main.vue

@@ -55,6 +55,6 @@ defineExpose({
 </script>
 
 <style lang="scss" scoped>
-/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scc  */
-@import url('../wx-msg/card.scss');
+/* 因为 joolun 实现依赖 avue 组件,该页面使用了 card.scss  */
+@import '../wx-msg/card.scss';
 </style>

+ 3 - 3
src/views/mp/draft/components/CoverSelect.vue

@@ -51,7 +51,7 @@
       >
         <WxMaterialSelect
           type="image"
-          :account-id="accountId"
+          :account-id="accountId!"
           @select-material="onMaterialSelected"
         />
       </el-dialog>
@@ -93,11 +93,11 @@ const showImageDialog = ref(false)
 const fileList = ref<UploadFiles>([])
 interface UploadData {
   type: UploadType
-  accountId: number | undefined
+  accountId: number
 }
 const uploadData: UploadData = reactive({
   type: UploadType.Image,
-  accountId: accountId
+  accountId: accountId!
 })
 
 /** 素材选择完成事件*/

+ 1 - 1
src/views/mp/draft/components/NewsForm.vue

@@ -125,7 +125,7 @@
   </el-container>
 </template>
 
-<script setup lang="ts">
+<script setup lang="ts" name="NewsForm">
 import { Editor } from '@/components/Editor'
 import { createEditorConfig } from '../editor-config'
 import CoverSelect from './CoverSelect.vue'

+ 10 - 30
src/views/mp/draft/index.vue

@@ -76,30 +76,17 @@ import {
 
 const message = useMessage() // 消息
 
-const accountId = ref<number>(0)
+const accountId = ref(-1)
 provide('accountId', accountId)
 
 const loading = ref(true) // 列表的加载中
 const list = ref<any[]>([]) // 列表的数据
 const total = ref(0) // 列表的总页数
-interface QueryParams {
-  pageNo: number
-  pageSize: number
-  accountId: number
-}
-const queryParams: QueryParams = reactive({
+
+const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: 0
-})
-
-interface UploadData {
-  type: 'image' | 'video' | 'audio'
-  accountId: number
-}
-const uploadData: UploadData = reactive({
-  type: 'image',
-  accountId: 0
+  accountId: accountId
 })
 
 // ========== 草稿新建 or 修改 ==========
@@ -111,7 +98,8 @@ const isSubmitting = ref(false)
 
 /** 侦听公众号变化 **/
 const onAccountChanged = (id: number) => {
-  setAccountId(id)
+  accountId.value = id
+  queryParams.pageNo = 1
   getList()
 }
 
@@ -124,12 +112,6 @@ const onBeforeDialogClose = async (onDone: () => {}) => {
 }
 
 // ======================== 列表查询 ========================
-/** 设置账号编号 */
-const setAccountId = (id: number) => {
-  queryParams.accountId = id
-  uploadData.accountId = id
-}
-
 /** 查询列表 */
 const getList = async () => {
   loading.value = true
@@ -170,10 +152,10 @@ const onSubmitNewsItem = async () => {
   isSubmitting.value = true
   try {
     if (isCreating.value) {
-      await MpDraftApi.createDraft(queryParams.accountId, newsList.value)
+      await MpDraftApi.createDraft(accountId.value, newsList.value)
       message.notifySuccess('新增成功')
     } else {
-      await MpDraftApi.updateDraft(queryParams.accountId, mediaId.value, newsList.value)
+      await MpDraftApi.updateDraft(accountId.value, mediaId.value, newsList.value)
       message.notifySuccess('更新成功')
     }
   } finally {
@@ -185,7 +167,6 @@ const onSubmitNewsItem = async () => {
 
 // ======================== 草稿箱发布 ========================
 const onPublish = async (item: Article) => {
-  const accountId = queryParams.accountId
   const mediaId = item.mediaId
   const content =
     '你正在通过发布的方式发表内容。 发布不占用群发次数,一天可多次发布。' +
@@ -193,7 +174,7 @@ const onPublish = async (item: Article) => {
     '发布后,你可以前往发表记录获取链接,也可以将发布内容添加到自定义菜单、自动回复、话题和页面模板中。'
   try {
     await message.confirm(content)
-    await MpFreePublishApi.submitFreePublish(accountId, mediaId)
+    await MpFreePublishApi.submitFreePublish(accountId.value, mediaId)
     message.notifySuccess('发布成功')
     await getList()
   } catch {}
@@ -201,11 +182,10 @@ const onPublish = async (item: Article) => {
 
 /** 删除按钮操作 */
 const onDelete = async (item: Article) => {
-  const accountId = queryParams.accountId
   const mediaId = item.mediaId
   try {
     await message.confirm('此操作将永久删除该草稿, 是否继续?')
-    await MpDraftApi.deleteDraft(accountId, mediaId)
+    await MpDraftApi.deleteDraft(accountId.value, mediaId)
     message.notifySuccess('删除成功')
     await getList()
   } catch {}

+ 3 - 7
src/views/mp/freePublish/index.vue

@@ -59,20 +59,16 @@ const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
 const list = ref<any[]>([]) // 列表的数据
 
-interface QueryParams {
-  pageNo: number
-  pageSize: number
-  accountId: number
-}
-const queryParams: QueryParams = reactive({
+const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: 0
+  accountId: -1
 })
 
 /** 侦听公众号变化 **/
 const onAccountChanged = (id: number) => {
   queryParams.accountId = id
+  queryParams.pageNo = 1
   getList()
 }
 

+ 3 - 8
src/views/mp/material/index.vue

@@ -100,16 +100,10 @@ const loading = ref(false) // 遮罩层
 const list = ref<any[]>([]) // 总条数
 const total = ref(0) // 数据列表
 // 查询参数
-interface QueryParams {
-  pageNo: number
-  pageSize: number
-  accountId: number
-  permanent: boolean
-}
-const queryParams: QueryParams = reactive({
+const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: 0,
+  accountId: -1,
   permanent: true
 })
 const showCreateVideo = ref(false) // 是否新建视频的弹窗
@@ -117,6 +111,7 @@ const showCreateVideo = ref(false) // 是否新建视频的弹窗
 /** 侦听公众号变化 **/
 const onAccountChanged = (id: number) => {
   queryParams.accountId = id
+  queryParams.pageNo = 1
   getList()
 }
 

+ 28 - 20
src/views/mp/menu/components/MenuPreviewer.vue

@@ -4,7 +4,7 @@
     item-key="id"
     ghost-class="draggable-ghost"
     :animation="400"
-    @end="onDragEnd"
+    @end="onParentDragEnd"
   >
     <template #item="{ element: parent, index: x }">
       <div class="menu_bottom">
@@ -23,6 +23,7 @@
             item-key="id"
             ghost-class="draggable-ghost"
             :animation="400"
+            @end="onChildDragEnd"
           >
             <template #item="{ element: child, index: y }">
               <div class="subtitle menu_bottom">
@@ -118,42 +119,49 @@ const subMenuClicked = (child: Menu, x: number, y: number) => {
 }
 
 /**
- * 处理一级菜单展开后被拖动
+ * 处理一级菜单展开后被拖动,激活(展开)原来活动的一级菜单
  *
  * @param oldIndex: 一级菜单拖动前的位置
  * @param newIndex: 一级菜单拖动后的位置
  */
-const onDragEnd = ({ oldIndex, newIndex }) => {
+const onParentDragEnd = ({ oldIndex, newIndex }) => {
   // 二级菜单没有展开,直接返回
   if (props.activeIndex === '__MENU_NOT_SELECTED__') {
     return
   }
 
-  let newParent = props.parentIndex
-  if (props.parentIndex === oldIndex) {
-    newParent = newIndex
-  } else if (props.parentIndex === newIndex) {
-    newParent = oldIndex
-  } else {
-    // 如果展开的二级菜单下标`props.parentIndex`不是被移动的菜单的前后下标。
-    // 那么使用一个辅助素组来模拟菜单移动,然后找到展开的二级菜单的新下标`newParent`
-    let positions = new Array<boolean>(menuList.value.length).fill(false)
-    positions[props.parentIndex] = true
-    positions.splice(oldIndex, 1)
-    positions.splice(newIndex, 0, true)
-    newParent = positions.indexOf(true)
-  }
+  // 使用一个辅助数组来模拟菜单移动,然后找到展开的二级菜单的新下标`newParent`
+  let positions = new Array<boolean>(menuList.value.length).fill(false)
+  positions[props.parentIndex] = true
+  const [out] = positions.splice(oldIndex, 1) // 移出菜单,保存到变量out
+  positions.splice(newIndex, 0, out) // 把out变量插入被移出的菜单
+  const newParentIndex = positions.indexOf(true)
 
   // 找到菜单元素,触发一级菜单点击
-  const parent = menuList.value[newParent]
-  emit('menu-clicked', parent, newParent)
+  const parent = menuList.value[newParentIndex]
+  emit('menu-clicked', parent, newParentIndex)
+}
+
+/**
+ * 处理二级菜单展开后被拖动,激活被拖动的菜单
+ *
+ * @param newIndex 二级菜单拖动后的位置
+ */
+const onChildDragEnd = ({ newIndex }) => {
+  const x = props.parentIndex
+  const y = newIndex
+  const children = menuList.value[x]?.children
+  if (children && children?.length > 0) {
+    const child = children[y]
+    emit('submenu-clicked', child, x, y)
+  }
 }
 </script>
 
 <style lang="scss" scoped>
 .menu_bottom {
   position: relative;
-  display: inline-block;
+  display: block;
   float: left;
   width: 85.5px;
   text-align: center;

+ 2 - 2
src/views/mp/menu/index.vue

@@ -65,7 +65,7 @@ const MENU_NOT_SELECTED = '__MENU_NOT_SELECTED__'
 
 // ======================== 列表查询 ========================
 const loading = ref(false) // 遮罩层
-const accountId = ref<number>(0)
+const accountId = ref(-1)
 const accountName = ref<string>('')
 const menuList = ref<Menu[]>([])
 
@@ -339,7 +339,7 @@ div {
 
   .left {
     position: relative;
-    display: inline-block;
+    display: block;
     float: left;
     width: 350px;
     height: 715px;

+ 5 - 12
src/views/mp/message/index.vue

@@ -93,20 +93,12 @@ const total = ref(0) // 数据的总页数
 const list = ref<any[]>([]) // 当前页的列表数据
 
 // 搜索参数
-interface QueryParams {
-  pageNo: number
-  pageSize: number
-  openid: string | undefined
-  accountId: number
-  type: MsgType | undefined
-  createTime: string[] | []
-}
-const queryParams: QueryParams = reactive({
+const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  openid: undefined,
-  accountId: 0,
-  type: undefined,
+  openid: '',
+  accountId: -1,
+  type: MsgType.Text,
   createTime: []
 })
 const queryFormRef = ref<FormInstance | null>(null) // 搜索的表单
@@ -120,6 +112,7 @@ const messageBox = reactive({
 /** 侦听accountId */
 const onAccountChanged = (id: number) => {
   queryParams.accountId = id
+  queryParams.pageNo = 1
   handleQuery()
 }
 

+ 2 - 2
src/views/mp/statistics/index.vue

@@ -84,7 +84,7 @@ const dateRange = ref([
   beginOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24 * 7)),
   endOfDay(new Date(new Date().getTime() - 3600 * 1000 * 24))
 ])
-const accountId = ref() // 选中的公众号编号
+const accountId = ref(-1) // 选中的公众号编号
 const accountList = ref<MpAccountApi.AccountVO[]>([]) // 公众号账号列表
 
 const xAxisDate = ref([] as any[]) // X 轴的日期范围
@@ -232,7 +232,7 @@ const getAccountList = async () => {
   accountList.value = await MpAccountApi.getSimpleAccountList()
   // 默认选中第一个
   if (accountList.value.length > 0) {
-    accountId.value = accountList.value[0].id
+    accountId.value = accountList.value[0].id!
   }
 }
 

+ 3 - 8
src/views/mp/tag/index.vue

@@ -95,23 +95,18 @@ const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
 const list = ref<any[]>([]) // 列表的数据
 
-interface QueryParams {
-  pageNo: number
-  pageSize: number
-  accountId: number
-}
-const queryParams: QueryParams = reactive({
+const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: 0
+  accountId: -1
 })
 
 const formRef = ref<InstanceType<typeof TagForm> | null>(null)
 
 /** 侦听公众号变化 **/
 const onAccountChanged = (id: number) => {
-  queryParams.pageNo = 1
   queryParams.accountId = id
+  queryParams.pageNo = 1
   getList()
 }
 

+ 5 - 12
src/views/mp/user/index.vue

@@ -113,27 +113,20 @@ const loading = ref(true) // 列表的加载中
 const total = ref(0) // 列表的总页数
 const list = ref<any[]>([]) // 列表的数据
 
-interface QueryParams {
-  pageNo: number
-  pageSize: number
-  accountId: number
-  openid: string | null
-  nickname: string | null
-}
-const queryParams: QueryParams = reactive({
+const queryParams = reactive({
   pageNo: 1,
   pageSize: 10,
-  accountId: 0,
-  openid: null,
-  nickname: null
+  accountId: -1,
+  openid: '',
+  nickname: ''
 })
 const queryFormRef = ref<FormInstance | null>(null) // 搜索的表单
 const tagList = ref<any[]>([]) // 公众号标签列表
 
 /** 侦听公众号变化 **/
 const onAccountChanged = (id: number) => {
-  queryParams.pageNo = 1
   queryParams.accountId = id
+  queryParams.pageNo = 1
   getList()
 }
 

+ 43 - 34
src/views/system/dict/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="type">
         <el-input
           v-model="queryParams.type"
-          placeholder="请输入字典类型"
+          class="!w-240px"
           clearable
+          placeholder="请输入字典类型"
           @keyup.enter="handleQuery"
-          class="!w-240px"
         />
       </el-form-item>
       <el-form-item label="状态" prop="status">
         <el-select
           v-model="queryParams.status"
-          placeholder="请选择字典状态"
-          clearable
           class="!w-240px"
+          clearable
+          placeholder="请选择字典状态"
         >
           <el-option
             v-for="dict in getIntDictOptions(DICT_TYPE.COMMON_STATUS)"
@@ -44,33 +44,41 @@
       <el-form-item label="创建时间" prop="createTime">
         <el-date-picker
           v-model="queryParams.createTime"
-          value-format="yyyy-MM-dd HH:mm:ss"
-          type="daterange"
-          start-placeholder="开始日期"
-          end-placeholder="结束日期"
           :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
           class="!w-240px"
+          end-placeholder="结束日期"
+          start-placeholder="开始日期"
+          type="daterange"
+          value-format="yyyy-MM-dd HH:mm:ss"
         />
       </el-form-item>
       <el-form-item>
-        <el-button @click="handleQuery"><Icon icon="ep:search" class="mr-5px" /> 搜索</el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button @click="handleQuery">
+          <Icon class="mr-5px" icon="ep:search" />
+          搜索
+        </el-button>
+        <el-button @click="resetQuery">
+          <Icon class="mr-5px" icon="ep:refresh" />
+          重置
+        </el-button>
         <el-button
-          type="primary"
+          v-hasPermi="['system:dict:create']"
           plain
+          type="primary"
           @click="openForm('create')"
-          v-hasPermi="['system:dict:create']"
         >
-          <Icon icon="ep:plus" class="mr-5px" /> 新增
+          <Icon class="mr-5px" icon="ep:plus" />
+          新增
         </el-button>
         <el-button
-          type="success"
+          v-hasPermi="['system:dict:export']"
+          :loading="exportLoading"
           plain
+          type="success"
           @click="handleExport"
-          :loading="exportLoading"
-          v-hasPermi="['system:dict:export']"
         >
-          <Icon icon="ep:download" class="mr-5px" /> 导出
+          <Icon class="mr-5px" icon="ep:download" />
+          导出
         </el-button>
       </el-form-item>
     </el-form>
@@ -79,29 +87,29 @@
   <!-- 列表 -->
   <ContentWrap>
     <el-table v-loading="loading" :data="list">
-      <el-table-column label="字典编号" align="center" prop="id" />
-      <el-table-column label="字典名称" align="center" prop="name" show-overflow-tooltip />
-      <el-table-column label="字典类型" align="center" prop="type" width="300" />
-      <el-table-column label="状态" align="center" prop="status">
+      <el-table-column align="center" label="字典编号" prop="id" />
+      <el-table-column align="center" label="字典名称" prop="name" show-overflow-tooltip />
+      <el-table-column align="center" label="字典类型" prop="type" width="300" />
+      <el-table-column align="center" label="状态" prop="status">
         <template #default="scope">
           <dict-tag :type="DICT_TYPE.COMMON_STATUS" :value="scope.row.status" />
         </template>
       </el-table-column>
-      <el-table-column label="备注" align="center" prop="remark" />
+      <el-table-column align="center" label="备注" prop="remark" />
       <el-table-column
-        label="创建时间"
         :formatter="dateFormatter"
         align="center"
+        label="创建时间"
         prop="createTime"
         width="180"
       />
-      <el-table-column label="操作" align="center">
+      <el-table-column align="center" label="操作">
         <template #default="scope">
           <el-button
+            v-hasPermi="['system:dict:update']"
             link
             type="primary"
             @click="openForm('update', scope.row.id)"
-            v-hasPermi="['system:dict:update']"
           >
             修改
           </el-button>
@@ -109,10 +117,10 @@
             <el-button link type="primary">数据</el-button>
           </router-link>
           <el-button
+            v-hasPermi="['system:dict:delete']"
             link
             type="danger"
             @click="handleDelete(scope.row.id)"
-            v-hasPermi="['system:dict:delete']"
           >
             删除
           </el-button>
@@ -121,9 +129,9 @@
     </el-table>
     <!-- 分页 -->
     <Pagination
-      :total="total"
-      v-model:page="queryParams.pageNo"
       v-model:limit="queryParams.pageSize"
+      v-model:page="queryParams.pageNo"
+      :total="total"
       @pagination="getList"
     />
   </ContentWrap>
@@ -132,12 +140,13 @@
   <DictTypeForm ref="formRef" @success="getList" />
 </template>
 
-<script setup lang="ts" name="SystemDictType">
-import { getIntDictOptions, DICT_TYPE } from '@/utils/dict'
+<script lang="ts" name="SystemDictType" setup>
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 import { dateFormatter } from '@/utils/formatTime'
 import * as DictTypeApi from '@/api/system/dict/dict.type'
 import DictTypeForm from './DictTypeForm.vue'
 import download from '@/utils/download'
+
 const message = useMessage() // 消息弹窗
 const { t } = useI18n() // 国际化