houzhongjian
2024-08-08 820397e43a0b64d35c6d31d2a55475061438593b
提交 | 用户 | 时间
820397 1 <template>
H 2   <el-container class="editor">
3     <!-- 顶部:工具栏 -->
4     <el-header class="editor-header">
5       <!-- 左侧操作区 -->
6       <slot name="toolBarLeft"></slot>
7       <!-- 中心操作区 -->
8       <div class="header-center flex flex-1 items-center justify-center">
9         <span>{{ title }}</span>
10       </div>
11       <!-- 右侧操作区 -->
12       <el-button-group class="header-right">
13         <el-tooltip content="重置">
14           <el-button @click="handleReset">
15             <Icon icon="system-uicons:reset-alt" :size="24" />
16           </el-button>
17         </el-tooltip>
18         <el-tooltip content="预览" v-if="previewUrl">
19           <el-button @click="handlePreview">
20             <Icon icon="ep:view" :size="24" />
21           </el-button>
22         </el-tooltip>
23         <el-tooltip content="保存">
24           <el-button @click="handleSave">
25             <Icon icon="ep:check" :size="24" />
26           </el-button>
27         </el-tooltip>
28       </el-button-group>
29     </el-header>
30
31     <!-- 中心区域 -->
32     <el-container class="editor-container">
33       <!-- 左侧:组件库(ComponentLibrary) -->
34       <ComponentLibrary ref="componentLibrary" :list="libs" v-if="libs && libs.length > 0" />
35       <!-- 中心:设计区域(ComponentContainer) -->
36       <div class="editor-center page-prop-area" @click="handlePageSelected">
37         <!-- 手机顶部 -->
38         <div class="editor-design-top">
39           <!-- 手机顶部状态栏 -->
40           <img src="@/assets/imgs/diy/statusBar.png" alt="" class="status-bar" />
41           <!-- 手机顶部导航栏 -->
42           <ComponentContainer
43             v-if="showNavigationBar"
44             :component="navigationBarComponent"
45             :show-toolbar="false"
46             :active="selectedComponent?.id === navigationBarComponent.id"
47             @click="handleNavigationBarSelected"
48             class="cursor-pointer!"
49           />
50         </div>
51         <!-- 绝对定位的组件:例如 弹窗、浮动按钮等 -->
52         <div
53           v-for="(component, index) in pageComponents"
54           :key="index"
55           @click="handleComponentSelected(component, index)"
56         >
57           <component
58             v-if="component.position === 'fixed' && selectedComponent?.uid === component.uid"
59             :is="component.id"
60             :property="component.property"
61           />
62         </div>
63         <!-- 手机页面编辑区域 -->
64         <el-scrollbar
65           height="100%"
66           wrap-class="editor-design-center page-prop-area"
67           view-class="phone-container"
68           :view-style="{
69             backgroundColor: pageConfigComponent.property.backgroundColor,
70             backgroundImage: `url(${pageConfigComponent.property.backgroundImage})`
71           }"
72         >
73           <draggable
74             class="page-prop-area drag-area"
75             v-model="pageComponents"
76             item-key="index"
77             :animation="200"
78             filter=".component-toolbar"
79             ghost-class="draggable-ghost"
80             :force-fallback="true"
81             group="component"
82             @change="handleComponentChange"
83           >
84             <template #item="{ element, index }">
85               <ComponentContainer
86                 v-if="!element.position || element.position === 'center'"
87                 :component="element"
88                 :active="selectedComponentIndex === index"
89                 :can-move-up="index > 0"
90                 :can-move-down="index < pageComponents.length - 1"
91                 @move="(direction) => handleMoveComponent(index, direction)"
92                 @copy="handleCopyComponent(index)"
93                 @delete="handleDeleteComponent(index)"
94                 @click="handleComponentSelected(element, index)"
95               />
96             </template>
97           </draggable>
98         </el-scrollbar>
99         <!-- 手机底部导航 -->
100         <div v-if="showTabBar" :class="['editor-design-bottom', 'component', 'cursor-pointer!']">
101           <ComponentContainer
102             :component="tabBarComponent"
103             :show-toolbar="false"
104             :active="selectedComponent?.id === tabBarComponent.id"
105             @click="handleTabBarSelected"
106           />
107         </div>
108         <!-- 固定布局的组件 操作按钮区 -->
109         <div class="fixed-component-action-group">
110           <el-tag
111             v-if="showPageConfig"
112             size="large"
113             :effect="selectedComponent?.uid === pageConfigComponent.uid ? 'dark' : 'plain'"
114             :type="selectedComponent?.uid === pageConfigComponent.uid ? '' : 'info'"
115             @click="handleComponentSelected(pageConfigComponent)"
116           >
117             <Icon :icon="pageConfigComponent.icon" :size="12" />
118             <span>{{ pageConfigComponent.name }}</span>
119           </el-tag>
120           <template v-for="(component, index) in pageComponents" :key="index">
121             <el-tag
122               v-if="component.position === 'fixed'"
123               size="large"
124               closable
125               :effect="selectedComponent?.uid === component.uid ? 'dark' : 'plain'"
126               :type="selectedComponent?.uid === component.uid ? '' : 'info'"
127               @click="handleComponentSelected(component)"
128               @close="handleDeleteComponent(index)"
129             >
130               <Icon :icon="component.icon" :size="12" />
131               <span>{{ component.name }}</span>
132             </el-tag>
133           </template>
134         </div>
135       </div>
136       <!-- 右侧:属性面板(ComponentContainerProperty) -->
137       <el-aside class="editor-right" width="350px" v-if="selectedComponent?.property">
138         <el-card
139           shadow="never"
140           body-class="h-[calc(100%-var(--el-card-padding)-var(--el-card-padding))]"
141           class="h-full"
142         >
143           <!-- 组件名称 -->
144           <template #header>
145             <div class="flex items-center gap-8px">
146               <Icon :icon="selectedComponent?.icon" color="gray" />
147               <span>{{ selectedComponent?.name }}</span>
148             </div>
149           </template>
150           <el-scrollbar
151             class="m-[calc(0px-var(--el-card-padding))]"
152             view-class="p-[var(--el-card-padding)] p-b-[calc(var(--el-card-padding)+var(--el-card-padding))] property"
153           >
154             <component
155               :key="selectedComponent?.uid || selectedComponent?.id"
156               :is="selectedComponent?.id + 'Property'"
157               v-model="selectedComponent.property"
158             />
159           </el-scrollbar>
160         </el-card>
161       </el-aside>
162     </el-container>
163   </el-container>
164
165   <!-- 预览弹框 -->
166   <Dialog v-model="previewDialogVisible" title="预览" width="700">
167     <div class="flex justify-around">
168       <IFrame
169         class="w-375px border-4px border-rounded-8px border-solid p-2px h-667px!"
170         :src="previewUrl"
171       />
172       <div class="flex flex-col">
173         <el-text>手机扫码预览</el-text>
174         <Qrcode :text="previewUrl" logo="/logo.gif" />
175       </div>
176     </div>
177   </Dialog>
178 </template>
179 <script lang="ts">
180 // 注册所有的组件
181 import { components } from './components/mobile/index'
182 export default {
183   components: { ...components }
184 }
185 </script>
186 <script lang="ts" setup>
187 import draggable from 'vuedraggable'
188 import ComponentLibrary from './components/ComponentLibrary.vue'
189 import { cloneDeep, includes } from 'lodash-es'
190 import { component as PAGE_CONFIG_COMPONENT } from '@/components/DiyEditor/components/mobile/PageConfig/config'
191 import { component as NAVIGATION_BAR_COMPONENT } from './components/mobile/NavigationBar/config'
192 import { component as TAB_BAR_COMPONENT } from './components/mobile/TabBar/config'
193 import { isString } from '@/utils/is'
194 import { DiyComponent, DiyComponentLibrary, PageConfig } from '@/components/DiyEditor/util'
195 import { componentConfigs } from '@/components/DiyEditor/components/mobile'
196 import { array, oneOfType } from 'vue-types'
197 import { propTypes } from '@/utils/propTypes'
198
199 /** 页面装修详情页 */
200 defineOptions({ name: 'DiyPageDetail' })
201
202 // 左侧组件库
203 const componentLibrary = ref()
204 // 页面设置组件
205 const pageConfigComponent = ref<DiyComponent<any>>(cloneDeep(PAGE_CONFIG_COMPONENT))
206 // 顶部导航栏
207 const navigationBarComponent = ref<DiyComponent<any>>(cloneDeep(NAVIGATION_BAR_COMPONENT))
208 // 底部导航菜单
209 const tabBarComponent = ref<DiyComponent<any>>(cloneDeep(TAB_BAR_COMPONENT))
210
211 // 选中的组件,默认选中顶部导航栏
212 const selectedComponent = ref<DiyComponent<any>>()
213 // 选中的组件索引
214 const selectedComponentIndex = ref<number>(-1)
215 // 组件列表
216 const pageComponents = ref<DiyComponent<any>[]>([])
217 // 定义属性
218 const props = defineProps({
219   // 页面配置,支持Json字符串
220   modelValue: oneOfType<string | PageConfig>([String, Object]).isRequired,
221   // 标题
222   title: propTypes.string.def(''),
223   // 组件库
224   libs: array<DiyComponentLibrary>(),
225   // 是否显示顶部导航栏
226   showNavigationBar: propTypes.bool.def(true),
227   // 是否显示底部导航菜单
228   showTabBar: propTypes.bool.def(false),
229   // 是否显示页面配置
230   showPageConfig: propTypes.bool.def(true),
231   // 预览地址:提供了预览地址,才会显示预览按钮
232   previewUrl: propTypes.string.def('')
233 })
234
235 // 监听传入的页面配置
236 // 解析出 pageConfigComponent 页面整体的配置,navigationBarComponent、pageComponents、tabBarComponent 页面上、中、下的配置
237 watch(
238   () => props.modelValue,
239   () => {
240     const modelValue = isString(props.modelValue)
241       ? (JSON.parse(props.modelValue) as PageConfig)
242       : props.modelValue
243     pageConfigComponent.value.property = modelValue?.page || PAGE_CONFIG_COMPONENT.property
244     navigationBarComponent.value.property =
245       modelValue?.navigationBar || NAVIGATION_BAR_COMPONENT.property
246     tabBarComponent.value.property = modelValue?.tabBar || TAB_BAR_COMPONENT.property
247     // 查找对应的页面组件
248     pageComponents.value = (modelValue?.components || []).map((item) => {
249       const component = componentConfigs[item.id]
250       return { ...component, property: item.property }
251     })
252   },
253   {
254     immediate: true
255   }
256 )
257
258 // 保存
259 const handleSave = () => {
260   const pageConfig = {
261     page: pageConfigComponent.value.property,
262     navigationBar: navigationBarComponent.value.property,
263     tabBar: tabBarComponent.value.property,
264     components: pageComponents.value.map((component) => {
265       // 只保留APP有用的字段
266       return { id: component.id, property: component.property }
267     })
268   } as PageConfig
269   if (!props.showTabBar) {
270     delete pageConfig.tabBar
271   }
272   // 发送数据更新通知
273   const modelValue = isString(props.modelValue) ? JSON.stringify(pageConfig) : pageConfig
274   emits('update:modelValue', modelValue)
275   // 发送保存通知
276   emits('save', pageConfig)
277 }
278
279 // 处理页面选中:显示属性表单
280 const handlePageSelected = (event: any) => {
281   if (!props.showPageConfig) return
282
283   // 配置了样式 page-prop-area 的元素,才显示页面设置
284   if (includes(event?.target?.classList, 'page-prop-area')) {
285     handleComponentSelected(unref(pageConfigComponent))
286   }
287 }
288
289 /**
290  * 选中组件
291  *
292  * @param component 组件
293  * @param index 组件的索引
294  */
295 const handleComponentSelected = (component: DiyComponent<any>, index: number = -1) => {
296   selectedComponent.value = component
297   selectedComponentIndex.value = index
298 }
299
300 // 选中顶部导航栏
301 const handleNavigationBarSelected = () => {
302   handleComponentSelected(unref(navigationBarComponent))
303 }
304
305 // 选中底部导航菜单
306 const handleTabBarSelected = () => {
307   handleComponentSelected(unref(tabBarComponent))
308 }
309
310 // 组件变动(拖拽)
311 const handleComponentChange = (dragEvent: any) => {
312   // 新增,即从组件库拖拽添加组件
313   if (dragEvent.added) {
314     const { element, newIndex } = dragEvent.added
315     handleComponentSelected(element, newIndex)
316   } else if (dragEvent.moved) {
317     // 拖拽排序
318     const { newIndex } = dragEvent.moved
319     // 保持选中
320     selectedComponentIndex.value = newIndex
321   }
322 }
323
324 // 交换组件
325 const swapComponent = (oldIndex: number, newIndex: number) => {
326   ;[pageComponents.value[oldIndex], pageComponents.value[newIndex]] = [
327     pageComponents.value[newIndex],
328     pageComponents.value[oldIndex]
329   ]
330   // 保持选中
331   selectedComponentIndex.value = newIndex
332 }
333
334 /** 移动组件(上移、下移) */
335 const handleMoveComponent = (index: number, direction: number) => {
336   const newIndex = index + direction
337   if (newIndex < 0 || newIndex >= pageComponents.value.length) return
338
339   swapComponent(index, newIndex)
340 }
341
342 /** 复制组件 */
343 const handleCopyComponent = (index: number) => {
344   const component = cloneDeep(pageComponents.value[index])
345   component.uid = new Date().getTime()
346   pageComponents.value.splice(index + 1, 0, component)
347 }
348
349 /**
350  * 删除组件
351  * @param index 当前组件index
352  */
353 const handleDeleteComponent = (index: number) => {
354   // 删除组件
355   pageComponents.value.splice(index, 1)
356   if (index < pageComponents.value.length) {
357     // 1. 不是最后一个组件时,删除后选中下面的组件
358     let bottomIndex = index
359     handleComponentSelected(pageComponents.value[bottomIndex], bottomIndex)
360   } else if (pageComponents.value.length > 0) {
361     // 2. 不是第一个组件时,删除后选中上面的组件
362     let topIndex = index - 1
363     handleComponentSelected(pageComponents.value[topIndex], topIndex)
364   } else {
365     // 3. 组件全部删除之后,显示页面设置
366     handleComponentSelected(unref(pageConfigComponent))
367   }
368 }
369
370 // 工具栏操作
371 const emits = defineEmits(['reset', 'preview', 'save', 'update:modelValue'])
372
373 // 注入无感刷新页面函数
374 const reload = inject<() => void>('reload')
375 // 重置
376 const handleReset = () => {
377   if (reload) reload()
378   emits('reset')
379 }
380
381 // 预览
382 const previewDialogVisible = ref(false)
383 const handlePreview = () => {
384   previewDialogVisible.value = true
385   emits('preview')
386 }
387
388 // 设置默认选中的组件
389 const setDefaultSelectedComponent = () => {
390   if (props.showPageConfig) {
391     selectedComponent.value = unref(pageConfigComponent)
392   } else if (props.showNavigationBar) {
393     selectedComponent.value = unref(navigationBarComponent)
394   } else if (props.showTabBar) {
395     selectedComponent.value = unref(tabBarComponent)
396   }
397 }
398
399 watch(
400   () => [props.showPageConfig, props.showNavigationBar, props.showTabBar],
401   () => setDefaultSelectedComponent()
402 )
403
404 onMounted(() => setDefaultSelectedComponent())
405 </script>
406 <style lang="scss" scoped>
407 /* 手机宽度 */
408 $phone-width: 375px;
409 $toolbar-height: 42px;
410
411 /* 根节点样式 */
412 .editor {
413   display: flex;
414   height: 100%;
415   margin: calc(0px - var(--app-content-padding));
416   flex-direction: column;
417
418   /* 顶部:工具栏 */
419   .editor-header {
420     display: flex;
421     height: $toolbar-height;
422     padding: 0;
423     background-color: var(--el-bg-color);
424     border-bottom: solid 1px var(--el-border-color);
425     align-items: center;
426     justify-content: space-between;
427
428     /* 工具栏:右侧按钮 */
429     .header-right {
430       height: 100%;
431
432       .el-button {
433         height: 100%;
434       }
435     }
436
437     /* 隐藏工具栏按钮的边框 */
438     :deep(.el-radio-button__inner),
439     :deep(.el-button) {
440       border-top: none !important;
441       border-bottom: none !important;
442       border-radius: 0 !important;
443     }
444   }
445
446   /* 中心操作区 */
447   .editor-container {
448     height: calc(
449       100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) -
450         $toolbar-height
451     );
452
453     /* 右侧属性面板 */
454     .editor-right {
455       overflow: hidden;
456       box-shadow: -8px 0 8px -8px rgb(0 0 0 / 12%);
457       flex-shrink: 0;
458
459       /* 属性面板顶部:减少内边距 */
460       :deep(.el-card__header) {
461         padding: 8px 16px;
462       }
463
464       /* 属性面板分组 */
465       :deep(.property-group) {
466         margin: 0 -20px;
467
468         &.el-card {
469           border: none;
470         }
471
472         /* 属性分组名称 */
473         .el-card__header {
474           padding: 8px 32px;
475           background: var(--el-bg-color-page);
476           border: none;
477         }
478
479         .el-card__body {
480           border: none;
481         }
482       }
483     }
484
485     /* 中心区域 */
486     .editor-center {
487       position: relative;
488       display: flex;
489       width: 100%;
490       margin: 16px 0 0;
491       overflow: hidden;
492       background-color: var(--app-content-bg-color);
493       flex: 1 1 0;
494       flex-direction: column;
495       justify-content: center;
496
497       /* 手机顶部 */
498       .editor-design-top {
499         display: flex;
500         width: $phone-width;
501         margin: 0 auto;
502         flex-direction: column;
503
504         /* 手机顶部状态栏 */
505         .status-bar {
506           width: $phone-width;
507           height: 20px;
508           background-color: #fff;
509         }
510       }
511
512       /* 手机底部导航 */
513       .editor-design-bottom {
514         width: $phone-width;
515         margin: 0 auto;
516       }
517
518       /* 手机页面编辑区域 */
519       :deep(.editor-design-center) {
520         width: 100%;
521
522         /* 主体内容 */
523         .phone-container {
524           position: relative;
525           width: $phone-width;
526           height: 100%;
527           margin: 0 auto;
528           background-repeat: no-repeat;
529           background-size: 100% 100%;
530
531           .drag-area {
532             width: 100%;
533             height: 100%;
534           }
535         }
536       }
537
538       /* 固定布局的组件 操作按钮区 */
539       .fixed-component-action-group {
540         position: absolute;
541         top: 0;
542         right: 16px;
543         display: flex;
544         flex-direction: column;
545         gap: 8px;
546
547         :deep(.el-tag) {
548           box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.1);
549           border: none;
550           .el-tag__content {
551             width: 100%;
552             display: flex;
553             align-items: center;
554             justify-content: flex-start;
555
556             .el-icon {
557               margin-right: 4px;
558             }
559           }
560         }
561       }
562     }
563   }
564 }
565 </style>