瀏覽代碼

!347 商城装修
Merge pull request !347 from 疯狂的世界/dev

芋道源码 1 年之前
父節點
當前提交
1abff21e56

+ 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>

+ 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>

+ 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
 

+ 1 - 1
src/components/DiyEditor/components/mobile/MenuSwiper/property.vue

@@ -23,7 +23,7 @@
       </el-form-item>
 
       <el-card header="菜单设置" class="property-group" shadow="never">
-        <Draggable v-model="formData.list" :empty-item="cloneDeep(EMPTY_MENU_SWIPER_ITEM_PROPERTY">
+        <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">

+ 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'

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

@@ -124,7 +124,15 @@ export const PAGE_LIBS = [
   {
     name: '图文组件',
     extended: true,
-    components: ['ImageBar', 'Carousel', 'TitleBar', 'VideoPlayer', 'Divider', 'MagicCube']
+    components: [
+      'ImageBar',
+      'Carousel',
+      'TitleBar',
+      'VideoPlayer',
+      'Divider',
+      'MagicCube',
+      'HotZone'
+    ]
   },
   { name: '商品组件', extended: true, components: ['ProductCard', 'ProductList'] },
   {