index.vue 20 KB

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