Quellcode durchsuchen

!159 完善商品查看详情,修改了一些问题
Merge pull request !159 from puhui999/dev-to-dev

芋道源码 vor 1 Jahr
Ursprung
Commit
541b23c8c1

+ 2 - 2
.env.dev

@@ -19,13 +19,13 @@ VITE_API_URL=/admin-api
 VITE_BASE_PATH=/
 
 # 是否删除debugger
-VITE_DROP_DEBUGGER=false
+VITE_DROP_DEBUGGER=true
 
 # 是否删除console.log
 VITE_DROP_CONSOLE=false
 
 # 是否sourcemap
-VITE_SOURCEMAP=true
+VITE_SOURCEMAP=false
 
 # 输出路径
 VITE_OUT_DIR=dist-dev

+ 3 - 2
package.json

@@ -62,8 +62,9 @@
     "qs": "^6.11.1",
     "steady-xml": "^0.1.0",
     "url": "^0.11.0",
-    "video.js": "^8.0.4",
-    "vue": "3.2.47",
+    "video.js": "^8.3.0",
+    "vue": "3.3.4",
+    "vue-dompurify-html": "^4.1.4",
     "vue-i18n": "9.2.2",
     "vue-router": "^4.1.6",
     "vue-types": "^5.0.2",

+ 6 - 8
src/api/mall/product/spu.ts

@@ -7,8 +7,7 @@ export interface Property {
   valueName?: string // 属性值名称
 }
 
-// TODO puhui999:是不是直接叫 Sku 更简洁一点哈。type 待后面,总感觉有个类型?
-export interface SkuType {
+export interface Sku {
   id?: number // 商品 SKU 编号
   spuId?: number // SPU 编号
   properties?: Property[] // 属性数组
@@ -25,8 +24,7 @@ export interface SkuType {
   salesCount?: number // 商品销量
 }
 
-// TODO puhui999:是不是直接叫 Spu 更简洁一点哈。type 待后面,总感觉有个类型?
-export interface SpuType {
+export interface Spu {
   id?: number
   name?: string // 商品名称
   categoryId?: number | null // 商品分类
@@ -39,9 +37,9 @@ export interface SpuType {
   brandId?: number | null // 商品品牌编号
   specType?: boolean // 商品规格
   subCommissionType?: boolean // 分销类型
-  skus: SkuType[] // sku数组
+  skus?: Sku[] // sku数组
   description?: string // 商品详情
-  sort?: string // 商品排序
+  sort?: number // 商品排序
   giveIntegral?: number // 赠送积分
   virtualSalesCount?: number // 虚拟销量
   recommendHot?: boolean // 是否热卖
@@ -62,12 +60,12 @@ export const getTabsCount = () => {
 }
 
 // 创建商品 Spu
-export const createSpu = (data: SpuType) => {
+export const createSpu = (data: Spu) => {
   return request.post({ url: '/product/spu/create', data })
 }
 
 // 更新商品 Spu
-export const updateSpu = (data: SpuType) => {
+export const updateSpu = (data: Spu) => {
   return request.put({ url: '/product/spu/update', data })
 }
 

+ 5 - 0
src/api/mall/trade/delivery/expressTemplate/index.ts

@@ -33,6 +33,11 @@ export const getDeliveryExpressTemplate = async (id: number) => {
   return await request.get({ url: '/trade/delivery/express-template/get?id=' + id })
 }
 
+// 查询快递运费模板详情
+export const getSimpleTemplateList = async () => {
+  return await request.get({ url: '/trade/delivery/express-template/list-all-simple' })
+}
+
 // 新增快递运费模板
 export const createDeliveryExpressTemplate = async (data: DeliveryExpressTemplateVO) => {
   return await request.post({ url: '/trade/delivery/express-template/create', data })

+ 7 - 7
src/components/Form/src/Form.vue

@@ -1,16 +1,16 @@
 <script lang="tsx">
-import { PropType, defineComponent, ref, computed, unref, watch, onMounted } from 'vue'
-import { ElForm, ElFormItem, ElRow, ElCol, ElTooltip } from 'element-plus'
+import { computed, defineComponent, onMounted, PropType, ref, unref, watch } from 'vue'
+import { ElCol, ElForm, ElFormItem, ElRow, ElTooltip } from 'element-plus'
 import { componentMap } from './componentMap'
 import { propTypes } from '@/utils/propTypes'
 import { getSlot } from '@/utils/tsxHelper'
 import {
-  setTextPlaceholder,
-  setGridProp,
+  initModel,
   setComponentProps,
+  setFormItemSlots,
+  setGridProp,
   setItemComponentSlots,
-  initModel,
-  setFormItemSlots
+  setTextPlaceholder
 } from './helper'
 import { useRenderSelect } from './components/useRenderSelect'
 import { useRenderRadio } from './components/useRenderRadio'
@@ -196,7 +196,7 @@ export default defineComponent({
               <span>{item.label}</span>
               <ElTooltip placement="right" raw-content>
                 {{
-                  content: () => <span v-html={item.labelMessage}></span>,
+                  content: () => <span v-dompurify-html={item.labelMessage}></span>,
                   default: () => (
                     <Icon
                       icon="ep:warning"

+ 4 - 1
src/main.ts

@@ -38,9 +38,10 @@ import App from './App.vue'
 import './permission'
 
 import '@/plugins/tongji' // 百度统计
-
 import Logger from '@/utils/Logger'
 
+import VueDOMPurifyHTML from 'vue-dompurify-html' // 解决v-html 的安全隐患
+
 // 创建实例
 const setupAll = async () => {
   const app = createApp(App)
@@ -61,6 +62,8 @@ const setupAll = async () => {
 
   await router.isReady()
 
+  app.use(VueDOMPurifyHTML)
+
   app.mount('#app')
 }
 

+ 13 - 0
src/router/modules/remaining.ts

@@ -379,6 +379,19 @@ const remainingRouter: AppRouteRecordRaw[] = [
           title: '编辑商品',
           activeMenu: '/product/product-spu'
         }
+      },
+      {
+        path: 'productSpuDetail/:spuId(\\d+)',
+        component: () => import('@/views/mall/product/spu/addForm.vue'),
+        name: 'productSpuDetail',
+        meta: {
+          noCache: true,
+          hidden: true,
+          canTo: true,
+          icon: 'ep:view',
+          title: '商品详情',
+          activeMenu: '/product/product-spu'
+        }
       }
     ]
   }

+ 92 - 0
src/utils/tree.ts

@@ -3,6 +3,7 @@ interface TreeHelperConfig {
   children: string
   pid: string
 }
+
 const DEFAULT_CONFIG: TreeHelperConfig = {
   id: 'id',
   children: 'children',
@@ -133,6 +134,7 @@ export const filter = <T = any>(
 ): T[] => {
   config = getConfig(config)
   const children = config.children as string
+
   function listFilter(list: T[]) {
     return list
       .map((node: any) => ({ ...node }))
@@ -141,6 +143,7 @@ export const filter = <T = any>(
         return func(node) || (node[children] && node[children].length)
       })
   }
+
   return listFilter(tree)
 }
 
@@ -264,6 +267,7 @@ export const handleTree = (data: any[], id?: string, parentId?: string, children
       }
     }
   }
+
   return tree
 }
 
@@ -302,3 +306,91 @@ export const handleTree2 = (data, id, parentId, children, rootId) => {
   })
   return treeData !== '' ? treeData : data
 }
+/**
+ *
+ * @param tree 要操作的树结构数据
+ * @param nodeId 需要判断在什么层级的数据
+ * @param level 检查的级别, 默认检查到二级
+ */
+export const checkSelectedNode = (tree: any[], nodeId: any, level = 2): boolean => {
+  if (typeof tree === 'undefined' || !Array.isArray(tree) || tree.length === 0) {
+    console.warn('tree must be an array')
+    return false
+  }
+
+  // 校验是否是一级节点
+  if (tree.some((item) => item.id === nodeId)) {
+    return false
+  }
+
+  // 递归计数
+  let count = 1
+
+  // 深层次校验
+  function performAThoroughValidation(arr: any[]): boolean {
+    count += 1
+    for (const item of arr) {
+      if (item.id === nodeId) {
+        return true
+      } else if (typeof item.children !== 'undefined' && item.children.length !== 0) {
+        if (performAThoroughValidation(item.children)) {
+          return true
+        }
+      }
+    }
+    return false
+  }
+
+  for (const item of tree) {
+    count = 1
+    if (performAThoroughValidation(item.children)) {
+      // 找到后对比是否是期望的层级
+      if (count >= level) {
+        return true
+      }
+    }
+  }
+
+  return false
+}
+
+/**
+ * 获取节点的完整结构
+ * @param tree 树数据
+ * @param nodeId 节点 id
+ */
+export const treeToString = (tree: any[], nodeId) => {
+  if (typeof tree === 'undefined' || !Array.isArray(tree) || tree.length === 0) {
+    console.warn('tree must be an array')
+    return ''
+  }
+  // 校验是否是一级节点
+  const node = tree.find((item) => item.id === nodeId)
+  if (typeof node !== 'undefined') {
+    return node.name
+  }
+  let str = ''
+
+  function performAThoroughValidation(arr) {
+    for (const item of arr) {
+      if (item.id === nodeId) {
+        str += `/${item.name}`
+        return true
+      } else if (typeof item.children !== 'undefined' && item.children.length !== 0) {
+        str += `/${item.name}`
+        if (performAThoroughValidation(item.children)) {
+          return true
+        }
+      }
+    }
+    return false
+  }
+
+  for (const item of tree) {
+    str = `${item.name}`
+    if (performAThoroughValidation(item.children)) {
+      break
+    }
+  }
+  return str
+}

+ 4 - 4
src/views/infra/build/index.vue

@@ -16,20 +16,20 @@
   </ContentWrap>
 
   <!-- 弹窗:表单预览 -->
-  <Dialog :title="dialogTitle" v-model="dialogVisible" max-height="600">
-    <div ref="editor" v-if="dialogVisible">
+  <Dialog v-model="dialogVisible" :title="dialogTitle" max-height="600">
+    <div v-if="dialogVisible" ref="editor">
       <el-button style="float: right" @click="copy(formData)">
         {{ t('common.copy') }}
       </el-button>
       <el-scrollbar height="580">
         <div>
-          <pre><code class="hljs" v-html="highlightedCode(formData)"></code></pre>
+          <pre><code v-dompurify-html="highlightedCode(formData)" class="hljs"></code></pre>
         </div>
       </el-scrollbar>
     </div>
   </Dialog>
 </template>
-<script setup lang="ts" name="InfraBuild">
+<script lang="ts" name="InfraBuild" setup>
 import FcDesigner from '@form-create/designer'
 import { useClipboard } from '@vueuse/core'
 import { isString } from '@/utils/is'

+ 1 - 1
src/views/infra/codegen/PreviewCode.vue

@@ -46,7 +46,7 @@
               {{ t('common.copy') }}
             </el-button>
             <div>
-              <pre><code class="hljs" v-html="highlightedCode(item)"></code></pre>
+              <pre><code v-dompurify-html="highlightedCode(item)" class="hljs"></code></pre>
             </div>
           </el-tab-pane>
         </el-tabs>

+ 20 - 11
src/views/mall/product/spu/addForm.vue

@@ -5,6 +5,7 @@
         <BasicInfoForm
           ref="basicInfoRef"
           v-model:activeName="activeName"
+          :is-detail="isDetail"
           :propFormData="formData"
         />
       </el-tab-pane>
@@ -12,6 +13,7 @@
         <DescriptionForm
           ref="descriptionRef"
           v-model:activeName="activeName"
+          :is-detail="isDetail"
           :propFormData="formData"
         />
       </el-tab-pane>
@@ -19,13 +21,16 @@
         <OtherSettingsForm
           ref="otherSettingsRef"
           v-model:activeName="activeName"
+          :is-detail="isDetail"
           :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 v-if="!isDetail" :loading="formLoading" type="primary" @click="submitForm">
+          保存
+        </el-button>
         <el-button @click="close">返回</el-button>
       </el-form-item>
     </el-form>
@@ -42,16 +47,17 @@ import { convertToInteger, formatToFraction } from '@/utils'
 const { t } = useI18n() // 国际化
 const message = useMessage() // 消息弹窗
 const { push, currentRoute } = useRouter() // 路由
-const { params } = useRoute() // 查询参数
+const { params, name } = useRoute() // 查询参数
 const { delView } = useTagsViewStore() // 视图操作
 
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
 const activeName = ref('basicInfo') // Tag 激活的窗口
+const isDetail = ref(false) // 是否查看详情
 const basicInfoRef = ref<ComponentRef<typeof BasicInfoForm>>() // 商品信息Ref
 const descriptionRef = ref<ComponentRef<typeof DescriptionForm>>() // 商品详情Ref
 const otherSettingsRef = ref<ComponentRef<typeof OtherSettingsForm>>() // 其他设置Ref
 // spu 表单数据
-const formData = ref<ProductSpuApi.SpuType>({
+const formData = ref<ProductSpuApi.Spu>({
   name: '', // 商品名称
   categoryId: null, // 商品分类
   keyword: '', // 关键字
@@ -59,7 +65,7 @@ const formData = ref<ProductSpuApi.SpuType>({
   picUrl: '', // 商品封面图
   sliderPicUrls: [], // 商品轮播图
   introduction: '', // 商品简介
-  deliveryTemplateId: 1, // 运费模版
+  deliveryTemplateId: null, // 运费模版
   brandId: null, // 商品品牌
   specType: false, // 商品规格
   subCommissionType: false, // 分销类型
@@ -90,12 +96,15 @@ const formData = ref<ProductSpuApi.SpuType>({
 
 /** 获得详情 */
 const getDetail = async () => {
+  if ('productSpuDetail' === name) {
+    isDetail.value = true
+  }
   const id = params.spuId as number
   if (id) {
     formLoading.value = true
     try {
-      const res = (await ProductSpuApi.getSpu(id)) as ProductSpuApi.SpuType
-      res.skus.forEach((item) => {
+      const res = (await ProductSpuApi.getSpu(id)) as ProductSpuApi.Spu
+      res.skus!.forEach((item) => {
         // 回显价格分转元
         item.price = formatToFraction(item.price)
         item.marketPrice = formatToFraction(item.marketPrice)
@@ -120,9 +129,10 @@ const submitForm = async () => {
     await unref(basicInfoRef)?.validate()
     await unref(descriptionRef)?.validate()
     await unref(otherSettingsRef)?.validate()
-    const deepCopyFormData = cloneDeep(unref(formData.value)) // 深拷贝一份 fix:这样最终 server 端不满足,不需要恢复,
-    // TODO 兜底处理 sku 空数据
-    formData.value.skus.forEach((sku) => {
+    // 深拷贝一份, 这样最终 server 端不满足,不需要恢复,
+    const deepCopyFormData = cloneDeep(unref(formData.value))
+    // 兜底处理 sku 空数据
+    formData.value.skus!.forEach((sku) => {
       // 因为是空数据这里判断一下商品条码是否为空就行
       if (sku.barCode === '') {
         const index = deepCopyFormData.skus.findIndex(
@@ -150,7 +160,7 @@ const submitForm = async () => {
     })
     deepCopyFormData.sliderPicUrls = newSliderPicUrls
     // 校验都通过后提交表单
-    const data = deepCopyFormData as ProductSpuApi.SpuType
+    const data = deepCopyFormData as ProductSpuApi.Spu
     const id = params.spuId as number
     if (!id) {
       await ProductSpuApi.createSpu(data)
@@ -170,7 +180,6 @@ const close = () => {
   delView(unref(currentRoute))
   push('/product/product-spu')
 }
-
 /** 初始化 */
 onMounted(async () => {
   await getDetail()

+ 125 - 32
src/views/mall/product/spu/components/BasicInfoForm.vue

@@ -1,5 +1,11 @@
 <template>
-  <el-form ref="productSpuBasicInfoRef" :model="formData" :rules="rules" label-width="120px">
+  <el-form
+    v-if="!isDetail"
+    ref="productSpuBasicInfoRef"
+    :model="formData"
+    :rules="rules"
+    label-width="120px"
+  >
     <el-row>
       <el-col :span="12">
         <el-form-item label="商品名称" prop="name">
@@ -7,7 +13,7 @@
         </el-form-item>
       </el-col>
       <el-col :span="12">
-        <!-- TODO @puhui999:只能选根节点 -->
+        <!-- TODO @puhui999:只能选根节点 fix: 已完善-->
         <el-form-item label="商品分类" prop="categoryId">
           <el-tree-select
             v-model="formData.categoryId"
@@ -17,6 +23,7 @@
             class="w-1/1"
             node-key="id"
             placeholder="请选择商品分类"
+            @change="nodeClick"
           />
         </el-form-item>
       </el-col>
@@ -60,9 +67,15 @@
       <el-col :span="12">
         <el-form-item label="运费模板" prop="deliveryTemplateId">
           <el-select v-model="formData.deliveryTemplateId" placeholder="请选择">
-            <el-option v-for="item in []" :key="item.id" :label="item.name" :value="item.id" />
+            <el-option
+              v-for="item in deliveryTemplateList"
+              :key="item.id"
+              :label="item.name"
+              :value="item.id"
+            />
           </el-select>
-          <el-button class="ml-20px">运费模板</el-button>
+          <!-- TODO 可能情况:善品录入后选择运费发现下拉选择中没有对应的模版 这里需不需要做添加运费模版后选择的功能 -->
+          <!-- <el-button class="ml-20px">运费模板</el-button>-->
         </el-form-item>
       </el-col>
       <el-col :span="12">
@@ -95,6 +108,9 @@
       </el-col>
       <!-- 多规格添加-->
       <el-col :span="24">
+        <el-form-item v-if="!formData.specType">
+          <SkuList ref="skuListRef" :prop-form-data="formData" :propertyList="propertyList" />
+        </el-form-item>
         <el-form-item v-if="formData.specType" label="商品属性">
           <el-button class="mr-15px mb-10px" @click="attributesAddFormRef.open">添加规格</el-button>
           <ProductAttributes :propertyList="propertyList" @success="generateSkus" />
@@ -107,34 +123,89 @@
             <SkuList ref="skuListRef" :prop-form-data="formData" :propertyList="propertyList" />
           </el-form-item>
         </template>
-        <el-form-item v-if="!formData.specType">
-          <SkuList :prop-form-data="formData" :propertyList="propertyList" />
-        </el-form-item>
       </el-col>
     </el-row>
   </el-form>
   <ProductAttributesAddForm ref="attributesAddFormRef" :propertyList="propertyList" />
+  <!-- 详情跟表单放在一块可以共用已有功能,再抽离成组件有点过度封装的感觉 -->
+  <Descriptions v-if="isDetail" :data="formData" :schema="allSchemas.detailSchema">
+    <template #categoryId="{ row }"> {{ categoryString(row.categoryId) }}</template>
+    <template #brandId="{ row }">
+      {{ brandList.find((item) => item.id === row.brandId)?.name }}
+    </template>
+    <template #deliveryTemplateId="{ row }">
+      {{ deliveryTemplateList.find((item) => item.id === row.deliveryTemplateId)?.name }}
+    </template>
+    <template #specType="{ row }">
+      {{ row.specType ? '多规格' : '单规格' }}
+    </template>
+    <template #subCommissionType="{ row }">
+      {{ row.subCommissionType ? '自行设置' : '默认设置' }}
+    </template>
+    <template #picUrl="{ row }">
+      <el-image :src="row.picUrl" class="w-60px h-60px" @click="imagePreview(row.picUrl)" />
+    </template>
+    <template #sliderPicUrls="{ row }">
+      <el-image
+        v-for="(item, index) in row.sliderPicUrls"
+        :key="index"
+        :src="item.url"
+        class="w-60px h-60px mr-10px"
+        @click="imagePreview(row.sliderPicUrls)"
+      />
+    </template>
+    <template #skus>
+      <SkuList
+        ref="skuDetailListRef"
+        :is-detail="isDetail"
+        :prop-form-data="formData"
+        :propertyList="propertyList"
+      />
+    </template>
+  </Descriptions>
 </template>
 <script lang="ts" name="ProductSpuBasicInfoForm" setup>
 import { PropType } from 'vue'
+import { isArray } from '@/utils/is'
 import { copyValueToTarget } from '@/utils'
 import { propTypes } from '@/utils/propTypes'
-import { defaultProps, handleTree } from '@/utils/tree'
+import { checkSelectedNode, defaultProps, handleTree, treeToString } from '@/utils/tree'
+import { createImageViewer } from '@/components/ImageViewer'
 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
-import type { SpuType } from '@/api/mall/product/spu'
 import { UploadImg, UploadImgs } from '@/components/UploadFile'
 import { ProductAttributes, ProductAttributesAddForm, SkuList } from './index'
+import { basicInfoSchema } from './spu.data'
+import type { Spu } from '@/api/mall/product/spu'
 import * as ProductCategoryApi from '@/api/mall/product/category'
 import { getSimpleBrandList } from '@/api/mall/product/brand'
+import { getSimpleTemplateList } from '@/api/mall/trade/delivery/expressTemplate/index'
+// ====== 商品详情相关操作 ======
+const { allSchemas } = useCrudSchemas(basicInfoSchema)
+/** 商品图预览 */
+const imagePreview = (args) => {
+  const urlList = []
+  if (isArray(args)) {
+    args.forEach((item) => {
+      urlList.push(item.url)
+    })
+  } else {
+    urlList.push(args)
+  }
+  createImageViewer({
+    urlList
+  })
+}
+// ====== end ======
 
 const message = useMessage() // 消息弹窗
 
 const props = defineProps({
   propFormData: {
-    type: Object as PropType<SpuType>,
+    type: Object as PropType<Spu>,
     default: () => {}
   },
-  activeName: propTypes.string.def('')
+  activeName: propTypes.string.def(''),
+  isDetail: propTypes.bool.def(false) // 是否作为详情组件
 })
 const attributesAddFormRef = ref() // 添加商品属性表单
 const productSpuBasicInfoRef = ref() // 表单 Ref
@@ -144,15 +215,15 @@ const skuListRef = ref() // 商品属性列表Ref
 const generateSkus = (propertyList) => {
   skuListRef.value.generateTableData(propertyList)
 }
-const formData = reactive<SpuType>({
+const formData = reactive<Spu>({
   name: '', // 商品名称
   categoryId: null, // 商品分类
   keyword: '', // 关键字
-  unit: '', // 单位
+  unit: null, // 单位
   picUrl: '', // 商品封面图
   sliderPicUrls: [], // 商品轮播图
   introduction: '', // 商品简介
-  deliveryTemplateId: 1, // 运费模版
+  deliveryTemplateId: null, // 运费模版
   brandId: null, // 商品品牌
   specType: false, // 商品规格
   subCommissionType: false, // 分销类型
@@ -185,26 +256,24 @@ watch(
     formData.sliderPicUrls = data['sliderPicUrls'].map((item) => ({
       url: item
     }))
-    // TODO @puhui999:if return,减少嵌套层级
     // 只有是多规格才处理
-    if (formData.specType) {
-      //  直接拿返回的 skus 属性逆向生成出 propertyList
-      const properties = []
-      formData.skus.forEach((sku) => {
-        sku.properties.forEach(({ propertyId, propertyName, valueId, valueName }) => {
-          // 添加属性
-          if (!properties.some((item) => item.id === propertyId)) {
-            properties.push({ id: propertyId, name: propertyName, values: [] })
-          }
-          // 添加属性值
-          const index = properties.findIndex((item) => item.id === propertyId)
-          if (!properties[index].values.some((value) => value.id === valueId)) {
-            properties[index].values.push({ id: valueId, name: valueName })
-          }
-        })
+    if (!formData.specType) return
+    //  直接拿返回的 skus 属性逆向生成出 propertyList
+    const properties = []
+    formData.skus.forEach((sku) => {
+      sku.properties.forEach(({ propertyId, propertyName, valueId, valueName }) => {
+        // 添加属性
+        if (!properties.some((item) => item.id === propertyId)) {
+          properties.push({ id: propertyId, name: propertyName, values: [] })
+        }
+        // 添加属性值
+        const index = properties.findIndex((item) => item.id === propertyId)
+        if (!properties[index].values.some((value) => value.id === valueId)) {
+          properties[index].values.push({ id: valueId, name: valueName })
+        }
       })
-      propertyList.value = properties
-    }
+    })
+    propertyList.value = properties
   },
   {
     immediate: true
@@ -216,6 +285,11 @@ watch(
  */
 const emit = defineEmits(['update:activeName'])
 const validate = async () => {
+  // 校验 sku
+  if (!skuListRef.value.validateSku()) {
+    message.warning('商品相关价格不能低于0.01元!!')
+    throw new Error('商品相关价格不能低于0.01元!!')
+  }
   // 校验表单
   if (!productSpuBasicInfoRef) return
   return await unref(productSpuBasicInfoRef).validate((valid) => {
@@ -263,12 +337,31 @@ const onChangeSpec = () => {
 }
 
 const categoryList = ref([]) // 分类树
+/**
+ * 选择分类时触发校验
+ */
+const nodeClick = () => {
+  if (!checkSelectedNode(categoryList.value, formData.categoryId)) {
+    formData.categoryId = null
+    message.warning('必须选择二级及以下节点!!')
+  }
+}
+/**
+ * 获取分类的节点的完整结构
+ * @param categoryId 分类id
+ */
+const categoryString = (categoryId) => {
+  return treeToString(categoryList.value, categoryId)
+}
 const brandList = ref([]) // 精简商品品牌列表
+const deliveryTemplateList = ref([]) // 运费模版
 onMounted(async () => {
   // 获得分类树
   const data = await ProductCategoryApi.getCategoryList({})
   categoryList.value = handleTree(data, 'id', 'parentId')
   // 获取商品品牌列表
   brandList.value = await getSimpleBrandList()
+  // 获取运费模版
+  deliveryTemplateList.value = await getSimpleTemplateList()
 })
 </script>

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

@@ -1,28 +1,49 @@
 <template>
-  <el-form ref="descriptionFormRef" :model="formData" :rules="rules" label-width="120px">
+  <el-form
+    v-if="!isDetail"
+    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>
+  <Descriptions
+    v-if="isDetail"
+    :data="formData"
+    :schema="allSchemas.detailSchema"
+    class="descriptionFormDescriptions"
+  >
+    <!-- 展示 HTML 内容 -->
+    <template #description="{ row }">
+      <div v-dompurify-html="row.description" style="width: 600px"></div>
+    </template>
+  </Descriptions>
 </template>
 <script lang="ts" name="DescriptionForm" setup>
-import type { SpuType } from '@/api/mall/product/spu'
+import type { Spu } from '@/api/mall/product/spu'
 import { Editor } from '@/components/Editor'
 import { PropType } from 'vue'
 import { propTypes } from '@/utils/propTypes'
 import { copyValueToTarget } from '@/utils'
+import { descriptionSchema } from './spu.data'
+
+const { allSchemas } = useCrudSchemas(descriptionSchema)
 
 const message = useMessage() // 消息弹窗
 const props = defineProps({
   propFormData: {
-    type: Object as PropType<SpuType>,
+    type: Object as PropType<Spu>,
     default: () => {}
   },
-  activeName: propTypes.string.def('')
+  activeName: propTypes.string.def(''),
+  isDetail: propTypes.bool.def(false) // 是否作为详情组件
 })
 const descriptionFormRef = ref() // 表单Ref
-const formData = ref<SpuType>({
+const formData = ref<Spu>({
   description: '' // 商品详情
 })
 // 表单规则

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

@@ -1,5 +1,11 @@
 <template>
-  <el-form ref="otherSettingsFormRef" :model="formData" :rules="rules" label-width="120px">
+  <el-form
+    v-if="!isDetail"
+    ref="otherSettingsFormRef"
+    :model="formData"
+    :rules="rules"
+    label-width="120px"
+  >
     <el-row>
       <el-col :span="24">
         <el-row :gutter="20">
@@ -50,26 +56,53 @@
       </el-col>
     </el-row>
   </el-form>
+  <Descriptions v-if="isDetail" :data="formData" :schema="allSchemas.detailSchema">
+    <template #recommendHot="{ row }">
+      {{ row.recommendHot ? '是' : '否' }}
+    </template>
+    <template #recommendBenefit="{ row }">
+      {{ row.recommendBenefit ? '是' : '否' }}
+    </template>
+    <template #recommendBest="{ row }">
+      {{ row.recommendBest ? '是' : '否' }}
+    </template>
+    <template #recommendNew="{ row }">
+      {{ row.recommendNew ? '是' : '否' }}
+    </template>
+    <template #recommendGood="{ row }">
+      {{ row.recommendGood ? '是' : '否' }}
+    </template>
+    <template #activityOrders>
+      <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>
+    </template>
+  </Descriptions>
 </template>
 <script lang="ts" name="OtherSettingsForm" setup>
-import type { SpuType } from '@/api/mall/product/spu'
+import type { Spu } from '@/api/mall/product/spu'
 import { PropType } from 'vue'
 import { propTypes } from '@/utils/propTypes'
 import { copyValueToTarget } from '@/utils'
+import { otherSettingsSchema } from './spu.data'
+
+const { allSchemas } = useCrudSchemas(otherSettingsSchema)
 
 const message = useMessage() // 消息弹窗
 
 const props = defineProps({
   propFormData: {
-    type: Object as PropType<SpuType>,
+    type: Object as PropType<Spu>,
     default: () => {}
   },
-  activeName: propTypes.string.def('')
+  activeName: propTypes.string.def(''),
+  isDetail: propTypes.bool.def(false) // 是否作为详情组件
 })
 
 const otherSettingsFormRef = ref() // 表单Ref
 // 表单数据
-const formData = ref<SpuType>({
+const formData = ref<Spu>({
   sort: 1, // 商品排序
   giveIntegral: 1, // 赠送积分
   virtualSalesCount: 1, // 虚拟销量

+ 1 - 2
src/views/mall/product/spu/components/ProductAttributesAddForm.vue

@@ -90,8 +90,7 @@ const submitForm = async () => {
 /** 重置表单 */
 const resetForm = () => {
   formData.value = {
-    name: '',
-    remark: ''
+    name: ''
   }
   formRef.value?.resetFields()
 }

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

@@ -1,6 +1,7 @@
 <template>
   <el-table
-    :data="isBatch ? skuList : formData.skus"
+    v-if="!isDetail"
+    :data="isBatch ? skuList : formData!.skus"
     border
     class="tabNumWidth"
     max-height="500"
@@ -11,7 +12,7 @@
         <UploadImg v-model="row.picUrl" height="80px" width="100%" />
       </template>
     </el-table-column>
-    <template v-if="formData.specType && !isBatch">
+    <template v-if="formData!.specType && !isBatch">
       <!--  根据商品属性动态添加 -->
       <el-table-column
         v-for="(item, index) in tableHeaders"
@@ -21,8 +22,10 @@
         min-width="120"
       >
         <template #default="{ row }">
-          <!-- TODO puhui999:展示成蓝色,有点区分度哈 -->
-          {{ row.properties[index]?.valueName }}
+          <!-- TODO puhui999:展示成蓝色,有点区分度哈 fix-->
+          <span style="font-weight: bold; color: #40aaff">
+            {{ row.properties[index]?.valueName }}
+          </span>
         </template>
       </el-table-column>
     </template>
@@ -73,7 +76,7 @@
         <el-input-number v-model="row.volume" :min="0" :precision="2" :step="0.1" class="w-100%" />
       </template>
     </el-table-column>
-    <template v-if="formData.subCommissionType">
+    <template v-if="formData!.subCommissionType">
       <el-table-column align="center" label="一级返佣(元)" min-width="168">
         <template #default="{ row }">
           <el-input-number
@@ -97,7 +100,7 @@
         </template>
       </el-table-column>
     </template>
-    <el-table-column v-if="formData.specType" align="center" fixed="right" label="操作" width="80">
+    <el-table-column v-if="formData?.specType" align="center" fixed="right" label="操作" width="80">
       <template #default="{ row }">
         <el-button v-if="isBatch" link size="small" type="primary" @click="batchAdd">
           批量添加
@@ -106,27 +109,107 @@
       </template>
     </el-table-column>
   </el-table>
+  <el-table
+    v-if="isDetail"
+    :data="formData!.skus"
+    border
+    max-height="500"
+    size="small"
+    style="width: 99%"
+  >
+    <el-table-column align="center" label="图片" min-width="80">
+      <template #default="{ row }">
+        <el-image :src="row.picUrl" class="w-60px h-60px" @click="imagePreview(row.picUrl)" />
+      </template>
+    </el-table-column>
+    <template v-if="formData!.specType && !isBatch">
+      <!--  根据商品属性动态添加 -->
+      <el-table-column
+        v-for="(item, index) in tableHeaders"
+        :key="index"
+        :label="item.label"
+        align="center"
+        min-width="80"
+      >
+        <template #default="{ row }">
+          <!-- TODO puhui999:展示成蓝色,有点区分度哈 fix-->
+          <span style="font-weight: bold; color: #40aaff">
+            {{ row.properties[index]?.valueName }}
+          </span>
+        </template>
+      </el-table-column>
+    </template>
+    <el-table-column align="center" label="商品条码" min-width="100">
+      <template #default="{ row }">
+        {{ row.barCode }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="销售价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.price }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="市场价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.marketPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="成本价(元)" min-width="80">
+      <template #default="{ row }">
+        {{ row.costPrice }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="库存" min-width="80">
+      <template #default="{ row }">
+        {{ row.stock }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="重量(kg)" min-width="80">
+      <template #default="{ row }">
+        {{ row.weight }}
+      </template>
+    </el-table-column>
+    <el-table-column align="center" label="体积(m^3)" min-width="80">
+      <template #default="{ row }">
+        {{ row.volume }}
+      </template>
+    </el-table-column>
+    <template v-if="formData!.subCommissionType">
+      <el-table-column align="center" label="一级返佣(元)" min-width="80">
+        <template #default="{ row }">
+          {{ row.subCommissionFirstPrice }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="二级返佣(元)" min-width="80">
+        <template #default="{ row }">
+          {{ row.subCommissionSecondPrice }}
+        </template>
+      </el-table-column>
+    </template>
+  </el-table>
 </template>
 <script lang="ts" name="SkuList" setup>
-import { PropType } from 'vue'
+import { PropType, Ref } from 'vue'
 import { copyValueToTarget } from '@/utils'
 import { propTypes } from '@/utils/propTypes'
 import { UploadImg } from '@/components/UploadFile'
-import type { Property, SkuType, SpuType } from '@/api/mall/product/spu'
+import type { Property, Sku, Spu } from '@/api/mall/product/spu'
+import { createImageViewer } from '@/components/ImageViewer'
 
 const props = defineProps({
   propFormData: {
-    type: Object as PropType<SpuType>,
+    type: Object as PropType<Spu>,
     default: () => {}
   },
   propertyList: {
     type: Array,
     default: () => []
   },
-  isBatch: propTypes.bool.def(false) // 是否作为批量操作组件
+  isBatch: propTypes.bool.def(false), // 是否作为批量操作组件
+  isDetail: propTypes.bool.def(false) // 是否作为 sku 详情组件
 })
-const formData = ref<SpuType>() // 表单数据
-const skuList = ref<SkuType[]>([
+const formData: Ref<Spu | undefined> = ref<Spu>() // 表单数据
+const skuList = ref<Sku[]>([
   {
     price: 0, // 商品价格
     marketPrice: 0, // 市场价
@@ -140,24 +223,44 @@ const skuList = ref<SkuType[]>([
     subCommissionSecondPrice: 0 // 二级分销的佣金
   }
 ]) // 批量添加时的临时数据
-// TODO @puhui999:保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
+
+/** 商品图预览 */
+const imagePreview = (imgUrl: string) => {
+  createImageViewer({
+    urlList: [imgUrl]
+  })
+}
 
 /** 批量添加 */
 const batchAdd = () => {
-  formData.value.skus.forEach((item) => {
+  formData.value!.skus!.forEach((item) => {
     copyValueToTarget(item, skuList.value[0])
   })
 }
 
 /** 删除 sku */
 const deleteSku = (row) => {
-  const index = formData.value.skus.findIndex(
+  const index = formData.value!.skus!.findIndex(
     // 直接把列表转成字符串比较
     (sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties)
   )
-  formData.value.skus.splice(index, 1)
+  formData.value!.skus!.splice(index, 1)
 }
 const tableHeaders = ref<{ prop: string; label: string }[]>([]) // 多属性表头
+/**
+ * 保存时,每个商品规格的表单要校验下。例如说,销售金额最低是 0.01 这种。
+ */
+const validateSku = (): boolean => {
+  const checks = ['price', 'marketPrice', 'costPrice']
+  let validate = true // 默认通过
+  for (const sku of formData.value!.skus) {
+    if (checks.some((check) => sku[check] < 0.01)) {
+      validate = false // 只要有一个不通过则直接不通过
+      break
+    }
+  }
+  return validate
+}
 
 /**
  * 将传进来的值赋值给 skuList
@@ -185,14 +288,13 @@ const generateTableData = (propertyList: any[]) => {
       valueName: v.name
     }))
   )
-  // TODO @puhui:是不是 buildSkuList,这样容易理解一点哈。item 改成 sku
-  const buildList = build(propertyValues)
+  const buildSkuList = build(propertyValues)
   // 如果回显的 sku 属性和添加的属性不一致则重置 skus 列表
   if (!validateData(propertyList)) {
     // 如果不一致则重置表数据,默认添加新的属性重新生成 sku 列表
     formData.value!.skus = []
   }
-  for (const item of buildList) {
+  for (const item of buildSkuList) {
     const row = {
       properties: Array.isArray(item) ? item : [item], // 如果只有一个属性的话返回的是一个 property 对象
       price: 0,
@@ -207,13 +309,13 @@ const generateTableData = (propertyList: any[]) => {
       subCommissionSecondPrice: 0
     }
     // 如果存在属性相同的 sku 则不做处理
-    const index = formData.value!.skus.findIndex(
+    const index = formData.value!.skus!.findIndex(
       (sku) => JSON.stringify(sku.properties) === JSON.stringify(row.properties)
     )
     if (index !== -1) {
       continue
     }
-    formData.value.skus.push(row)
+    formData.value!.skus!.push(row)
   }
 }
 
@@ -222,7 +324,7 @@ const generateTableData = (propertyList: any[]) => {
  */
 const validateData = (propertyList: any[]) => {
   const skuPropertyIds = []
-  formData.value.skus.forEach((sku) =>
+  formData.value!.skus!.forEach((sku) =>
     sku.properties
       ?.map((property) => property.propertyId)
       .forEach((propertyId) => {
@@ -263,7 +365,7 @@ watch(
   () => props.propertyList,
   (propertyList) => {
     // 如果不是多规格则结束
-    if (!formData.value.specType) {
+    if (!formData.value!.specType) {
       return
     }
     // 如果当前组件作为批量添加数据使用,则重置表数据
@@ -313,5 +415,5 @@ watch(
   }
 )
 // 暴露出生成 sku 方法,给添加属性成功时调用
-defineExpose({ generateTableData })
+defineExpose({ generateTableData, validateSku })
 </script>

+ 105 - 0
src/views/mall/product/spu/components/spu.data.ts

@@ -0,0 +1,105 @@
+import { CrudSchema } from '@/hooks/web/useCrudSchemas'
+
+export const basicInfoSchema = reactive<CrudSchema[]>([
+  {
+    label: '商品名称',
+    field: 'name'
+  },
+  {
+    label: '关键字',
+    field: 'keyword'
+  },
+  {
+    label: '商品简介',
+    field: 'introduction'
+  },
+  {
+    label: '商品分类',
+    field: 'categoryId'
+  },
+  {
+    label: '商品品牌',
+    field: 'brandId'
+  },
+  {
+    label: '商品封面图',
+    field: 'picUrl'
+  },
+  {
+    label: '商品轮播图',
+    field: 'sliderPicUrls'
+  },
+  {
+    label: '商品视频',
+    field: 'videoUrl'
+  },
+  {
+    label: '单位',
+    field: 'unit',
+    dictType: DICT_TYPE.PRODUCT_UNIT
+  },
+  {
+    label: '规格类型',
+    field: 'specType'
+  },
+  {
+    label: '分销类型',
+    field: 'subCommissionType'
+  },
+  {
+    label: '物流模版',
+    field: 'deliveryTemplateId'
+  },
+  {
+    label: '商品属性列表',
+    field: 'skus'
+  }
+])
+export const descriptionSchema = reactive<CrudSchema[]>([
+  {
+    label: '商品详情',
+    field: 'description'
+  }
+])
+export const otherSettingsSchema = reactive<CrudSchema[]>([
+  {
+    label: '商品排序',
+    field: 'sort'
+  },
+  {
+    label: '赠送积分',
+    field: 'giveIntegral'
+  },
+  {
+    label: '虚拟销量',
+    field: 'virtualSalesCount'
+  },
+  {
+    label: '是否热卖推荐',
+    field: 'recommendHot'
+  },
+  {
+    label: '是否优惠推荐',
+    field: 'recommendBenefit'
+  },
+  {
+    label: '是否精品推荐',
+    field: 'recommendBest'
+  },
+  {
+    label: '是否新品推荐',
+    field: 'recommendNew'
+  },
+  {
+    label: '是否优品推荐',
+    field: 'recommendGood'
+  },
+  {
+    label: '赠送的优惠劵',
+    field: 'giveCouponTemplateIds'
+  },
+  {
+    label: '活动显示排序',
+    field: 'activityOrders'
+  }
+])

+ 77 - 26
src/views/mall/product/spu/index.vue

@@ -8,18 +8,16 @@
       class="-mb-15px"
       label-width="68px"
     >
-      <!-- TODO @puhui999:品牌应该是数据下拉哈 -->
-      <el-form-item label="品牌名称" prop="name">
+      <el-form-item label="商品名称" prop="name">
         <el-input
           v-model="queryParams.name"
           class="!w-240px"
           clearable
-          placeholder="请输入品名称"
+          placeholder="请输入品名称"
           @keyup.enter="handleQuery"
         />
       </el-form-item>
-      <!--  TODO 分类只能选择二级分类目前还没做,还是先以联调通顺为主 -->
-      <!-- TODO puhui999:我们要不改成支持选择一级。如果选择一级,后端要递归查询下子分类,然后去 in? -->
+      <!--  TODO 分类只能选择二级分类目前还没做,还是先以联调通顺为主 fixL: 已完善 -->
       <el-form-item label="商品分类" prop="categoryId">
         <el-tree-select
           v-model="queryParams.categoryId"
@@ -29,6 +27,7 @@
           class="w-1/1"
           node-key="id"
           placeholder="请选择商品分类"
+          @change="nodeClick"
         />
       </el-form-item>
       <el-form-item label="创建时间" prop="createTime">
@@ -80,31 +79,60 @@
       />
     </el-tabs>
     <el-table v-loading="loading" :data="list">
-      <!-- TODO puhui:这几个属性哈,一行三个
+      <!-- TODO puhui:这几个属性哈,一行三个 fix
       商品分类:服装鞋包/箱包
 商品市场价格:100.00
 成本价:0.00
 收藏:5
-虚拟销量:999   -->
+虚拟销量:999  -->
       <el-table-column type="expand" width="30">
         <template #default="{ row }">
-          <el-form class="demo-table-expand" inline label-position="left">
-            <el-form-item label="市场价:">
-              <span>{{ formatToFraction(row.marketPrice) }}</span>
-            </el-form-item>
-            <el-form-item label="成本价:">
-              <span>{{ formatToFraction(row.costPrice) }}</span>
-            </el-form-item>
-            <el-form-item label="虚拟销量:">
-              <span>{{ row.virtualSalesCount }}</span>
-            </el-form-item>
+          <el-form class="demo-table-expand" label-position="left">
+            <el-row>
+              <el-col :span="24">
+                <el-row>
+                  <el-col :span="8">
+                    <el-form-item label="商品分类:">
+                      <span>{{ categoryString(row.categoryId) }}</span>
+                    </el-form-item>
+                  </el-col>
+                  <el-col :span="8">
+                    <el-form-item label="市场价:">
+                      <span>{{ formatToFraction(row.marketPrice) }}</span>
+                    </el-form-item>
+                  </el-col>
+                  <el-col :span="8">
+                    <el-form-item label="成本价:">
+                      <span>{{ formatToFraction(row.costPrice) }}</span>
+                    </el-form-item>
+                  </el-col>
+                </el-row>
+              </el-col>
+            </el-row>
+            <el-row>
+              <el-col :span="24">
+                <el-row>
+                  <el-col :span="8">
+                    <el-form-item label="收藏:">
+                      <!-- TODO 没有这个属性,暂时写死 5 个 -->
+                      <span>5</span>
+                    </el-form-item>
+                  </el-col>
+                  <el-col :span="8">
+                    <el-form-item label="虚拟销量:">
+                      <span>{{ row.virtualSalesCount }}</span>
+                    </el-form-item>
+                  </el-col>
+                </el-row>
+              </el-col>
+            </el-row>
           </el-form>
         </template>
       </el-table-column>
       <el-table-column key="id" align="center" label="商品编号" prop="id" />
       <el-table-column label="商品图" min-width="80">
         <template #default="{ row }">
-          <el-image :src="row.picUrl" @click="imagePreview(row.picUrl)" class="w-30px h-30px" />
+          <el-image :src="row.picUrl" class="w-30px h-30px" @click="imagePreview(row.picUrl)" />
         </template>
       </el-table-column>
       <el-table-column :show-overflow-tooltip="true" label="商品名称" min-width="300" prop="name" />
@@ -143,8 +171,13 @@
       </el-table-column>
       <el-table-column align="center" fixed="right" label="操作" min-width="200">
         <template #default="{ row }">
-          <!-- TODO @puhui999:【详情】,可以后面点做哈 -->
-          <el-button v-hasPermi="['product:spu:update']" link type="primary" @click="openDetail">
+          <!-- TODO @puhui999:【详情】,可以后面点做哈 fix-->
+          <el-button
+            v-hasPermi="['product:spu:update']"
+            link
+            type="primary"
+            @click="openDetail(row.id)"
+          >
             详情
           </el-button>
           <template v-if="queryParams.tabType === 4">
@@ -202,7 +235,7 @@ import { TabsPaneContext } from 'element-plus'
 import { cloneDeep } from 'lodash-es'
 import { createImageViewer } from '@/components/ImageViewer'
 import { dateFormatter } from '@/utils/formatTime'
-import { defaultProps, handleTree } from '@/utils/tree'
+import { checkSelectedNode, defaultProps, handleTree, treeToString } from '@/utils/tree'
 import { ProductSpuStatusEnum } from '@/utils/constants'
 import { formatToFraction } from '@/utils'
 import download from '@/utils/download'
@@ -256,12 +289,14 @@ const getTabsCount = async () => {
 const queryParams = ref({
   pageNo: 1,
   pageSize: 10,
-  tabType: 0
+  tabType: 0,
+  name: '',
+  categoryId: null
 }) // 查询参数
 const queryFormRef = ref() // 搜索的表单Ref
 
 const handleTabClick = (tab: TabsPaneContext) => {
-  queryParams.value.tabType = tab.paneName
+  queryParams.value.tabType = tab.paneName as number
   getList()
 }
 
@@ -372,8 +407,8 @@ const openForm = (id?: number) => {
 /**
  * 查看商品详情
  */
-const openDetail = () => {
-  message.alert('查看详情未完善!!!')
+const openDetail = (id?: number) => {
+  push('/product/productSpuDetail/' + id)
 }
 
 /** 导出按钮操作 */
@@ -391,7 +426,7 @@ const handleExport = async () => {
   }
 }
 
-// 监听路由变化更新列表 TODO @puhui999:这个是必须加的么?fix: 因为编辑表单是以路由的方式打开,保存表单后列表不刷新
+// 监听路由变化更新列表,解决商品保存后列表不刷新的问题。
 watch(
   () => currentRoute.value,
   () => {
@@ -400,6 +435,22 @@ watch(
 )
 
 const categoryList = ref() // 分类树
+/**
+ * 获取分类的节点的完整结构
+ * @param categoryId 分类id
+ */
+const categoryString = (categoryId) => {
+  return treeToString(categoryList.value, categoryId)
+}
+/**
+ * 校验所选是否为二级及以下节点
+ */
+const nodeClick = () => {
+  if (!checkSelectedNode(categoryList.value, queryParams.value.categoryId)) {
+    queryParams.value.categoryId = null
+    message.warning('必须选择二级及以下节点!!')
+  }
+}
 /** 初始化 **/
 onMounted(async () => {
   await getTabsCount()

+ 1 - 1
src/views/system/mail/log/MailLogDetail.vue

@@ -3,7 +3,7 @@
     <Descriptions :data="detailData" :schema="allSchemas.detailSchema">
       <!-- 展示 HTML 内容 -->
       <template #templateContent="{ row }">
-        <div v-html="row.templateContent"></div>
+        <div v-dompurify-html="row.templateContent"></div>
       </template>
     </Descriptions>
   </Dialog>