Explorar o código

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

YunaiV hai 1 ano
pai
achega
df1c565cd9
Modificáronse 43 ficheiros con 1532 adicións e 699 borrados
  1. 0 19
      .env.base
  2. 15 9
      .env.dev
  3. 10 10
      .env.local-dev
  4. 5 2
      .env.prod
  5. 4 1
      .env.stage
  6. 5 2
      .env.test
  7. 7 8
      package.json
  8. 25 16
      src/components/AppLinkInput/AppLinkSelectDialog.vue
  9. 18 1
      src/components/AppLinkInput/data.ts
  10. 1 1
      src/components/AppLinkInput/index.vue
  11. 3 1
      src/components/DiyEditor/components/ComponentLibrary.vue
  12. 47 83
      src/components/DiyEditor/components/mobile/Carousel/property.vue
  13. 36 0
      src/components/DiyEditor/components/mobile/FloatingActionButton/config.ts
  14. 74 0
      src/components/DiyEditor/components/mobile/FloatingActionButton/index.vue
  15. 44 0
      src/components/DiyEditor/components/mobile/FloatingActionButton/property.vue
  16. 143 0
      src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/controller.ts
  17. 236 0
      src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/index.vue
  18. 42 0
      src/components/DiyEditor/components/mobile/HotZone/config.ts
  19. 42 0
      src/components/DiyEditor/components/mobile/HotZone/index.vue
  20. 63 0
      src/components/DiyEditor/components/mobile/HotZone/property.vue
  21. 31 62
      src/components/DiyEditor/components/mobile/MenuGrid/property.vue
  22. 18 48
      src/components/DiyEditor/components/mobile/MenuList/property.vue
  23. 2 2
      src/components/DiyEditor/components/mobile/MenuSwiper/index.vue
  24. 28 58
      src/components/DiyEditor/components/mobile/MenuSwiper/property.vue
  25. 9 2
      src/components/DiyEditor/components/mobile/NoticeBar/config.ts
  26. 27 57
      src/components/DiyEditor/components/mobile/NoticeBar/property.vue
  27. 26 0
      src/components/DiyEditor/components/mobile/Popover/config.ts
  28. 38 0
      src/components/DiyEditor/components/mobile/Popover/index.vue
  29. 38 0
      src/components/DiyEditor/components/mobile/Popover/property.vue
  30. 51 77
      src/components/DiyEditor/components/mobile/SearchBar/property.vue
  31. 9 2
      src/components/DiyEditor/components/mobile/TabBar/index.vue
  32. 28 73
      src/components/DiyEditor/components/mobile/TabBar/property.vue
  33. 15 11
      src/components/DiyEditor/components/mobile/TitleBar/config.ts
  34. 7 14
      src/components/DiyEditor/components/mobile/TitleBar/index.vue
  35. 98 92
      src/components/DiyEditor/components/mobile/TitleBar/property.vue
  36. 102 16
      src/components/DiyEditor/index.vue
  37. 29 2
      src/components/DiyEditor/util.ts
  38. 77 0
      src/components/Draggable/index.vue
  39. 12 1
      src/layout/components/AppView.vue
  40. 24 21
      src/views/Home/Index.vue
  41. 4 1
      src/views/mall/home/components/ShortcutCard.vue
  42. 0 3
      src/views/mall/promotion/diy/page/decorate.vue
  43. 39 4
      src/views/mall/promotion/diy/template/decorate.vue

+ 0 - 19
.env.base

@@ -1,19 +0,0 @@
-# 本地开发环境
-NODE_ENV=development
-
-VITE_DEV=true
-
-# 请求路径
-VITE_BASE_URL='http://127.0.0.1:48080'
-
-# 上传路径
-VITE_UPLOAD_URL='http://127.0.0.1:48080/admin-api/infra/file/upload'
-
-# 接口前缀
-VITE_API_BASEPATH=/dev-api
-
-# 接口地址
-VITE_API_URL=/admin-api
-
-# 打包路径
-VITE_BASE_PATH=/

+ 15 - 9
.env.dev

@@ -1,13 +1,13 @@
-# 开发环境
+# 开发环境:本地只启动前端项目,依赖开发环境(后端、APP)
 NODE_ENV=development
 
-VITE_DEV=false
+VITE_DEV=true
 
 # 请求路径
-VITE_BASE_URL='http://localhost:48080'
+VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
 
 # 上传路径
-VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
+VITE_UPLOAD_URL='http://api-dashboard.yudao.iocoder.cn/admin-api/infra/file/upload'
 
 # 接口前缀
 VITE_API_BASEPATH=/dev-api
@@ -15,17 +15,23 @@ VITE_API_BASEPATH=/dev-api
 # 接口地址
 VITE_API_URL=/admin-api
 
-# 打包路径
-VITE_BASE_PATH=/
-
 # 是否删除debugger
-VITE_DROP_DEBUGGER=true
+VITE_DROP_DEBUGGER=false
 
 # 是否删除console.log
 VITE_DROP_CONSOLE=false
 
 # 是否sourcemap
-VITE_SOURCEMAP=false
+VITE_SOURCEMAP=true
+
+# 打包路径
+VITE_BASE_PATH=/
 
 # 输出路径
 VITE_OUT_DIR=dist
+
+# 商城H5会员端域名
+VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'
+
+# 验证码的开关
+VITE_APP_CAPTCHA_ENABLE=false

+ 10 - 10
.env.front → .env.local-dev

@@ -1,13 +1,13 @@
-# 本地开发环境
+# 本地开发环境:本地启动所有项目(前端、后端、APP)时使用,不依赖外部环境
 NODE_ENV=development
 
 VITE_DEV=true
 
 # 请求路径
-VITE_BASE_URL='http://api-dashboard.yudao.iocoder.cn'
+VITE_BASE_URL='http://localhost:48080'
 
 # 上传路径
-VITE_UPLOAD_URL='http://api-dashboard.yudao.iocoder.cn/admin-api/infra/file/upload'
+VITE_UPLOAD_URL='http://localhost:48080/admin-api/infra/file/upload'
 
 # 接口前缀
 VITE_API_BASEPATH=/dev-api
@@ -15,12 +15,6 @@ VITE_API_BASEPATH=/dev-api
 # 接口地址
 VITE_API_URL=/admin-api
 
-# 打包路径
-VITE_BASE_PATH=/
-
-# 项目本地运行端口号, 与.vscode/launch.json配合
-VITE_PORT=80
-
 # 是否删除debugger
 VITE_DROP_DEBUGGER=false
 
@@ -28,7 +22,13 @@ VITE_DROP_DEBUGGER=false
 VITE_DROP_CONSOLE=false
 
 # 是否sourcemap
-VITE_SOURCEMAP=true
+VITE_SOURCEMAP=false
+
+# 打包路径
+VITE_BASE_PATH=/
+
+# 商城H5会员端域名
+VITE_MALL_H5_DOMAIN='http://localhost:3000'
 
 # 验证码的开关
 VITE_APP_CAPTCHA_ENABLE=false

+ 5 - 2
.env.pro → .env.prod

@@ -1,4 +1,4 @@
-# 生产环境
+# 生产环境:只在打包时使用
 NODE_ENV=production
 
 VITE_DEV=false
@@ -28,4 +28,7 @@ VITE_SOURCEMAP=false
 VITE_BASE_PATH=/
 
 # 输出路径
-VITE_OUT_DIR=dist-pro
+VITE_OUT_DIR=dist-prod
+
+# 商城H5会员端域名
+VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'

+ 4 - 1
.env.stage

@@ -1,4 +1,4 @@
-# 生产环境
+# 预发布环境:只在打包时使用
 NODE_ENV=production
 
 VITE_DEV=false
@@ -29,3 +29,6 @@ VITE_BASE_PATH='http://static-vue3.yudao.iocoder.cn/'
 
 # 输出路径
 VITE_OUT_DIR=dist-stage
+
+# 商城H5会员端域名
+VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'

+ 5 - 2
.env.static → .env.test

@@ -1,4 +1,4 @@
-# 开发环境
+# 测试环境:只在打包时使用
 NODE_ENV=production
 
 VITE_DEV=false
@@ -28,4 +28,7 @@ VITE_SOURCEMAP=false
 VITE_BASE_PATH=/admin-ui-vue3/
 
 # 输出路径
-VITE_OUT_DIR=dist-dev
+VITE_OUT_DIR=dist-test
+
+# 商城H5会员端域名
+VITE_MALL_H5_DOMAIN='http://mall.yudao.iocoder.cn'

+ 7 - 8
package.json

@@ -6,18 +6,17 @@
   "private": false,
   "scripts": {
     "i": "pnpm install",
-    "dev": "vite --mode base",
-    "front": "vite --mode front",
+    "local-dev": "vite --mode local-dev",
+    "dev": "vite --mode dev",
     "ts:check": "vue-tsc --noEmit",
-    "build:pro": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode pro",
+    "build:local-dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode local-dev",
     "build:dev": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode dev",
-    "build:base": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode base",
+    "build:test": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode test",
     "build:stage": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode stage",
-    "build:static": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode static",
-    "build:front": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode front",
-    "serve:pro": "vite preview --mode pro",
+    "build:prod": "node --max_old_space_size=8192 ./node_modules/vite/bin/vite.js build --mode prod",
     "serve:dev": "vite preview --mode dev",
-    "preview": "pnpm build:base && vite preview",
+    "serve:prod": "vite preview --mode prod",
+    "preview": "pnpm build:local-dev && vite preview",
     "clean": "npx rimraf node_modules",
     "clean:cache": "npx rimraf node_modules/.cache",
     "lint:eslint": "eslint --fix --ext .js,.ts,.vue ./src",

+ 25 - 16
src/components/AppLinkInput/AppLinkSelectDialog.vue

@@ -29,10 +29,11 @@
             :key="appLinkIndex"
             :content="appLink.path"
             placement="bottom"
+            :show-after="300"
           >
             <el-button
               class="m-b-8px m-r-8px m-l-0px!"
-              :type="isSameLink(appLink.path, activeAppLink) ? 'primary' : 'default'"
+              :type="isSameLink(appLink.path, activeAppLink.path) ? 'primary' : 'default'"
               @click="handleAppLinkSelected(appLink)"
             >
               {{ appLink.name }}
@@ -63,7 +64,7 @@
   </Dialog>
 </template>
 <script lang="ts" setup>
-import { APP_LINK_GROUP_LIST, APP_LINK_TYPE_ENUM } from './data'
+import { APP_LINK_GROUP_LIST, APP_LINK_TYPE_ENUM, AppLink } from './data'
 import { ButtonInstance, ScrollbarInstance } from 'element-plus'
 import { split } from 'lodash-es'
 import ProductCategorySelect from '@/views/mall/product/category/components/ProductCategorySelect.vue'
@@ -74,17 +75,23 @@ defineOptions({ name: 'AppLinkSelectDialog' })
 // 选中的分组,默认选中第一个
 const activeGroup = ref(APP_LINK_GROUP_LIST[0].name)
 // 选中的 APP 链接
-const activeAppLink = ref('')
+const activeAppLink = ref({} as AppLink)
 
 /** 打开弹窗 */
 const dialogVisible = ref(false)
 const open = (link: string) => {
-  activeAppLink.value = link
+  activeAppLink.value.path = link
   dialogVisible.value = true
 
   // 滚动到当前的链接
   const group = APP_LINK_GROUP_LIST.find((group) =>
-    group.links.some((linkItem) => isSameLink(linkItem.path, link))
+    group.links.some((linkItem) => {
+      const sameLink = isSameLink(linkItem.path, link)
+      if (sameLink) {
+        activeAppLink.value = { ...linkItem, path: link }
+      }
+      return sameLink
+    })
   )
   if (group) {
     // 使用 nextTick 的原因:可能 Dom 还没生成,导致滚动失败
@@ -94,9 +101,9 @@ const open = (link: string) => {
 defineExpose({ open })
 
 // 处理 APP 链接选中
-const handleAppLinkSelected = (appLink: any) => {
-  if (!isSameLink(appLink.path, activeAppLink.value)) {
-    activeAppLink.value = appLink.path
+const handleAppLinkSelected = (appLink: AppLink) => {
+  if (!isSameLink(appLink.path, activeAppLink.value.path)) {
+    activeAppLink.value = appLink
   }
   switch (appLink.type) {
     case APP_LINK_TYPE_ENUM.PRODUCT_CATEGORY_LIST:
@@ -104,7 +111,7 @@ const handleAppLinkSelected = (appLink: any) => {
       detailSelectDialog.value.type = appLink.type
       // 返显
       detailSelectDialog.value.id =
-        getUrlNumberValue('id', 'http://127.0.0.1' + activeAppLink.value) || undefined
+        getUrlNumberValue('id', 'http://127.0.0.1' + activeAppLink.value.path) || undefined
       break
     default:
       break
@@ -114,10 +121,12 @@ const handleAppLinkSelected = (appLink: any) => {
 // 处理绑定值更新
 const emit = defineEmits<{
   change: [link: string]
+  appLinkChange: [appLink: AppLink]
 }>()
 const handleSubmit = () => {
   dialogVisible.value = false
-  emit('change', activeAppLink.value)
+  emit('change', activeAppLink.value.path)
+  emit('appLinkChange', activeAppLink.value)
 }
 
 // 分组标题引用列表
@@ -127,7 +136,7 @@ const groupTitleRefs = ref<HTMLInputElement[]>([])
  * @param scrollTop 滚动条的位置
  */
 const handleScroll = ({ scrollTop }: { scrollTop: number }) => {
-  const titleEl = groupTitleRefs.value.find((titleEl) => {
+  const titleEl = groupTitleRefs.value.find((titleEl: HTMLInputElement) => {
     // 获取标题的位置信息
     const { offsetHeight, offsetTop } = titleEl
     // 判断标题是否在可视范围内
@@ -146,7 +155,7 @@ const linkScrollbar = ref<ScrollbarInstance>()
 // 处理分组选中
 const handleGroupSelected = (group: string) => {
   activeGroup.value = group
-  const titleRef = groupTitleRefs.value.find((item) => item.textContent === group)
+  const titleRef = groupTitleRefs.value.find((item: HTMLInputElement) => item.textContent === group)
   if (titleRef) {
     // 滚动分组标题
     linkScrollbar.value?.setScrollTop(titleRef.offsetTop)
@@ -160,8 +169,8 @@ const groupBtnRefs = ref<ButtonInstance[]>([])
 // 自动滚动分组按钮,确保分组按钮保持在可视区域内
 const scrollToGroupBtn = (group: string) => {
   const groupBtn = groupBtnRefs.value
-    .map((btn) => btn['ref'])
-    .find((ref) => ref.textContent === group)
+    .map((btn: ButtonInstance) => btn['ref'])
+    .find((ref: Node) => ref.textContent === group)
   if (groupBtn) {
     groupScrollbar.value?.setScrollTop(groupBtn.offsetTop)
   }
@@ -184,11 +193,11 @@ const detailSelectDialog = ref<{
 })
 // 处理详情选择
 const handleProductCategorySelected = (id: number) => {
-  const url = new URL(activeAppLink.value, 'http://127.0.0.1')
+  const url = new URL(activeAppLink.value.path, 'http://127.0.0.1')
   // 修改 id 参数
   url.searchParams.set('id', `${id}`)
   // 排除域名
-  activeAppLink.value = `${url.pathname}${url.search}`
+  activeAppLink.value.path = `${url.pathname}${url.search}`
   // 关闭对话框
   detailSelectDialog.value.visible = false
   // 重置 id

+ 18 - 1
src/components/AppLinkInput/data.ts

@@ -1,3 +1,20 @@
+// APP 链接分组
+export interface AppLinkGroup {
+  // 分组名称
+  name: string
+  // 链接列表
+  links: AppLink[]
+}
+// APP 链接
+export interface AppLink {
+  // 链接名称
+  name: string
+  // 链接地址
+  path: string
+  // 链接的类型
+  type?: APP_LINK_TYPE_ENUM
+}
+
 // APP 链接类型(需要特殊处理,例如商品详情)
 export const enum APP_LINK_TYPE_ENUM {
   // 拼团活动
@@ -243,4 +260,4 @@ export const APP_LINK_GROUP_LIST = [
       }
     ]
   }
-]
+] as AppLinkGroup[]

+ 1 - 1
src/components/AppLinkInput/index.vue

@@ -37,7 +37,7 @@ const emit = defineEmits<{
   'update:modelValue': [link: string]
 }>()
 watch(
-  () => appLink,
+  () => appLink.value,
   () => emit('update:modelValue', appLink.value)
 )
 </script>

+ 3 - 1
src/components/DiyEditor/components/ComponentLibrary.vue

@@ -82,7 +82,9 @@ watch(
 
 // 克隆组件
 const handleCloneComponent = (component: DiyComponent<any>) => {
-  return cloneDeep(component)
+  const instance = cloneDeep(component)
+  instance.uid = new Date().getTime()
+  return instance
 }
 </script>
 

+ 47 - 83
src/components/DiyEditor/components/mobile/Carousel/property.vue

@@ -39,87 +39,60 @@
         </el-form-item>
       </el-card>
       <el-card header="内容设置" class="property-group" shadow="never">
-        <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序 </el-text>
-        <template v-if="formData.items[0]">
-          <draggable
-            :list="formData.items"
-            :force-fallback="true"
-            :animation="200"
-            handle=".drag-icon"
-            class="m-t-8px"
-            item-key="index"
-          >
-            <template #item="{ element, index }">
-              <div class="content mb-4px flex flex-col gap-4px rounded bg-gray-50 p-8px">
-                <div
-                  class="m--8px m-b-8px flex flex-row items-center justify-between bg-gray-100 p-8px"
-                >
-                  <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
-                  <Icon
-                    icon="ep:delete"
-                    class="cursor-pointer text-red-5"
-                    @click="handleDeleteImage(index)"
-                    v-if="formData.items.length > 1"
-                  />
-                </div>
-                <el-form-item label="类型" prop="type" class="m-b-8px!" label-width="50px">
-                  <el-radio-group v-model="element.type">
-                    <el-radio label="img">图片</el-radio>
-                    <el-radio label="video">视频</el-radio>
-                  </el-radio-group>
-                </el-form-item>
-                <el-form-item
-                  label="图片"
-                  class="m-b-8px!"
-                  label-width="50px"
-                  v-if="element.type === 'img'"
-                >
-                  <UploadImg
-                    v-model="element.imgUrl"
-                    draggable="false"
-                    height="80px"
-                    width="100%"
-                    class="min-w-80px"
-                  />
-                </el-form-item>
-                <template v-else>
-                  <el-form-item label="封面" class="m-b-8px!" label-width="50px">
-                    <UploadImg
-                      v-model="element.imgUrl"
-                      draggable="false"
-                      height="80px"
-                      width="100%"
-                      class="min-w-80px"
-                    />
-                  </el-form-item>
-                  <el-form-item label="视频" class="m-b-8px!" label-width="50px">
-                    <UploadFile
-                      v-model="element.videoUrl"
-                      :file-type="['mp4']"
-                      :limit="1"
-                      :file-size="100"
-                      class="min-w-80px"
-                    />
-                  </el-form-item>
-                </template>
-                <el-form-item label="链接" class="m-b-8px!" label-width="50px">
-                  <AppLinkInput v-model="element.url" />
-                </el-form-item>
-              </div>
+        <Draggable v-model="formData.items" :empty-item="{ type: 'img' }">
+          <template #default="{ element }">
+            <el-form-item label="类型" prop="type" class="m-b-8px!" label-width="40px">
+              <el-radio-group v-model="element.type">
+                <el-radio label="img">图片</el-radio>
+                <el-radio label="video">视频</el-radio>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item
+              label="图片"
+              class="m-b-8px!"
+              label-width="40px"
+              v-if="element.type === 'img'"
+            >
+              <UploadImg
+                v-model="element.imgUrl"
+                draggable="false"
+                height="80px"
+                width="100%"
+                class="min-w-80px"
+              />
+            </el-form-item>
+            <template v-else>
+              <el-form-item label="封面" class="m-b-8px!" label-width="40px">
+                <UploadImg
+                  v-model="element.imgUrl"
+                  draggable="false"
+                  height="80px"
+                  width="100%"
+                  class="min-w-80px"
+                />
+              </el-form-item>
+              <el-form-item label="视频" class="m-b-8px!" label-width="40px">
+                <UploadFile
+                  v-model="element.videoUrl"
+                  :file-type="['mp4']"
+                  :limit="1"
+                  :file-size="100"
+                  class="min-w-80px"
+                />
+              </el-form-item>
             </template>
-          </draggable>
-        </template>
-        <el-button @click="handleAddImage" type="primary" plain class="w-full">
-          添加图片
-        </el-button>
+            <el-form-item label="链接" class="m-b-8px!" label-width="40px">
+              <AppLinkInput v-model="element.url" />
+            </el-form-item>
+          </template>
+        </Draggable>
       </el-card>
     </el-form>
   </ComponentContainerProperty>
 </template>
 
 <script setup lang="ts">
-import draggable from 'vuedraggable' //拖拽组件
-import { CarouselItemProperty, CarouselProperty } from './config'
+import { CarouselProperty } from './config'
 import { usePropertyForm } from '@/components/DiyEditor/util'
 
 // 轮播图属性面板
@@ -128,15 +101,6 @@ defineOptions({ name: 'CarouselProperty' })
 const props = defineProps<{ modelValue: CarouselProperty }>()
 const emit = defineEmits(['update:modelValue'])
 const { formData } = usePropertyForm(props.modelValue, emit)
-
-// 添加图片
-const handleAddImage = () => {
-  formData.value.items.push({} as CarouselItemProperty)
-}
-// 删除图片
-const handleDeleteImage = (index: number) => {
-  formData.value.items.splice(index, 1)
-}
 </script>
 
 <style scoped lang="scss"></style>

+ 36 - 0
src/components/DiyEditor/components/mobile/FloatingActionButton/config.ts

@@ -0,0 +1,36 @@
+import { DiyComponent } from '@/components/DiyEditor/util'
+
+// 悬浮按钮属性
+export interface FloatingActionButtonProperty {
+  // 展开方向
+  direction: 'horizontal' | 'vertical'
+  // 是否显示文字
+  showText: boolean
+  // 按钮列表
+  list: FloatingActionButtonItemProperty[]
+}
+
+// 悬浮按钮项属性
+export interface FloatingActionButtonItemProperty {
+  // 图片地址
+  imgUrl: string
+  // 跳转连接
+  url: string
+  // 文字
+  text: string
+  // 文字颜色
+  textColor: string
+}
+
+// 定义组件
+export const component = {
+  id: 'FloatingActionButton',
+  name: '悬浮按钮',
+  icon: 'tabler:float-right',
+  position: 'fixed',
+  property: {
+    direction: 'vertical',
+    showText: true,
+    list: [{ textColor: '#fff' }]
+  }
+} as DiyComponent<FloatingActionButtonProperty>

+ 74 - 0
src/components/DiyEditor/components/mobile/FloatingActionButton/index.vue

@@ -0,0 +1,74 @@
+<template>
+  <div
+    :class="[
+      'absolute bottom-32px right-[calc(50%-375px/2+32px)] flex z-12 gap-12px items-center',
+      {
+        'flex-row': property.direction === 'horizontal',
+        'flex-col': property.direction === 'vertical'
+      }
+    ]"
+  >
+    <template v-if="expanded">
+      <div
+        v-for="(item, index) in property.list"
+        :key="index"
+        class="flex flex-col items-center"
+        @click="handleActive(index)"
+      >
+        <el-image :src="item.imgUrl" fit="contain" class="h-27px w-27px">
+          <template #error>
+            <div class="h-full w-full flex items-center justify-center">
+              <Icon icon="ep:picture" :color="item.textColor" />
+            </div>
+          </template>
+        </el-image>
+        <span v-if="property.showText" class="mt-4px text-12px" :style="{ color: item.textColor }">
+          {{ item.text }}
+        </span>
+      </div>
+    </template>
+    <!-- todo: @owen 使用APP主题色 -->
+    <el-button type="primary" size="large" circle @click="handleToggleFab">
+      <Icon icon="ep:plus" :class="['fab-icon', { active: expanded }]" />
+    </el-button>
+  </div>
+  <!-- 模态背景:展开时显示,点击后折叠 -->
+  <div v-if="expanded" class="modal-bg" @click="handleToggleFab"></div>
+</template>
+<script setup lang="ts">
+import { FloatingActionButtonProperty } from './config'
+
+/** 悬浮按钮 */
+defineOptions({ name: 'FloatingActionButton' })
+// 定义属性
+defineProps<{ property: FloatingActionButtonProperty }>()
+
+// 是否展开
+const expanded = ref(true)
+// 处理展开/折叠
+const handleToggleFab = () => {
+  expanded.value = !expanded.value
+}
+</script>
+
+<style scoped lang="scss">
+/* 模态背景 */
+.modal-bg {
+  position: absolute;
+  left: calc(50% - 375px / 2);
+  top: 0;
+  z-index: 11;
+  width: 375px;
+  height: 100%;
+  background-color: rgba(#000000, 0.4);
+}
+
+.fab-icon {
+  transform: rotate(0deg);
+  transition: transform 0.3s;
+
+  &.active {
+    transform: rotate(135deg);
+  }
+}
+</style>

+ 44 - 0
src/components/DiyEditor/components/mobile/FloatingActionButton/property.vue

@@ -0,0 +1,44 @@
+<template>
+  <el-form label-width="80px" :model="formData">
+    <el-card header="按钮配置" class="property-group" shadow="never">
+      <el-form-item label="展开方向" prop="direction">
+        <el-radio-group v-model="formData.direction">
+          <el-radio label="vertical">垂直</el-radio>
+          <el-radio label="horizontal">水平</el-radio>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="显示文字" prop="showText">
+        <el-switch v-model="formData.showText" />
+      </el-form-item>
+    </el-card>
+    <el-card header="按钮列表" class="property-group" shadow="never">
+      <Draggable v-model="formData.list" :empty-item="{ textColor: '#fff' }">
+        <template #default="{ element, index }">
+          <el-form-item label="图标" :prop="`list[${index}].imgUrl`">
+            <UploadImg v-model="element.imgUrl" height="56px" width="56px" />
+          </el-form-item>
+          <el-form-item label="文字" :prop="`list[${index}].text`">
+            <InputWithColor v-model="element.text" v-model:color="element.textColor" />
+          </el-form-item>
+          <el-form-item label="跳转链接" :prop="`list[${index}].url`">
+            <AppLinkInput v-model="element.url" />
+          </el-form-item>
+        </template>
+      </Draggable>
+    </el-card>
+  </el-form>
+</template>
+
+<script setup lang="ts">
+import { FloatingActionButtonProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+
+// 悬浮按钮属性面板
+defineOptions({ name: 'FloatingActionButtonProperty' })
+
+const props = defineProps<{ modelValue: FloatingActionButtonProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style scoped lang="scss"></style>

+ 143 - 0
src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/controller.ts

@@ -0,0 +1,143 @@
+import { HotZoneItemProperty } from '@/components/DiyEditor/components/mobile/HotZone/config'
+import { StyleValue } from 'vue'
+
+// 热区的最小宽高
+export const HOT_ZONE_MIN_SIZE = 100
+
+// 控制的类型
+export enum CONTROL_TYPE_ENUM {
+  LEFT,
+  TOP,
+  WIDTH,
+  HEIGHT
+}
+
+// 定义热区的控制点
+export interface ControlDot {
+  position: string
+  types: CONTROL_TYPE_ENUM[]
+  style: StyleValue
+}
+
+// 热区的8个控制点
+export const CONTROL_DOT_LIST = [
+  {
+    position: '左上角',
+    types: [
+      CONTROL_TYPE_ENUM.LEFT,
+      CONTROL_TYPE_ENUM.TOP,
+      CONTROL_TYPE_ENUM.WIDTH,
+      CONTROL_TYPE_ENUM.HEIGHT
+    ],
+    style: { left: '-5px', top: '-5px', cursor: 'nwse-resize' }
+  },
+  {
+    position: '上方中间',
+    types: [CONTROL_TYPE_ENUM.TOP, CONTROL_TYPE_ENUM.HEIGHT],
+    style: { left: '50%', top: '-5px', cursor: 'n-resize', transform: 'translateX(-50%)' }
+  },
+  {
+    position: '右上角',
+    types: [CONTROL_TYPE_ENUM.TOP, CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT],
+    style: { right: '-5px', top: '-5px', cursor: 'nesw-resize' }
+  },
+  {
+    position: '右侧中间',
+    types: [CONTROL_TYPE_ENUM.WIDTH],
+    style: { right: '-5px', top: '50%', cursor: 'e-resize', transform: 'translateX(-50%)' }
+  },
+  {
+    position: '右下角',
+    types: [CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT],
+    style: { right: '-5px', bottom: '-5px', cursor: 'nwse-resize' }
+  },
+  {
+    position: '下方中间',
+    types: [CONTROL_TYPE_ENUM.HEIGHT],
+    style: { left: '50%', bottom: '-5px', cursor: 's-resize', transform: 'translateX(-50%)' }
+  },
+  {
+    position: '左下角',
+    types: [CONTROL_TYPE_ENUM.LEFT, CONTROL_TYPE_ENUM.WIDTH, CONTROL_TYPE_ENUM.HEIGHT],
+    style: { left: '-5px', bottom: '-5px', cursor: 'nesw-resize' }
+  },
+  {
+    position: '左侧中间',
+    types: [CONTROL_TYPE_ENUM.LEFT, CONTROL_TYPE_ENUM.WIDTH],
+    style: { left: '-5px', top: '50%', cursor: 'w-resize', transform: 'translateX(-50%)' }
+  }
+] as ControlDot[]
+
+//region 热区的缩放
+// 热区的缩放比例
+export const HOT_ZONE_SCALE_RATE = 2
+// 缩小:缩回适合手机屏幕的大小
+export const zoomOut = (list?: HotZoneItemProperty[]) => {
+  return (
+    list?.map((hotZone) => ({
+      ...hotZone,
+      left: (hotZone.left /= HOT_ZONE_SCALE_RATE),
+      top: (hotZone.top /= HOT_ZONE_SCALE_RATE),
+      width: (hotZone.width /= HOT_ZONE_SCALE_RATE),
+      height: (hotZone.height /= HOT_ZONE_SCALE_RATE)
+    })) || []
+  )
+}
+// 放大:作用是为了方便在电脑屏幕上编辑
+export const zoomIn = (list?: HotZoneItemProperty[]) => {
+  return (
+    list?.map((hotZone) => ({
+      ...hotZone,
+      left: (hotZone.left *= HOT_ZONE_SCALE_RATE),
+      top: (hotZone.top *= HOT_ZONE_SCALE_RATE),
+      width: (hotZone.width *= HOT_ZONE_SCALE_RATE),
+      height: (hotZone.height *= HOT_ZONE_SCALE_RATE)
+    })) || []
+  )
+}
+//endregion
+
+/**
+ * 封装热区拖拽
+ *
+ * 注:为什么不使用vueuse的useDraggable。在本场景下,其使用方式比较复杂
+ * @param hotZone 热区
+ * @param downEvent 鼠标按下事件
+ * @param callback 回调函数
+ */
+export const useDraggable = (
+  hotZone: HotZoneItemProperty,
+  downEvent: MouseEvent,
+  callback: (
+    left: number,
+    top: number,
+    width: number,
+    height: number,
+    moveWidth: number,
+    moveHeight: number
+  ) => void
+) => {
+  // 阻止事件冒泡
+  downEvent.stopPropagation()
+
+  // 移动前的鼠标坐标
+  const { clientX: startX, clientY: startY } = downEvent
+  // 移动前的热区坐标、大小
+  const { left, top, width, height } = hotZone
+
+  // 监听鼠标移动
+  document.onmousemove = (e) => {
+    // 移动宽度
+    const moveWidth = e.clientX - startX
+    // 移动高度
+    const moveHeight = e.clientY - startY
+    // 移动回调
+    callback(left, top, width, height, moveWidth, moveHeight)
+  }
+
+  // 松开鼠标后,结束拖拽
+  document.onmouseup = () => {
+    document.onmousemove = null
+    document.onmouseup = null
+  }
+}

+ 236 - 0
src/components/DiyEditor/components/mobile/HotZone/components/HotZoneEditDialog/index.vue

@@ -0,0 +1,236 @@
+<template>
+  <Dialog v-model="dialogVisible" title="设置热区" width="780" @close="handleClose">
+    <div ref="container" class="relative h-full w-750px">
+      <el-image :src="imgUrl" class="pointer-events-none h-full w-750px select-none" />
+      <div
+        v-for="(item, hotZoneIndex) in formData"
+        :key="hotZoneIndex"
+        class="hot-zone"
+        :style="{
+          width: `${item.width}px`,
+          height: `${item.height}px`,
+          top: `${item.top}px`,
+          left: `${item.left}px`
+        }"
+        @mousedown="handleMove(item, $event)"
+        @dblclick="handleShowAppLinkDialog(item)"
+      >
+        <span class="pointer-events-none select-none">{{ item.name || '双击选择链接' }}</span>
+        <Icon icon="ep:close" class="delete" :size="14" @click="handleRemove(item)" />
+
+        <!-- 8个控制点 -->
+        <span
+          class="ctrl-dot"
+          v-for="(dot, dotIndex) in CONTROL_DOT_LIST"
+          :key="dotIndex"
+          :style="dot.style"
+          @mousedown="handleResize(item, dot, $event)"
+        ></span>
+      </div>
+    </div>
+    <template #footer>
+      <el-button @click="handleAdd" type="primary" plain>
+        <Icon icon="ep:plus" class="mr-5px" />
+        添加热区
+      </el-button>
+      <el-button @click="handleSubmit" type="primary" plain>
+        <Icon icon="ep:check" class="mr-5px" />
+        确定
+      </el-button>
+    </template>
+  </Dialog>
+  <AppLinkSelectDialog ref="appLinkDialogRef" @app-link-change="handleAppLinkChange" />
+</template>
+
+<script setup lang="ts">
+import { HotZoneItemProperty } from '@/components/DiyEditor/components/mobile/HotZone/config'
+import { array, string } from 'vue-types'
+import {
+  CONTROL_DOT_LIST,
+  CONTROL_TYPE_ENUM,
+  ControlDot,
+  HOT_ZONE_MIN_SIZE,
+  useDraggable,
+  zoomIn,
+  zoomOut
+} from './controller'
+import { AppLink } from '@/components/AppLinkInput/data'
+import { remove } from 'lodash-es'
+
+/** 热区编辑对话框 */
+defineOptions({ name: 'HotZoneEditDialog' })
+
+// 定义属性
+const props = defineProps({
+  modelValue: array<HotZoneItemProperty>(),
+  imgUrl: string().def('')
+})
+const emit = defineEmits(['update:modelValue'])
+const formData = ref<HotZoneItemProperty[]>([])
+
+// 弹窗的是否显示
+const dialogVisible = ref(false)
+// 打开弹窗
+const open = () => {
+  // 放大
+  formData.value = zoomIn(props.modelValue)
+  dialogVisible.value = true
+}
+// 提供 open 方法,用于打开弹窗
+defineExpose({ open })
+
+// 热区容器
+const container = ref<HTMLDivElement>()
+
+// 增加热区
+const handleAdd = () => {
+  formData.value.push({
+    width: HOT_ZONE_MIN_SIZE,
+    height: HOT_ZONE_MIN_SIZE,
+    top: 0,
+    left: 0
+  } as HotZoneItemProperty)
+}
+// 删除热区
+const handleRemove = (hotZone: HotZoneItemProperty) => {
+  remove(formData.value, hotZone)
+}
+
+// 移动热区
+const handleMove = (item: HotZoneItemProperty, e: MouseEvent) => {
+  useDraggable(item, e, (left, top, _, __, moveWidth, moveHeight) => {
+    setLeft(item, left + moveWidth)
+    setTop(item, top + moveHeight)
+  })
+}
+
+// 调整热区大小、位置
+const handleResize = (item: HotZoneItemProperty, ctrlDot: ControlDot, e: MouseEvent) => {
+  useDraggable(item, e, (left, top, width, height, moveWidth, moveHeight) => {
+    ctrlDot.types.forEach((type) => {
+      switch (type) {
+        case CONTROL_TYPE_ENUM.LEFT:
+          setLeft(item, left + moveWidth)
+          break
+        case CONTROL_TYPE_ENUM.TOP:
+          setTop(item, top + moveHeight)
+          break
+        case CONTROL_TYPE_ENUM.WIDTH:
+          {
+            // 上移时,高度为减少
+            const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.LEFT) ? -1 : 1
+            setWidth(item, width + moveWidth * direction)
+          }
+          break
+        case CONTROL_TYPE_ENUM.HEIGHT:
+          {
+            // 左移时,宽度为减少
+            const direction = ctrlDot.types.includes(CONTROL_TYPE_ENUM.TOP) ? -1 : 1
+            setHeight(item, height + moveHeight * direction)
+          }
+          break
+      }
+    })
+  })
+}
+
+// 设置X轴坐标
+const setLeft = (item: HotZoneItemProperty, left: number) => {
+  // 不能超出容器
+  if (left >= 0 && left <= container.value!.offsetWidth - item.width) {
+    item.left = left
+  }
+}
+// 设置Y轴坐标
+const setTop = (item: HotZoneItemProperty, top: number) => {
+  // 不能超出容器
+  if (top >= 0 && top <= container.value!.offsetHeight - item.height) {
+    item.top = top
+  }
+}
+// 设置宽度
+const setWidth = (item: HotZoneItemProperty, width: number) => {
+  // 不能小于最小宽度 && 不能超出容器右边
+  if (width >= HOT_ZONE_MIN_SIZE && item.left + width <= container.value!.offsetWidth) {
+    item.width = width
+  }
+}
+// 设置高度
+const setHeight = (item: HotZoneItemProperty, height: number) => {
+  // 不能小于最小高度 && 不能超出容器底部
+  if (height >= HOT_ZONE_MIN_SIZE && item.top + height <= container.value!.offsetHeight) {
+    item.height = height
+  }
+}
+
+// 处理对话框关闭
+const handleSubmit = () => {
+  // 会自动触发handleClose
+  dialogVisible.value = false
+}
+
+// 处理对话框关闭
+const handleClose = () => {
+  // 缩小
+  const list = zoomOut(formData.value)
+  emit('update:modelValue', list)
+}
+
+const activeHotZone = ref<HotZoneItemProperty>()
+const appLinkDialogRef = ref()
+const handleShowAppLinkDialog = (hotZone: HotZoneItemProperty) => {
+  activeHotZone.value = hotZone
+  appLinkDialogRef.value.open(hotZone.url)
+}
+const handleAppLinkChange = (appLink: AppLink) => {
+  if (!appLink || !activeHotZone.value) return
+  activeHotZone.value.name = appLink.name
+  activeHotZone.value.url = appLink.path
+}
+</script>
+
+<style scoped lang="scss">
+.hot-zone {
+  position: absolute;
+  background: var(--el-color-primary-light-7);
+  opacity: 0.8;
+  border: 1px solid var(--el-color-primary);
+  color: var(--el-color-primary);
+  font-size: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: move;
+  z-index: 10;
+
+  /* 控制点 */
+  .ctrl-dot {
+    position: absolute;
+    width: 8px;
+    height: 8px;
+    border-radius: 50%;
+    border: inherit;
+    background-color: #fff;
+    z-index: 11;
+  }
+
+  .delete {
+    display: none;
+    position: absolute;
+    top: 0;
+    right: 0;
+    padding: 2px 2px 6px 6px;
+    background-color: var(--el-color-primary);
+    border-radius: 0 0 0 80%;
+    cursor: pointer;
+    color: #fff;
+    text-align: right;
+  }
+
+  &:hover {
+    .delete {
+      display: block;
+    }
+  }
+}
+</style>

+ 42 - 0
src/components/DiyEditor/components/mobile/HotZone/config.ts

@@ -0,0 +1,42 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 热区属性 */
+export interface HotZoneProperty {
+  // 图片地址
+  imgUrl: string
+  // 导航菜单列表
+  list: HotZoneItemProperty[]
+  // 组件样式
+  style: ComponentStyle
+}
+/** 热区项目属性 */
+export interface HotZoneItemProperty {
+  // 链接的名称
+  name: string
+  // 链接
+  url: string
+  // 宽
+  width: number
+  // 高
+  height: number
+  // 上
+  top: number
+  // 左
+  left: number
+}
+
+// 定义组件
+export const component = {
+  id: 'HotZone',
+  name: '热区',
+  icon: 'tabler:hand-click',
+  property: {
+    imgUrl: '',
+    list: [] as HotZoneItemProperty[],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<HotZoneProperty>

+ 42 - 0
src/components/DiyEditor/components/mobile/HotZone/index.vue

@@ -0,0 +1,42 @@
+<template>
+  <div class="relative h-full min-h-30px w-full">
+    <el-image :src="property.imgUrl" class="pointer-events-none h-full w-full select-none" />
+    <div
+      v-for="(item, index) in property.list"
+      :key="index"
+      class="hot-zone"
+      :style="{
+        width: `${item.width}px`,
+        height: `${item.height}px`,
+        top: `${item.top}px`,
+        left: `${item.left}px`
+      }"
+    >
+      {{ item.name }}
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { HotZoneProperty } from './config'
+
+/** 热区 */
+defineOptions({ name: 'HotZone' })
+const props = defineProps<{ property: HotZoneProperty }>()
+</script>
+
+<style scoped lang="scss">
+.hot-zone {
+  position: absolute;
+  background: var(--el-color-primary-light-7);
+  opacity: 0.8;
+  border: 1px solid var(--el-color-primary);
+  color: var(--el-color-primary);
+  font-size: 14px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: move;
+  z-index: 10;
+}
+</style>

+ 63 - 0
src/components/DiyEditor/components/mobile/HotZone/property.vue

@@ -0,0 +1,63 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <!-- 表单 -->
+    <el-form label-width="80px" :model="formData" class="m-t-8px">
+      <el-form-item label="上传图片" prop="imgUrl">
+        <UploadImg v-model="formData.imgUrl" height="50px" width="auto" class="min-w-80px">
+          <template #tip>
+            <el-text type="info" size="small"> 推荐宽度 750</el-text>
+          </template>
+        </UploadImg>
+      </el-form-item>
+    </el-form>
+
+    <el-button type="primary" plain class="w-full" @click="handleOpenEditDialog">
+      设置热区
+    </el-button>
+  </ComponentContainerProperty>
+  <!-- 热区编辑对话框 -->
+  <HotZoneEditDialog ref="editDialogRef" v-model="formData.list" :img-url="formData.imgUrl" />
+</template>
+
+<script setup lang="ts">
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import { HotZoneProperty } from '@/components/DiyEditor/components/mobile/HotZone/config'
+import HotZoneEditDialog from './components/HotZoneEditDialog/index.vue'
+
+/** 热区属性面板 */
+defineOptions({ name: 'HotZoneProperty' })
+
+const props = defineProps<{ modelValue: HotZoneProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+// 热区编辑对话框
+const editDialogRef = ref()
+// 打开热区编辑对话框
+const handleOpenEditDialog = () => {
+  editDialogRef.value.open()
+}
+</script>
+
+<style scoped lang="scss">
+.hot-zone {
+  position: absolute;
+  background: #409effbf;
+  border: 1px solid var(--el-color-primary);
+  color: #fff;
+  font-size: 12px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: move;
+
+  /* 控制点 */
+  .ctrl-dot {
+    position: absolute;
+    width: 4px;
+    height: 4px;
+    border-radius: 50%;
+    background-color: #fff;
+  }
+}
+</style>

+ 31 - 62
src/components/DiyEditor/components/mobile/MenuGrid/property.vue

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

+ 18 - 48
src/components/DiyEditor/components/mobile/MenuList/property.vue

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

+ 2 - 2
src/components/DiyEditor/components/mobile/MenuSwiper/index.vue

@@ -28,7 +28,7 @@
           <!-- 标题 -->
           <span
             v-if="property.layout === 'iconText'"
-            class="text-14px"
+            class="text-12px"
             :style="{
               color: item.titleColor,
               height: `${TITLE_HEIGHT}px`,
@@ -51,7 +51,7 @@ const props = defineProps<{ property: MenuSwiperProperty }>()
 // 标题的高度
 const TITLE_HEIGHT = 20
 // 图标的高度
-const ICON_SIZE = 50
+const ICON_SIZE = 42
 // 垂直间距:一行上下的间距
 const SPACE_Y = 16
 

+ 28 - 58
src/components/DiyEditor/components/mobile/MenuSwiper/property.vue

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

+ 9 - 2
src/components/DiyEditor/components/mobile/NoticeBar/config.ts

@@ -1,4 +1,4 @@
-import { DiyComponent } from '@/components/DiyEditor/util'
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
 
 /** 公告栏属性 */
 export interface NoticeBarProperty {
@@ -10,6 +10,8 @@ export interface NoticeBarProperty {
   backgroundColor: string
   // 文字颜色
   textColor: string
+  // 组件样式
+  style: ComponentStyle
 }
 
 /** 内容属性 */
@@ -34,6 +36,11 @@ export const component = {
       }
     ],
     backgroundColor: '#fff',
-    textColor: '#333'
+    textColor: '#333',
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
   }
 } as DiyComponent<NoticeBarProperty>

+ 27 - 57
src/components/DiyEditor/components/mobile/NoticeBar/property.vue

@@ -1,58 +1,37 @@
 <template>
-  <el-form label-width="80px" :model="formData" :rules="rules">
-    <el-form-item label="公告图标" prop="iconUrl">
-      <UploadImg v-model="formData.iconUrl" height="48px">
-        <template #tip>建议尺寸:24 * 24</template>
-      </UploadImg>
-    </el-form-item>
-    <el-form-item label="背景颜色" prop="backgroundColor">
-      <ColorInput v-model="formData.backgroundColor" />
-    </el-form-item>
-    <el-form-item label="文字颜色" prop="文字颜色">
-      <ColorInput v-model="formData.textColor" />
-    </el-form-item>
-    <el-text tag="p"> 公告内容 </el-text>
-    <el-text type="info" size="small"> 拖动左上角的小圆点可以调整热词顺序 </el-text>
-    <template v-if="formData.contents.length">
-      <VueDraggable
-        :list="formData.contents"
-        item-key="index"
-        handle=".drag-icon"
-        :forceFallback="true"
-        :animation="200"
-        class="m-t-8px"
-      >
-        <template #item="{ element, index }">
-          <div class="mb-4px flex flex-row gap-4px rounded bg-gray-100 p-8px">
-            <div class="flex flex-col items-start justify-between">
-              <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
-              <Icon
-                icon="ep:delete"
-                class="cursor-pointer text-red-5"
-                @click="handleDeleteContent(index)"
-                v-if="formData.contents.length > 1"
-              />
-            </div>
-            <div class="w-full flex flex-col gap-8px">
+  <ComponentContainerProperty v-model="formData.style">
+    <el-form label-width="80px" :model="formData" :rules="rules">
+      <el-form-item label="公告图标" prop="iconUrl">
+        <UploadImg v-model="formData.iconUrl" height="48px">
+          <template #tip>建议尺寸:24 * 24</template>
+        </UploadImg>
+      </el-form-item>
+      <el-form-item label="背景颜色" prop="backgroundColor">
+        <ColorInput v-model="formData.backgroundColor" />
+      </el-form-item>
+      <el-form-item label="文字颜色" prop="文字颜色">
+        <ColorInput v-model="formData.textColor" />
+      </el-form-item>
+
+      <el-card header="公告内容" class="property-group" shadow="never">
+        <Draggable v-model="formData.contents">
+          <template #default="{ element }">
+            <el-form-item label="公告" prop="text" label-width="40px">
               <el-input v-model="element.text" placeholder="请输入公告" />
+            </el-form-item>
+            <el-form-item label="链接" prop="url" label-width="40px">
               <AppLinkInput v-model="element.url" />
-            </div>
-          </div>
-        </template>
-      </VueDraggable>
-    </template>
-    <el-form-item label-width="0">
-      <el-button @click="handleAddContent" type="primary" plain class="m-t-8px w-full">
-        添加内容
-      </el-button>
-    </el-form-item>
-  </el-form>
+            </el-form-item>
+          </template>
+        </Draggable>
+      </el-card>
+    </el-form>
+  </ComponentContainerProperty>
 </template>
 
 <script setup lang="ts">
-import { NoticeBarProperty, NoticeContentProperty } from './config'
+import { NoticeBarProperty } from './config'
 import { usePropertyForm } from '@/components/DiyEditor/util'
-import VueDraggable from 'vuedraggable'
 // 通知栏属性面板
 defineOptions({ name: 'NoticeBarProperty' })
 // 表单校验
@@ -63,15 +42,6 @@ const rules = {
 const props = defineProps<{ modelValue: NoticeBarProperty }>()
 const emit = defineEmits(['update:modelValue'])
 const { formData } = usePropertyForm(props.modelValue, emit)
-
-/* 添加公告 */
-const handleAddContent = () => {
-  formData.value.contents.push({} as NoticeContentProperty)
-}
-/* 删除公告 */
-const handleDeleteContent = (index: number) => {
-  formData.value.contents.splice(index, 1)
-}
 </script>
 
 <style scoped lang="scss"></style>

+ 26 - 0
src/components/DiyEditor/components/mobile/Popover/config.ts

@@ -0,0 +1,26 @@
+import { DiyComponent } from '@/components/DiyEditor/util'
+
+/** 弹窗广告属性 */
+export interface PopoverProperty {
+  list: PopoverItemProperty[]
+}
+
+export interface PopoverItemProperty {
+  // 图片地址
+  imgUrl: string
+  // 跳转连接
+  url: string
+  // 显示类型:仅显示一次、每次启动都会显示
+  showType: 'once' | 'always'
+}
+
+// 定义组件
+export const component = {
+  id: 'Popover',
+  name: '弹窗广告',
+  icon: 'carbon:popup',
+  position: 'fixed',
+  property: {
+    list: [{ showType: 'once' }]
+  }
+} as DiyComponent<PopoverProperty>

+ 38 - 0
src/components/DiyEditor/components/mobile/Popover/index.vue

@@ -0,0 +1,38 @@
+<template>
+  <div
+    v-for="(item, index) in property.list"
+    :key="index"
+    class="absolute bottom-50% right-50% h-454px w-292px border-1px border-gray border-rounded-4px border-solid bg-white p-1px"
+    :style="{
+      zIndex: 100 + index + (activeIndex === index ? 100 : 0),
+      marginRight: `${-146 - index * 20}px`,
+      marginBottom: `${-227 - index * 20}px`
+    }"
+    @click="handleActive(index)"
+  >
+    <el-image :src="item.imgUrl" fit="contain" class="h-full w-full">
+      <template #error>
+        <div class="h-full w-full flex items-center justify-center">
+          <Icon icon="ep:picture" />
+        </div>
+      </template>
+    </el-image>
+    <div class="absolute right-1 top-1 text-12px">{{ index + 1 }}</div>
+  </div>
+</template>
+<script setup lang="ts">
+import { PopoverProperty } from './config'
+
+/** 弹窗广告 */
+defineOptions({ name: 'Popover' })
+// 定义属性
+defineProps<{ property: PopoverProperty }>()
+
+// 处理选中
+const activeIndex = ref(0)
+const handleActive = (index: number) => {
+  activeIndex.value = index
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 38 - 0
src/components/DiyEditor/components/mobile/Popover/property.vue

@@ -0,0 +1,38 @@
+<template>
+  <el-form label-width="80px" :model="formData">
+    <Draggable v-model="formData.list" :empty-item="{ showType: 'once' }">
+      <template #default="{ element, index }">
+        <el-form-item label="图片" :prop="`list[${index}].imgUrl`">
+          <UploadImg v-model="element.imgUrl" height="56px" width="56px" />
+        </el-form-item>
+        <el-form-item label="跳转链接" :prop="`list[${index}].url`">
+          <AppLinkInput v-model="element.url" />
+        </el-form-item>
+        <el-form-item label="显示次数" :prop="`list[${index}].showType`">
+          <el-radio-group v-model="element.showType">
+            <el-tooltip content="只显示一次,下次打开时不显示" placement="bottom">
+              <el-radio label="once">一次</el-radio>
+            </el-tooltip>
+            <el-tooltip content="每次打开时都会显示" placement="bottom">
+              <el-radio label="always">不限</el-radio>
+            </el-tooltip>
+          </el-radio-group>
+        </el-form-item>
+      </template>
+    </Draggable>
+  </el-form>
+</template>
+
+<script setup lang="ts">
+import { PopoverProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+
+// 弹窗广告属性面板
+defineOptions({ name: 'PopoverProperty' })
+
+const props = defineProps<{ modelValue: PopoverProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style scoped lang="scss"></style>

+ 51 - 77
src/components/DiyEditor/components/mobile/SearchBar/property.vue

@@ -1,81 +1,64 @@
 <template>
   <ComponentContainerProperty v-model="formData.style">
-    <el-text tag="p"> 搜索热词 </el-text>
-    <el-text type="info" size="small"> 拖动左侧的小圆点可以调整热词顺序 </el-text>
-
     <!-- 表单 -->
     <el-form label-width="80px" :model="formData" class="m-t-8px">
-      <div v-if="formData.hotKeywords.length">
-        <VueDraggable
-          :list="formData.hotKeywords"
-          item-key="index"
-          handle=".drag-icon"
-          :forceFallback="true"
-          :animation="200"
-        >
-          <template #item="{ index }">
-            <div class="mb-4px flex flex-row items-center gap-4px rounded bg-gray-100 p-8px">
-              <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
-              <el-input v-model="formData.hotKeywords[index]" placeholder="请输入热词" />
-              <Icon icon="ep:delete" class="text-red-500" @click="deleteHotWord(index)" />
-            </div>
+      <el-card header="搜索热词" class="property-group" shadow="never">
+        <Draggable v-model="formData.hotKeywords" :empty-item="''">
+          <template #default="{ index }">
+            <el-input v-model="formData.hotKeywords[index]" placeholder="请输入热词" />
           </template>
-        </VueDraggable>
-      </div>
-      <el-form-item label-width="0">
-        <el-button @click="handleAddHotWord" type="primary" plain class="m-t-8px w-full">
-          添加热词
-        </el-button>
-      </el-form-item>
-      <el-form-item label="框体样式">
-        <el-radio-group v-model="formData!.borderRadius">
-          <el-tooltip content="方形" placement="top">
-            <el-radio-button :label="0">
-              <Icon icon="tabler:input-search" />
-            </el-radio-button>
-          </el-tooltip>
-          <el-tooltip content="圆形" placement="top">
-            <el-radio-button :label="10">
-              <Icon icon="iconoir:input-search" />
-            </el-radio-button>
-          </el-tooltip>
-        </el-radio-group>
-      </el-form-item>
-      <el-form-item label="提示文字" prop="placeholder">
-        <el-input v-model="formData.placeholder" />
-      </el-form-item>
-      <el-form-item label="文本位置" prop="placeholderPosition">
-        <el-radio-group v-model="formData!.placeholderPosition">
-          <el-tooltip content="居左" placement="top">
-            <el-radio-button label="left">
-              <Icon icon="ant-design:align-left-outlined" />
-            </el-radio-button>
-          </el-tooltip>
-          <el-tooltip content="居中" placement="top">
-            <el-radio-button label="center">
-              <Icon icon="ant-design:align-center-outlined" />
-            </el-radio-button>
-          </el-tooltip>
-        </el-radio-group>
-      </el-form-item>
-      <el-form-item label="扫一扫" prop="showScan">
-        <el-switch v-model="formData!.showScan" />
-      </el-form-item>
-      <el-form-item label="框体高度" prop="height">
-        <el-slider v-model="formData!.height" :max="50" :min="28" show-input input-size="small" />
-      </el-form-item>
-      <el-form-item label="框体颜色" prop="backgroundColor">
-        <ColorInput v-model="formData.backgroundColor" />
-      </el-form-item>
-      <el-form-item class="lef" label="文本颜色" prop="textColor">
-        <ColorInput v-model="formData.textColor" />
-      </el-form-item>
+        </Draggable>
+      </el-card>
+      <el-card header="搜索样式" class="property-group" shadow="never">
+        <el-form-item label="框体样式">
+          <el-radio-group v-model="formData!.borderRadius">
+            <el-tooltip content="方形" placement="top">
+              <el-radio-button :label="0">
+                <Icon icon="tabler:input-search" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip content="圆形" placement="top">
+              <el-radio-button :label="10">
+                <Icon icon="iconoir:input-search" />
+              </el-radio-button>
+            </el-tooltip>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="提示文字" prop="placeholder">
+          <el-input v-model="formData.placeholder" />
+        </el-form-item>
+        <el-form-item label="文本位置" prop="placeholderPosition">
+          <el-radio-group v-model="formData!.placeholderPosition">
+            <el-tooltip content="居左" placement="top">
+              <el-radio-button label="left">
+                <Icon icon="ant-design:align-left-outlined" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip content="居中" placement="top">
+              <el-radio-button label="center">
+                <Icon icon="ant-design:align-center-outlined" />
+              </el-radio-button>
+            </el-tooltip>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="扫一扫" prop="showScan">
+          <el-switch v-model="formData!.showScan" />
+        </el-form-item>
+        <el-form-item label="框体高度" prop="height">
+          <el-slider v-model="formData!.height" :max="50" :min="28" show-input input-size="small" />
+        </el-form-item>
+        <el-form-item label="框体颜色" prop="backgroundColor">
+          <ColorInput v-model="formData.backgroundColor" />
+        </el-form-item>
+        <el-form-item class="lef" label="文本颜色" prop="textColor">
+          <ColorInput v-model="formData.textColor" />
+        </el-form-item>
+      </el-card>
     </el-form>
   </ComponentContainerProperty>
 </template>
 
 <script setup lang="ts">
-import VueDraggable from 'vuedraggable'
 import { usePropertyForm } from '@/components/DiyEditor/util'
 import { SearchProperty } from '@/components/DiyEditor/components/mobile/SearchBar/config'
 
@@ -85,15 +68,6 @@ defineOptions({ name: 'SearchProperty' })
 const props = defineProps<{ modelValue: SearchProperty }>()
 const emit = defineEmits(['update:modelValue'])
 const { formData } = usePropertyForm(props.modelValue, emit)
-
-/* 添加热词 */
-const handleAddHotWord = () => {
-  formData.value.hotKeywords.push('')
-}
-/* 删除热词 */
-const deleteHotWord = (index: number) => {
-  formData.value.hotKeywords.splice(index, 1)
-}
 </script>
 
 <style scoped lang="scss"></style>

+ 9 - 2
src/components/DiyEditor/components/mobile/TabBar/index.vue

@@ -12,7 +12,13 @@
       }"
     >
       <div v-for="(item, index) in property.items" :key="index" class="tab-bar-item">
-        <img :src="index === 0 ? item.activeIconUrl : item.iconUrl" alt="" />
+        <el-image :src="index === 0 ? item.activeIconUrl : item.iconUrl">
+          <template #error>
+            <div class="h-full w-full flex items-center justify-center">
+              <Icon icon="ep:picture" />
+            </div>
+          </template>
+        </el-image>
         <span :style="{ color: index === 0 ? property.style.activeColor : property.style.color }">
           {{ item.text }}
         </span>
@@ -48,7 +54,8 @@ defineProps<{ property: TabBarProperty }>()
       align-items: center;
       justify-content: center;
 
-      img {
+      :deep(img),
+      .el-icon {
         width: 26px;
         height: 26px;
         border-radius: 4px;

+ 28 - 73
src/components/DiyEditor/components/mobile/TabBar/property.vue

@@ -42,80 +42,44 @@
 
       <el-text tag="p">图标设置</el-text>
       <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序, 图标建议尺寸 44*44 </el-text>
-      <draggable
-        :list="formData!.items"
-        item-key="index"
-        :forceFallback="true"
-        :animation="200"
-        handle=".drag-icon"
-        class="m-t-8px"
-      >
-        <template #item="{ element, index }">
-          <div class="mb-4px flex flex-row gap-4px rounded bg-gray-100 p-8px">
-            <div class="flex flex-col items-start justify-between">
-              <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
-              <Icon
-                icon="ep:delete"
-                class="cursor-pointer text-red-5"
-                @click="handleDeleteItem(index)"
-                v-if="formData.items.length > 1"
+      <Draggable v-model="formData.items" :limit="5">
+        <template #default="{ element }">
+          <div class="m-b-8px flex items-center justify-around">
+            <div class="flex flex-col items-center justify-between">
+              <UploadImg
+                v-model="element.iconUrl"
+                width="40px"
+                height="40px"
+                :show-delete="false"
+                :show-btn-text="false"
               />
+              <el-text size="small">未选中</el-text>
             </div>
-            <div class="w-full flex flex-col">
-              <div class="m-b-8px flex items-center justify-around">
-                <div class="flex flex-col items-center justify-between">
-                  <UploadImg
-                    v-model="element.iconUrl"
-                    width="40px"
-                    height="40px"
-                    :show-delete="false"
-                    :show-btn-text="false"
-                  />
-                  <el-text size="small">默认图片</el-text>
-                </div>
-                <div>
-                  <UploadImg
-                    v-model="element.activeIconUrl"
-                    width="40px"
-                    height="40px"
-                    :show-delete="false"
-                    :show-btn-text="false"
-                  />
-                  <el-text>选中图片</el-text>
-                </div>
-              </div>
-              <el-form-item prop="text" label-width="0" class="m-b-8px!">
-                <el-input v-model="element.text" placeholder="请输入文字" />
-              </el-form-item>
-              <el-form-item prop="url" label-width="0" class="m-b-0!">
-                <AppLinkInput v-model="element.url" />
-              </el-form-item>
+            <div>
+              <UploadImg
+                v-model="element.activeIconUrl"
+                width="40px"
+                height="40px"
+                :show-delete="false"
+                :show-btn-text="false"
+              />
+              <el-text>已选中</el-text>
             </div>
           </div>
+          <el-form-item prop="text" label="文字" label-width="48px" class="m-b-8px!">
+            <el-input v-model="element.text" placeholder="请输入文字" />
+          </el-form-item>
+          <el-form-item prop="url" label="链接" label-width="48px" class="m-b-0!">
+            <AppLinkInput v-model="element.url" />
+          </el-form-item>
         </template>
-      </draggable>
-
-      <el-form-item label-width="0">
-        <!-- 添加导航按钮 -->
-        <el-tooltip content="最多添加5个">
-          <el-button
-            @click="handleAddItem"
-            class="m-b-16px w-full"
-            type="primary"
-            plain
-            :disabled="formData!.items.length >= 5"
-          >
-            添加导航
-          </el-button>
-        </el-tooltip>
-      </el-form-item>
+      </Draggable>
     </el-form>
   </div>
 </template>
 
 <script setup lang="ts">
-import draggable from 'vuedraggable' //拖拽组件
-import { TabBarItemProperty, TabBarProperty, THEME_LIST } from './config'
+import { TabBarProperty, THEME_LIST } from './config'
 import { usePropertyForm } from '@/components/DiyEditor/util'
 // 底部导航栏
 defineOptions({ name: 'TabBarProperty' })
@@ -124,15 +88,6 @@ const props = defineProps<{ modelValue: TabBarProperty }>()
 const emit = defineEmits(['update:modelValue'])
 const { formData } = usePropertyForm(props.modelValue, emit)
 
-/** 添加导航项 */
-const handleAddItem = () => {
-  formData?.value?.items?.push({} as TabBarItemProperty)
-}
-/** 删除导航项 */
-const handleDeleteItem = (index: number) => {
-  formData?.value?.items?.splice(index, 1)
-}
-
 // 要的主题
 const handleThemeChange = () => {
   const theme = THEME_LIST.find((theme) => theme.id === formData.value.theme)

+ 15 - 11
src/components/DiyEditor/components/mobile/TitleBar/config.ts

@@ -1,7 +1,13 @@
-import { DiyComponent } from '@/components/DiyEditor/util'
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
 
 /** 标题栏属性 */
 export interface TitleBarProperty {
+  // 背景图
+  bgImgUrl: string
+  // 偏移
+  marginLeft: number
+  // 显示位置
+  textAlign: 'left' | 'center'
   // 主标题
   title: string
   // 副标题
@@ -12,18 +18,12 @@ export interface TitleBarProperty {
   descriptionSize: number
   // 标题粗细
   titleWeight: number
-  // 显示位置
-  position: 'left' | 'center'
   // 描述粗细
   descriptionWeight: number
   // 标题颜色
   titleColor: string
   // 描述颜色
   descriptionColor: string
-  // 背景颜色
-  backgroundColor: string
-  // 底部分割线
-  showBottomBorder: false
   // 查看更多
   more: {
     // 是否显示查看更多
@@ -35,6 +35,8 @@ export interface TitleBarProperty {
     // 链接
     url: string
   }
+  // 组件样式
+  style: ComponentStyle
 }
 
 // 定义组件
@@ -48,18 +50,20 @@ export const component = {
     titleSize: 16,
     descriptionSize: 12,
     titleWeight: 400,
-    position: 'left',
+    textAlign: 'left',
     descriptionWeight: 200,
     titleColor: 'rgba(50, 50, 51, 10)',
     descriptionColor: 'rgba(150, 151, 153, 10)',
-    backgroundColor: 'rgba(255, 255, 255, 10)',
-    showBottomBorder: false,
     more: {
       //查看更多
       show: false,
       type: 'icon',
       text: '查看更多',
       url: ''
-    }
+    },
+    style: {
+      bgType: 'color',
+      bgColor: '#fff'
+    } as ComponentStyle
   }
 } as DiyComponent<TitleBarProperty>

+ 7 - 14
src/components/DiyEditor/components/mobile/TitleBar/index.vue

@@ -1,19 +1,14 @@
 <template>
-  <div
-    class="title-bar"
-    :style="{
-      background: property.backgroundColor,
-      borderBottom: property.showBottomBorder ? '1px solid #F9F9F9' : '1px solid #fff'
-    }"
-  >
-    <div>
+  <div class="title-bar">
+    <el-image v-if="property.bgImgUrl" :src="property.bgImgUrl" fit="cover" class="w-full" />
+    <div class="absolute left-0 top-0 w-full">
       <!-- 标题 -->
       <div
         :style="{
           fontSize: `${property.titleSize}px`,
           fontWeight: property.titleWeight,
           color: property.titleColor,
-          textAlign: property.position
+          textAlign: property.textAlign
         }"
         v-if="property.title"
       >
@@ -25,7 +20,7 @@
           fontSize: `${property.descriptionSize}px`,
           fontWeight: property.descriptionWeight,
           color: property.descriptionColor,
-          textAlign: property.position
+          textAlign: property.textAlign
         }"
         class="m-t-8px"
         v-if="property.description"
@@ -38,10 +33,10 @@
       class="more"
       v-show="property.more.show"
       :style="{
-        color: property.more.type === 'text' ? '#38f' : ''
+        color: property.descriptionColor
       }"
     >
-      {{ property.more.type === 'icon' ? '' : property.more.text }}
+      <span v-if="property.more.type !== 'icon'"> {{ property.more.text }} </span>
       <Icon icon="ep:arrow-right" v-if="property.more.type !== 'text'" />
     </div>
   </div>
@@ -59,8 +54,6 @@ defineProps<{ property: TitleBarProperty }>()
   position: relative;
   width: 100%;
   min-height: 20px;
-  padding: 8px 16px;
-  border: 2px solid #fff;
   box-sizing: border-box;
 
   /* 更多 */

+ 98 - 92
src/components/DiyEditor/components/mobile/TitleBar/property.vue

@@ -1,102 +1,108 @@
 <template>
-  <section class="title-bar">
+  <ComponentContainerProperty v-model="formData.style">
     <el-form label-width="85px" :model="formData" :rules="rules">
-      <el-form-item label="主标题" prop="title">
-        <el-input
-          v-model="formData.title"
-          placeholder="请输入主标题"
-          show-word-limit
-          maxlength="20"
-        />
-      </el-form-item>
-      <el-form-item label="副标题" prop="description">
-        <el-input
-          type="textarea"
-          v-model="formData.description"
-          placeholder="请输入副标题"
-          maxlength="50"
-          show-word-limit
-        />
-      </el-form-item>
-      <el-form-item label="显示位置" prop="position">
-        <el-radio-group v-model="formData!.position">
-          <el-tooltip content="居左" placement="top">
-            <el-radio-button label="left">
-              <Icon icon="ant-design:align-left-outlined" />
-            </el-radio-button>
-          </el-tooltip>
-          <el-tooltip content="居中" placement="top">
-            <el-radio-button label="center">
-              <Icon icon="ant-design:align-center-outlined" />
-            </el-radio-button>
-          </el-tooltip>
-        </el-radio-group>
-      </el-form-item>
-      <el-form-item label="标题大小" prop="titleSize">
-        <el-slider v-model="formData.titleSize" :max="60" :min="10" show-input input-size="small" />
-      </el-form-item>
-      <el-form-item label="副标题大小" prop="descriptionSize">
-        <el-slider
-          v-model="formData.descriptionSize"
-          :max="60"
-          :min="10"
-          show-input
-          input-size="small"
-        />
-      </el-form-item>
-      <el-form-item label="标题粗细" prop="titleWeight">
-        <el-slider
-          v-model="formData.titleWeight"
-          :min="100"
-          :max="900"
-          :step="100"
-          show-input
-          input-size="small"
-        />
-      </el-form-item>
-      <el-form-item label="副标题粗细" prop="descriptionWeight">
-        <el-slider
-          v-model="formData.descriptionWeight"
-          :min="100"
-          :max="900"
-          :step="100"
-          show-input
-          input-size="small"
-        />
-      </el-form-item>
-      <el-form-item label="标题颜色" prop="titleColor">
-        <ColorInput v-model="formData.titleColor" />
-      </el-form-item>
-      <el-form-item label="副标题颜色" prop="descriptionColor">
-        <ColorInput v-model="formData.descriptionColor" />
-      </el-form-item>
-      <el-form-item label="背景颜色" prop="backgroundColor">
-        <ColorInput v-model="formData.backgroundColor" />
-      </el-form-item>
-      <el-form-item label="底部分割线" prop="showBottomBorder">
-        <el-switch v-model="formData!.showBottomBorder" />
-      </el-form-item>
-      <el-form-item label="查看更多" prop="more.show">
-        <el-checkbox v-model="formData.more.show" />
-      </el-form-item>
-      <!-- 更多样式选择 -->
-      <template v-if="formData.more.show">
-        <el-form-item label="样式" prop="more.type">
-          <el-radio-group v-model="formData.more.type">
-            <el-radio label="text">文字</el-radio>
-            <el-radio label="icon">图标</el-radio>
-            <el-radio label="all">文字+图标</el-radio>
+      <el-card header="风格" class="property-group" shadow="never">
+        <el-form-item label="背景图片" prop="bgImgUrl">
+          <UploadImg v-model="formData.bgImgUrl" width="100%" height="40px">
+            <template #tip>建议尺寸 750*80</template>
+          </UploadImg>
+        </el-form-item>
+        <el-form-item label="标题位置" prop="textAlign">
+          <el-radio-group v-model="formData!.textAlign">
+            <el-tooltip content="居左" placement="top">
+              <el-radio-button label="left">
+                <Icon icon="ant-design:align-left-outlined" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip content="居中" placement="top">
+              <el-radio-button label="center">
+                <Icon icon="ant-design:align-center-outlined" />
+              </el-radio-button>
+            </el-tooltip>
           </el-radio-group>
         </el-form-item>
-        <el-form-item label="更多文字" prop="more.text" v-show="formData.more.type !== 'icon'">
-          <el-input v-model="formData.more.text" />
+      </el-card>
+      <el-card header="主标题" class="property-group" shadow="never">
+        <el-form-item label="文字" prop="title" label-width="40px">
+          <InputWithColor
+            v-model="formData.title"
+            v-model:color="formData.titleColor"
+            show-word-limit
+            maxlength="20"
+          />
+        </el-form-item>
+        <el-form-item label="大小" prop="titleSize" label-width="40px">
+          <el-slider
+            v-model="formData.titleSize"
+            :max="60"
+            :min="10"
+            show-input
+            input-size="small"
+          />
+        </el-form-item>
+        <el-form-item label="粗细" prop="titleWeight" label-width="40px">
+          <el-slider
+            v-model="formData.titleWeight"
+            :min="100"
+            :max="900"
+            :step="100"
+            show-input
+            input-size="small"
+          />
+        </el-form-item>
+      </el-card>
+      <el-card header="副标题" class="property-group" shadow="never">
+        <el-form-item label="文字" prop="description" label-width="40px">
+          <InputWithColor
+            v-model="formData.description"
+            v-model:color="formData.descriptionColor"
+            show-word-limit
+            maxlength="50"
+          />
+        </el-form-item>
+        <el-form-item label="大小" prop="descriptionSize" label-width="40px">
+          <el-slider
+            v-model="formData.descriptionSize"
+            :max="60"
+            :min="10"
+            show-input
+            input-size="small"
+          />
+        </el-form-item>
+        <el-form-item label="粗细" prop="descriptionWeight" label-width="40px">
+          <el-slider
+            v-model="formData.descriptionWeight"
+            :min="100"
+            :max="900"
+            :step="100"
+            show-input
+            input-size="small"
+          />
         </el-form-item>
-        <el-form-item label="跳转链接" prop="more.url">
-          <AppLinkInput v-model="formData.more.url" />
+      </el-card>
+      <el-card header="查看更多" class="property-group" shadow="never">
+        <el-form-item label="是否显示" prop="more.show">
+          <el-checkbox v-model="formData.more.show" />
         </el-form-item>
-      </template>
+        <!-- 更多按钮的 样式选择 -->
+        <template v-if="formData.more.show">
+          <el-form-item label="样式" prop="more.type">
+            <el-radio-group v-model="formData.more.type">
+              <el-radio label="text">文字</el-radio>
+              <el-radio label="icon">图标</el-radio>
+              <el-radio label="all">文字+图标</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="更多文字" prop="more.text" v-show="formData.more.type !== 'icon'">
+            <el-input v-model="formData.more.text" />
+          </el-form-item>
+          <el-form-item label="跳转链接" prop="more.url">
+            <AppLinkInput v-model="formData.more.url" />
+          </el-form-item>
+        </template>
+      </el-card>
     </el-form>
-  </section>
+  </ComponentContainerProperty>
 </template>
 <script setup lang="ts">
 import { TitleBarProperty } from './config'

+ 102 - 16
src/components/DiyEditor/index.vue

@@ -15,7 +15,7 @@
             <Icon icon="system-uicons:reset-alt" :size="24" />
           </el-button>
         </el-tooltip>
-        <el-tooltip content="预览">
+        <el-tooltip content="预览" v-if="previewUrl">
           <el-button @click="handlePreview">
             <Icon icon="ep:view" :size="24" />
           </el-button>
@@ -47,6 +47,18 @@
             class="cursor-pointer!"
           />
         </div>
+        <!-- 绝对定位的组件:例如 弹窗、浮动按钮等 -->
+        <div
+          v-for="(component, index) in pageComponents"
+          :key="index"
+          @click="handleComponentSelected(component, index)"
+        >
+          <component
+            v-if="component.position === 'fixed' && selectedComponent?.uid === component.uid"
+            :is="component.id"
+            :property="component.property"
+          />
+        </div>
         <!-- 手机页面编辑区域 -->
         <el-scrollbar
           height="100%"
@@ -70,6 +82,7 @@
           >
             <template #item="{ element, index }">
               <ComponentContainer
+                v-if="!element.position || element.position === 'center'"
                 :component="element"
                 :active="selectedComponentIndex === index"
                 :can-move-up="index > 0"
@@ -91,6 +104,33 @@
             @click="handleTabBarSelected"
           />
         </div>
+        <!-- 固定布局的组件 操作按钮区 -->
+        <div class="fixed-component-action-group">
+          <el-tag
+            v-if="showPageConfig"
+            size="large"
+            :effect="selectedComponent?.uid === pageConfigComponent.uid ? 'dark' : 'plain'"
+            :type="selectedComponent?.uid === pageConfigComponent.uid ? '' : 'info'"
+            @click="handleComponentSelected(pageConfigComponent)"
+          >
+            <Icon :icon="pageConfigComponent.icon" :size="12" />
+            <span>{{ pageConfigComponent.name }}</span>
+          </el-tag>
+          <template v-for="(component, index) in pageComponents" :key="index">
+            <el-tag
+              v-if="component.position === 'fixed'"
+              size="large"
+              closable
+              :effect="selectedComponent?.uid === component.uid ? 'dark' : 'plain'"
+              :type="selectedComponent?.uid === component.uid ? '' : 'info'"
+              @click="handleComponentSelected(component)"
+              @close="handleDeleteComponent(index)"
+            >
+              <Icon :icon="component.icon" :size="12" />
+              <span>{{ component.name }}</span>
+            </el-tag>
+          </template>
+        </div>
       </div>
       <!-- 右侧属性面板 -->
       <el-aside class="editor-right" width="350px" v-if="selectedComponent?.property">
@@ -102,8 +142,8 @@
           <!-- 组件名称 -->
           <template #header>
             <div class="flex items-center gap-8px">
-              <Icon :icon="selectedComponent.icon" color="gray" />
-              <span>{{ selectedComponent.name }}</span>
+              <Icon :icon="selectedComponent?.icon" color="gray" />
+              <span>{{ selectedComponent?.name }}</span>
             </div>
           </template>
           <el-scrollbar
@@ -111,7 +151,8 @@
             view-class="p-[var(--el-card-padding)] p-b-[calc(var(--el-card-padding)+var(--el-card-padding))] property"
           >
             <component
-              :is="selectedComponent.id + 'Property'"
+              :key="selectedComponent?.uid || selectedComponent?.id"
+              :is="selectedComponent?.id + 'Property'"
               v-model="selectedComponent.property"
             />
           </el-scrollbar>
@@ -119,6 +160,19 @@
       </el-aside>
     </el-container>
   </el-container>
+  <!-- 预览弹框 -->
+  <Dialog v-model="previewDialogVisible" title="预览" width="700">
+    <div class="flex justify-around">
+      <IFrame
+        class="w-375px border-4px border-rounded-8px border-solid p-2px h-667px!"
+        :src="previewUrl"
+      />
+      <div class="flex flex-col">
+        <el-text>手机扫码预览</el-text>
+        <Qrcode :text="previewUrl" logo="/logo.gif" />
+      </div>
+    </div>
+  </Dialog>
 </template>
 <script lang="ts">
 // 注册所有的组件
@@ -137,12 +191,12 @@ import { component as TAB_BAR_COMPONENT } from './components/mobile/TabBar/confi
 import { isString } from '@/utils/is'
 import { DiyComponent, DiyComponentLibrary, PageConfig } from '@/components/DiyEditor/util'
 import { componentConfigs } from '@/components/DiyEditor/components/mobile'
+import { array, oneOfType } from 'vue-types'
+import { propTypes } from '@/utils/propTypes'
 
 /** 页面装修详情页 */
 defineOptions({ name: 'DiyPageDetail' })
 
-// 消息弹窗
-const message = useMessage()
 // 左侧组件库
 const componentLibrary = ref()
 // 页面设置组件
@@ -159,20 +213,22 @@ const selectedComponentIndex = ref<number>(-1)
 // 组件列表
 const pageComponents = ref<DiyComponent<any>[]>([])
 // 定义属性
-const props = defineProps<{
+const props = defineProps({
   // 页面配置,支持Json字符串
-  modelValue: string | PageConfig
+  modelValue: oneOfType<string | PageConfig>([String, Object]).isRequired,
   // 标题
-  title: string
+  title: propTypes.string.def(''),
   // 组件库
-  libs: DiyComponentLibrary[]
+  libs: array<DiyComponentLibrary>(),
   // 是否显示顶部导航栏
-  showNavigationBar: boolean
+  showNavigationBar: propTypes.bool.def(true),
   // 是否显示底部导航菜单
-  showTabBar: boolean
+  showTabBar: propTypes.bool.def(false),
   // 是否显示页面配置
-  showPageConfig: boolean
-}>()
+  showPageConfig: propTypes.bool.def(true),
+  // 预览地址:提供了预览地址,才会显示预览按钮
+  previewUrl: propTypes.string.def('')
+})
 
 // 监听传入的页面配置
 watch(
@@ -281,6 +337,7 @@ const handleMoveComponent = (index: number, direction: number) => {
 /** 复制组件 */
 const handleCopyComponent = (index: number) => {
   const component = cloneDeep(pageComponents.value[index])
+  component.uid = new Date().getTime()
   pageComponents.value.splice(index + 1, 0, component)
 }
 /**
@@ -306,14 +363,18 @@ const handleDeleteComponent = (index: number) => {
 
 // 工具栏操作
 const emits = defineEmits(['reset', 'preview', 'save', 'update:modelValue'])
+
+// 注入无感刷新页面函数
+const reload = inject<() => void>('reload')
 // 重置
 const handleReset = () => {
-  message.warning('开发中~')
+  if (reload) reload()
   emits('reset')
 }
 // 预览
+const previewDialogVisible = ref(false)
 const handlePreview = () => {
-  message.warning('开发中~')
+  previewDialogVisible.value = true
   emits('preview')
 }
 
@@ -464,6 +525,31 @@ $toolbar-height: 42px;
           }
         }
       }
+
+      /* 固定布局的组件 操作按钮区 */
+      .fixed-component-action-group {
+        position: absolute;
+        top: 0;
+        right: 16px;
+        display: flex;
+        flex-direction: column;
+        gap: 8px;
+
+        :deep(.el-tag) {
+          box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.1);
+          border: none;
+          .el-tag__content {
+            width: 100%;
+            display: flex;
+            align-items: center;
+            justify-content: flex-start;
+
+            .el-icon {
+              margin-right: 4px;
+            }
+          }
+        }
+      }
     }
   }
 }

+ 29 - 2
src/components/DiyEditor/util.ts

@@ -5,12 +5,23 @@ import { TabBarProperty } from '@/components/DiyEditor/components/mobile/TabBar/
 
 // 页面装修组件
 export interface DiyComponent<T> {
+  // 用于区分同一种组件的不同实例
+  uid: number
   // 组件唯一标识
   id: string
   // 组件名称
   name: string
   // 组件图标
   icon: string
+  /*
+   组件位置:
+   top: 固定于手机顶部,例如 顶部的导航栏
+   bottom: 固定于手机底部,例如 底部的菜单导航栏
+   center: 位于手机中心,每个组件占一行,顺序向下排列
+   空:同center
+   fixed: 由组件自己决定位置,如弹窗位于手机中心、浮动按钮一般位于手机右下角
+  */
+  position: 'top' | 'bottom' | 'center' | '' | 'fixed'
   // 组件属性
   property: T
 }
@@ -100,12 +111,28 @@ export const PAGE_LIBS = [
   {
     name: '基础组件',
     extended: true,
-    components: ['SearchBar', 'NoticeBar', 'MenuSwiper', 'MenuGrid', 'MenuList']
+    components: [
+      'SearchBar',
+      'NoticeBar',
+      'MenuSwiper',
+      'MenuGrid',
+      'MenuList',
+      'Popover',
+      'FloatingActionButton'
+    ]
   },
   {
     name: '图文组件',
     extended: true,
-    components: ['ImageBar', 'Carousel', 'TitleBar', 'VideoPlayer', 'Divider', 'MagicCube']
+    components: [
+      'ImageBar',
+      'Carousel',
+      'TitleBar',
+      'VideoPlayer',
+      'Divider',
+      'MagicCube',
+      'HotZone'
+    ]
   },
   { name: '商品组件', extended: true, components: ['ProductCard', 'ProductList'] },
   {

+ 77 - 0
src/components/Draggable/index.vue

@@ -0,0 +1,77 @@
+<template>
+  <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序 </el-text>
+  <VueDraggable
+    :list="formData"
+    :force-fallback="true"
+    :animation="200"
+    handle=".drag-icon"
+    class="m-t-8px"
+    item-key="index"
+  >
+    <template #item="{ element, index }">
+      <div
+        class="mb-4px flex flex-col gap-4px border border-gray-2 border-rounded rounded border-solid p-8px"
+      >
+        <!-- 操作按钮区 -->
+        <div class="m--8px m-b-4px flex flex-row items-center justify-between bg-gray-1 p-8px">
+          <el-tooltip content="拖动排序">
+            <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+          </el-tooltip>
+          <el-tooltip content="删除">
+            <Icon
+              icon="ep:delete"
+              class="cursor-pointer text-red-5"
+              v-if="formData.length > 1"
+              @click="handleDelete(index)"
+            />
+          </el-tooltip>
+        </div>
+        <!-- 内容区 -->
+        <slot :element="element" :index="index"></slot>
+      </div>
+    </template>
+  </VueDraggable>
+  <el-tooltip :disabled="limit < 1" :content="`最多添加${limit}个`">
+    <el-button
+      type="primary"
+      plain
+      class="m-t-4px w-full"
+      :disabled="limit > 0 && formData.length >= limit"
+      @click="handleAdd"
+    >
+      <Icon icon="ep:plus" /><span>添加</span>
+    </el-button>
+  </el-tooltip>
+</template>
+
+<script setup lang="ts">
+// 拖拽组件
+import VueDraggable from 'vuedraggable'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+import { any, array } from 'vue-types'
+import { propTypes } from '@/utils/propTypes'
+import { cloneDeep } from 'lodash-es'
+
+// 拖拽组件封装
+defineOptions({ name: 'Draggable' })
+
+// 定义属性
+const props = defineProps({
+  // 绑定值
+  modelValue: array<any>().isRequired,
+  // 空的元素:点击添加按钮时,创建元素并添加到列表;默认为空对象
+  emptyItem: any<unknown>().def({}),
+  // 数量限制:默认为0,表示不限制
+  limit: propTypes.number.def(0)
+})
+// 定义事件
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+// 处理添加
+const handleAdd = () => formData.value.push(cloneDeep(props.emptyItem || {}))
+// 处理删除
+const handleDelete = (index: number) => formData.value.splice(index, 1)
+</script>
+
+<style scoped lang="scss"></style>

+ 12 - 1
src/layout/components/AppView.vue

@@ -20,6 +20,17 @@ const getCaches = computed((): string[] => {
 })
 
 const tagsView = computed(() => appStore.getTagsView)
+
+//region 无感刷新
+const routerAlive = ref(true)
+// 无感刷新,防止出现页面闪烁白屏
+const reload = () => {
+  routerAlive.value = false
+  nextTick(() => (routerAlive.value = true))
+}
+// 为组件后代提供刷新方法
+provide('reload', reload)
+//endregion
 </script>
 
 <template>
@@ -49,7 +60,7 @@ const tagsView = computed(() => appStore.getTagsView)
       }
     ]"
   >
-    <router-view>
+    <router-view v-if="routerAlive">
       <template #default="{ Component, route }">
         <keep-alive :include="getCaches">
           <component :is="Component" :key="route.fullPath" />

+ 24 - 21
src/views/Home/Index.vue

@@ -2,10 +2,12 @@
   <div>
     <el-card shadow="never">
       <el-skeleton :loading="loading" animated>
-        <el-row :gutter="20" justify="space-between">
+        <el-row :gutter="16" justify="space-between">
           <el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24">
             <div class="flex items-center">
-              <img :src="avatar" alt="" class="mr-20px h-70px w-70px rounded-[50%]" />
+              <el-avatar :src="avatar" :size="70" class="mr-16px">
+                <img src="@/assets/imgs/avatar.gif" alt="" />
+              </el-avatar>
               <div>
                 <div class="text-20px">
                   {{ t('workplace.welcome') }} {{ username }} {{ t('workplace.happyDay') }}
@@ -19,7 +21,7 @@
           <el-col :xl="12" :lg="12" :md="12" :sm="24" :xs="24">
             <div class="h-70px flex items-center justify-end lt-sm:mt-10px">
               <div class="px-8px text-right">
-                <div class="mb-20px text-14px text-gray-400">{{ t('workplace.project') }}</div>
+                <div class="mb-16px text-14px text-gray-400">{{ t('workplace.project') }}</div>
                 <CountTo
                   class="text-20px"
                   :start-val="0"
@@ -29,7 +31,7 @@
               </div>
               <el-divider direction="vertical" />
               <div class="px-8px text-right">
-                <div class="mb-20px text-14px text-gray-400">{{ t('workplace.toDo') }}</div>
+                <div class="mb-16px text-14px text-gray-400">{{ t('workplace.toDo') }}</div>
                 <CountTo
                   class="text-20px"
                   :start-val="0"
@@ -39,7 +41,7 @@
               </div>
               <el-divider direction="vertical" border-style="dashed" />
               <div class="px-8px text-right">
-                <div class="mb-20px text-14px text-gray-400">{{ t('workplace.access') }}</div>
+                <div class="mb-16px text-14px text-gray-400">{{ t('workplace.access') }}</div>
                 <CountTo
                   class="text-20px"
                   :start-val="0"
@@ -54,8 +56,8 @@
     </el-card>
   </div>
 
-  <el-row class="mt-5px" :gutter="20" justify="space-between">
-    <el-col :xl="16" :lg="16" :md="24" :sm="24" :xs="24" class="mb-10px">
+  <el-row class="mt-8px" :gutter="8" justify="space-between">
+    <el-col :xl="16" :lg="16" :md="24" :sm="24" :xs="24" class="mb-8px">
       <el-card shadow="never">
         <template #header>
           <div class="h-3 flex justify-between">
@@ -76,11 +78,11 @@
             >
               <el-card shadow="hover">
                 <div class="flex items-center">
-                  <Icon :icon="item.icon" :size="25" class="mr-10px" />
+                  <Icon :icon="item.icon" :size="25" class="mr-8px" />
                   <span class="text-16px">{{ item.name }}</span>
                 </div>
-                <div class="mt-15px text-14px text-gray-400">{{ t(item.message) }}</div>
-                <div class="mt-20px flex justify-between text-12px text-gray-400">
+                <div class="mt-16px text-14px text-gray-400">{{ t(item.message) }}</div>
+                <div class="mt-16px flex justify-between text-12px text-gray-400">
                   <span>{{ item.personal }}</span>
                   <span>{{ formatTime(item.time, 'yyyy-MM-dd') }}</span>
                 </div>
@@ -90,18 +92,18 @@
         </el-skeleton>
       </el-card>
 
-      <el-card shadow="never" class="mt-5px">
+      <el-card shadow="never" class="mt-8px">
         <el-skeleton :loading="loading" animated>
           <el-row :gutter="20" justify="space-between">
             <el-col :xl="10" :lg="10" :md="24" :sm="24" :xs="24">
-              <el-card shadow="hover" class="mb-10px">
+              <el-card shadow="hover" class="mb-8px">
                 <el-skeleton :loading="loading" animated>
                   <Echart :options="pieOptionsData" :height="280" />
                 </el-skeleton>
               </el-card>
             </el-col>
             <el-col :xl="14" :lg="14" :md="24" :sm="24" :xs="24">
-              <el-card shadow="hover" class="mb-10px">
+              <el-card shadow="hover" class="mb-8px">
                 <el-skeleton :loading="loading" animated>
                   <Echart :options="barOptionsData" :height="280" />
                 </el-skeleton>
@@ -111,7 +113,7 @@
         </el-skeleton>
       </el-card>
     </el-col>
-    <el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24" class="mb-10px">
+    <el-col :xl="8" :lg="8" :md="24" :sm="24" :xs="24" class="mb-8px">
       <el-card shadow="never">
         <template #header>
           <div class="h-3 flex justify-between">
@@ -120,9 +122,9 @@
         </template>
         <el-skeleton :loading="loading" animated>
           <el-row>
-            <el-col v-for="item in shortcut" :key="`team-${item.name}`" :span="8" class="mb-10px">
+            <el-col v-for="item in shortcut" :key="`team-${item.name}`" :span="8" class="mb-8px">
               <div class="flex items-center">
-                <Icon :icon="item.icon" class="mr-10px" />
+                <Icon :icon="item.icon" class="mr-8px" />
                 <el-link type="default" :underline="false" @click="setWatermark(item.name)">
                   {{ item.name }}
                 </el-link>
@@ -131,7 +133,7 @@
           </el-row>
         </el-skeleton>
       </el-card>
-      <el-card shadow="never" class="mt-10px">
+      <el-card shadow="never" class="mt-8px">
         <template #header>
           <div class="h-3 flex justify-between">
             <span>{{ t('workplace.notice') }}</span>
@@ -141,14 +143,16 @@
         <el-skeleton :loading="loading" animated>
           <div v-for="(item, index) in notice" :key="`dynamics-${index}`">
             <div class="flex items-center">
-              <img :src="avatar" alt="" class="mr-20px h-35px w-35px rounded-[50%]" />
+              <el-avatar :src="avatar" :size="35" class="mr-16px">
+                <img src="@/assets/imgs/avatar.gif" alt="" />
+              </el-avatar>
               <div>
                 <div class="text-14px">
                   <Highlight :keys="item.keys.map((v) => t(v))">
                     {{ item.type }} : {{ item.title }}
                   </Highlight>
                 </div>
-                <div class="mt-15px text-12px text-gray-400">
+                <div class="mt-16px text-12px text-gray-400">
                   {{ formatTime(item.date, 'yyyy-MM-dd') }}
                 </div>
               </div>
@@ -167,7 +171,6 @@ import { formatTime } from '@/utils'
 
 import { useUserStore } from '@/store/modules/user'
 import { useWatermark } from '@/hooks/web/useWatermark'
-import avatarImg from '@/assets/imgs/avatar.gif'
 import type { WorkplaceTotal, Project, Notice, Shortcut } from './types'
 import { pieOptions, barOptions } from './echarts-data'
 
@@ -177,7 +180,7 @@ const { t } = useI18n()
 const userStore = useUserStore()
 const { setWatermark } = useWatermark()
 const loading = ref(true)
-const avatar = userStore.getUser.avatar ? userStore.getUser.avatar : avatarImg
+const avatar = userStore.getUser.avatar
 const username = userStore.getUser.nickname
 const pieOptionsData = reactive<EChartsOption>(pieOptions) as EChartsOption
 // 获取统计数

+ 4 - 1
src/views/mall/home/components/ShortcutCard.vue

@@ -10,7 +10,10 @@
         class="h-20 w-20% flex flex-col cursor-pointer items-center justify-center gap-2"
         @click="handleMenuClick(menu.routerName)"
       >
-        <div :class="menu.bgColor" class="rounded p-3 text-white">
+        <div
+          :class="menu.bgColor"
+          class="h-48px w-48px flex items-center justify-center rounded text-white"
+        >
           <Icon :icon="menu.icon" class="text-7.5!" />
         </div>
         <span>{{ menu.name }}</span>

+ 0 - 3
src/views/mall/promotion/diy/page/decorate.vue

@@ -4,9 +4,6 @@
     v-model="formData.property"
     :title="formData.name"
     :libs="PAGE_LIBS"
-    :show-page-config="true"
-    :show-navigation-bar="true"
-    :show-tab-bar="false"
     @save="submitForm"
   />
 </template>

+ 39 - 4
src/views/mall/promotion/diy/template/decorate.vue

@@ -7,7 +7,9 @@
     :show-page-config="selectedTemplateItem !== 0"
     :show-tab-bar="selectedTemplateItem === 0"
     :show-navigation-bar="selectedTemplateItem !== 0"
+    :preview-url="previewUrl"
     @save="submitForm"
+    @reset="handleEditorReset"
   >
     <template #toolBarLeft>
       <el-radio-group
@@ -29,6 +31,7 @@ import * as DiyTemplateApi from '@/api/mall/promotion/diy/template'
 import * as DiyPageApi from '@/api/mall/promotion/diy/page'
 import { useTagsViewStore } from '@/store/modules/tagsView'
 import { DiyComponentLibrary, PAGE_LIBS } from '@/components/DiyEditor/util'
+import { toNumber } from 'lodash-es'
 
 /** 装修模板表单 */
 defineOptions({ name: 'DiyTemplateDecorate' })
@@ -48,6 +51,8 @@ const formData = ref<DiyTemplateApi.DiyTemplatePropertyVO>()
 const formRef = ref() // 表单 Ref
 // 当前编辑的属性
 const currentFormData = ref<DiyTemplateApi.DiyTemplatePropertyVO | DiyPageApi.DiyPageVO>()
+// 商城H5预览地址
+const previewUrl = ref('')
 
 // 获取详情
 const getPageDetail = async (id: any) => {
@@ -55,6 +60,10 @@ const getPageDetail = async (id: any) => {
   try {
     formData.value = await DiyTemplateApi.getDiyTemplateProperty(id)
     currentFormData.value = formData.value
+
+    // 拼接手机预览链接
+    const domain = import.meta.env.VITE_MALL_H5_DOMAIN
+    previewUrl.value = `${domain}/#/pages/index/index?templateId=${formData.value.id}`
   } finally {
     formLoading.value = false
   }
@@ -115,17 +124,43 @@ const resetForm = () => {
   formRef.value?.resetFields()
 }
 
+// 重置时记录当前编辑的页面
+const handleEditorReset = () => storePageIndex()
+
+//#region 无感刷新
+// 记录标识
+const DIY_PAGE_INDEX_KEY = 'diy_page_index'
+// 1. 记录
+const storePageIndex = () =>
+  sessionStorage.setItem(DIY_PAGE_INDEX_KEY, `${selectedTemplateItem.value}`)
+// 2. 恢复
+const recoverPageIndex = () => {
+  // 恢复重置前的页面,默认是第一个页面
+  const pageIndex = toNumber(sessionStorage.getItem(DIY_PAGE_INDEX_KEY)) || 0
+  // 移除标记
+  sessionStorage.removeItem(DIY_PAGE_INDEX_KEY)
+  // 切换页面
+  if (pageIndex !== selectedTemplateItem.value) {
+    selectedTemplateItem.value = pageIndex
+    handleTemplateItemChange()
+  }
+}
+//#endregion
+
 /** 初始化 **/
 const { currentRoute } = useRouter() // 路由
 const { delView } = useTagsViewStore() // 视图操作
-const route = useRoute()
-onMounted(() => {
+onMounted(async () => {
   resetForm()
-  if (!route.params.id) {
+  if (!currentRoute.value.params.id) {
     message.warning('参数错误,页面编号不能为空!')
     delView(unref(currentRoute))
     return
   }
-  getPageDetail(route.params.id)
+
+  // 查询详情
+  await getPageDetail(currentRoute.value.params.id)
+  // 恢复重置前的页面
+  recoverPageIndex()
 })
 </script>