index.vue 17 KB

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