commit
						1abff21e56
					
				|  | @ -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 | ||||
|  |  | |||
|  | @ -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[] | ||||
|  |  | |||
|  | @ -37,7 +37,7 @@ const emit = defineEmits<{ | |||
|   'update:modelValue': [link: string] | ||||
| }>() | ||||
| watch( | ||||
|   () => appLink, | ||||
|   () => appLink.value, | ||||
|   () => emit('update:modelValue', appLink.value) | ||||
| ) | ||||
| </script> | ||||
|  |  | |||
|  | @ -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 | ||||
|   } | ||||
| } | ||||
|  | @ -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> | ||||
|  | @ -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> | ||||
|  | @ -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> | ||||
|  | @ -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> | ||||
|  | @ -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 | ||||
| 
 | ||||
|  |  | |||
|  | @ -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"> | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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; | ||||
| 
 | ||||
|   /* 更多 */ | ||||
|  |  | |||
|  | @ -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="more.url"> | ||||
|           <AppLinkInput v-model="formData.more.url" /> | ||||
|         <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> | ||||
|       </template> | ||||
|         <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-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 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' | ||||
|  |  | |||
|  | @ -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'] }, | ||||
|   { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 芋道源码
						芋道源码