Преглед на файлове

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

芋道源码 преди 1 година
родител
ревизия
ead30e8cd9
променени са 23 файла, в които са добавени 1111 реда и са изтрити 589 реда
  1. 222 0
      src/components/DiyEditor/components/ComponentContainer.vue
  2. 163 0
      src/components/DiyEditor/components/ComponentContainerProperty.vue
  3. 2 1
      src/components/DiyEditor/components/ComponentLibrary.vue
  4. 32 26
      src/components/DiyEditor/components/mobile/Carousel/config.ts
  5. 27 59
      src/components/DiyEditor/components/mobile/Carousel/index.vue
  6. 116 99
      src/components/DiyEditor/components/mobile/Carousel/property.vue
  7. 27 0
      src/components/DiyEditor/components/mobile/ImageBar/config.ts
  8. 24 0
      src/components/DiyEditor/components/mobile/ImageBar/index.vue
  9. 34 0
      src/components/DiyEditor/components/mobile/ImageBar/property.vue
  10. 1 1
      src/components/DiyEditor/components/mobile/NavigationBar/config.ts
  11. 14 6
      src/components/DiyEditor/components/mobile/SearchBar/config.ts
  12. 1 6
      src/components/DiyEditor/components/mobile/SearchBar/index.vue
  13. 72 73
      src/components/DiyEditor/components/mobile/SearchBar/property.vue
  14. 37 0
      src/components/DiyEditor/components/mobile/VideoPlayer/config.ts
  15. 30 0
      src/components/DiyEditor/components/mobile/VideoPlayer/index.vue
  16. 55 0
      src/components/DiyEditor/components/mobile/VideoPlayer/property.vue
  17. 108 249
      src/components/DiyEditor/index.vue
  18. 62 1
      src/components/DiyEditor/util.ts
  19. 39 16
      src/components/UploadFile/src/UploadFile.vue
  20. 40 0
      src/components/VerticalButtonGroup/index.vue
  21. 1 1
      src/layout/components/Footer/src/Footer.vue
  22. 2 26
      src/views/mall/promotion/diy/page/decorate.vue
  23. 2 25
      src/views/mall/promotion/diy/template/decorate.vue

+ 222 - 0
src/components/DiyEditor/components/ComponentContainer.vue

@@ -0,0 +1,222 @@
+<template>
+  <div :class="['component', { active: active }]">
+    <div
+      :style="{
+        ...style
+      }"
+    >
+      <component :is="component.id" :property="component.property" />
+    </div>
+    <div class="component-wrap">
+      <!-- 左侧组件名 -->
+      <div class="component-name" v-if="component.name">
+        {{ component.name }}
+      </div>
+      <!-- 左侧:组件操作工具栏 -->
+      <div class="component-toolbar" v-if="showToolbar && component.name && active">
+        <VerticalButtonGroup type="primary">
+          <el-tooltip content="上移" placement="right">
+            <el-button :disabled="!canMoveUp" @click.stop="handleMoveComponent(-1)">
+              <Icon icon="ep:arrow-up" />
+            </el-button>
+          </el-tooltip>
+          <el-tooltip content="下移" placement="right">
+            <el-button :disabled="!canMoveDown" @click.stop="handleMoveComponent(1)">
+              <Icon icon="ep:arrow-down" />
+            </el-button>
+          </el-tooltip>
+          <el-tooltip content="复制" placement="right">
+            <el-button @click.stop="handleCopyComponent()">
+              <Icon icon="ep:copy-document" />
+            </el-button>
+          </el-tooltip>
+          <el-tooltip content="删除" placement="right">
+            <el-button @click.stop="handleDeleteComponent()">
+              <Icon icon="ep:delete" />
+            </el-button>
+          </el-tooltip>
+        </VerticalButtonGroup>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts">
+// 注册所有的组件
+import { components } from '../components/mobile/index'
+export default {
+  components: { ...components }
+}
+</script>
+<script setup lang="ts">
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+import { propTypes } from '@/utils/propTypes'
+import { object } from 'vue-types'
+
+/**
+ * 组件容器
+ * 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式
+ */
+defineOptions({ name: 'ComponentContainer' })
+
+type DiyComponentWithStyle = DiyComponent<any> & { property: { style?: ComponentStyle } }
+const props = defineProps({
+  component: object<DiyComponentWithStyle>().isRequired,
+  active: propTypes.bool.def(false),
+  canMoveUp: propTypes.bool.def(false),
+  canMoveDown: propTypes.bool.def(false),
+  showToolbar: propTypes.bool.def(true)
+})
+
+/**
+ * 组件样式
+ */
+const style = computed(() => {
+  let componentStyle = props.component.property.style
+  if (!componentStyle) {
+    return {}
+  }
+  return {
+    marginTop: `${componentStyle.marginTop || 0}px`,
+    marginBottom: `${componentStyle.marginBottom || 0}px`,
+    marginLeft: `${componentStyle.marginLeft || 0}px`,
+    marginRight: `${componentStyle.marginRight || 0}px`,
+    paddingTop: `${componentStyle.paddingTop || 0}px`,
+    paddingRight: `${componentStyle.paddingRight || 0}px`,
+    paddingBottom: `${componentStyle.paddingBottom || 0}px`,
+    paddingLeft: `${componentStyle.paddingLeft || 0}px`,
+    borderTopLeftRadius: `${componentStyle.borderTopLeftRadius || 0}px`,
+    borderTopRightRadius: `${componentStyle.borderTopRightRadius || 0}px`,
+    borderBottomRightRadius: `${componentStyle.borderBottomRightRadius || 0}px`,
+    borderBottomLeftRadius: `${componentStyle.borderBottomLeftRadius || 0}px`,
+    overflow: 'hidden',
+    background:
+      componentStyle.bgType === 'color' ? componentStyle.bgColor : `url(${componentStyle.bgImg})`
+  }
+})
+
+const emits = defineEmits<{
+  (e: 'move', direction: number): void
+  (e: 'copy'): void
+  (e: 'delete'): void
+}>()
+/**
+ * 移动组件
+ * @param direction 移动方向
+ */
+const handleMoveComponent = (direction: number) => {
+  emits('move', direction)
+}
+/**
+ * 复制组件
+ */
+const handleCopyComponent = () => {
+  emits('copy')
+}
+/**
+ * 删除组件
+ */
+const handleDeleteComponent = () => {
+  emits('delete')
+}
+</script>
+
+<style scoped lang="scss">
+$active-border-width: 2px;
+$hover-border-width: 1px;
+$name-position: -85px;
+$toolbar-position: -55px;
+/* 组件 */
+.component {
+  position: relative;
+  cursor: move;
+  .component-wrap {
+    display: block;
+    position: absolute;
+    left: -$active-border-width;
+    top: 0;
+    width: 100%;
+    height: 100%;
+    /* 鼠标放到组件上时 */
+    &:hover {
+      border: $hover-border-width dashed var(--el-color-primary);
+      box-shadow: 0 0 5px 0 rgba(24, 144, 255, 0.3);
+      .component-name {
+        /* 防止加了边框之后,位置移动 */
+        left: $name-position - $hover-border-width;
+        top: $hover-border-width;
+      }
+    }
+    /* 左侧:组件名称 */
+    .component-name {
+      display: block;
+      position: absolute;
+      width: 80px;
+      text-align: center;
+      line-height: 25px;
+      height: 25px;
+      background: #fff;
+      font-size: 12px;
+      left: $name-position;
+      top: $active-border-width;
+      box-shadow:
+        0 0 4px #00000014,
+        0 2px 6px #0000000f,
+        0 4px 8px 2px #0000000a;
+      /* 右侧小三角 */
+      &:after {
+        position: absolute;
+        top: 7.5px;
+        right: -10px;
+        content: ' ';
+        height: 0;
+        width: 0;
+        border: 5px solid transparent;
+        border-left-color: #fff;
+      }
+    }
+    /* 右侧:组件操作工具栏 */
+    .component-toolbar {
+      display: none;
+      position: absolute;
+      top: 0;
+      right: $toolbar-position;
+      /* 左侧小三角 */
+      &:before {
+        position: absolute;
+        top: 10px;
+        left: -10px;
+        content: ' ';
+        height: 0;
+        width: 0;
+        border: 5px solid transparent;
+        border-right-color: #2d8cf0;
+      }
+    }
+  }
+  /* 组件选中时 */
+  &.active {
+    margin-bottom: 4px;
+
+    .component-wrap {
+      border: $active-border-width solid var(--el-color-primary) !important;
+      box-shadow: 0 0 10px 0 rgba(24, 144, 255, 0.3);
+      margin-bottom: $active-border-width + $active-border-width;
+
+      .component-name {
+        background: var(--el-color-primary);
+        color: #fff;
+        /* 防止加了边框之后,位置移动 */
+        left: $name-position - $active-border-width !important;
+        top: 0 !important;
+        &:after {
+          border-left-color: var(--el-color-primary);
+        }
+      }
+      .component-toolbar {
+        display: block;
+      }
+    }
+  }
+}
+</style>

+ 163 - 0
src/components/DiyEditor/components/ComponentContainerProperty.vue

@@ -0,0 +1,163 @@
+<template>
+  <el-tabs stretch>
+    <el-tab-pane label="内容">
+      <slot></slot>
+    </el-tab-pane>
+    <el-tab-pane label="样式" lazy>
+      <el-card header="组件样式" class="property-group">
+        <el-form :model="formData" label-width="80px">
+          <el-form-item label="组件背景" prop="bgType">
+            <el-radio-group v-model="formData.bgType">
+              <el-radio label="color">纯色</el-radio>
+              <el-radio label="img">图片</el-radio>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="选择颜色" prop="bgColor" v-if="formData.bgType === 'color'">
+            <ColorInput v-model="formData.bgColor" />
+          </el-form-item>
+          <el-form-item label="上传图片" prop="bgImg" v-else>
+            <UploadImg v-model="formData.bgImg" :limit="1">
+              <template #tip>建议宽度 750px</template>
+            </UploadImg>
+          </el-form-item>
+          <el-tree :data="treeData" :expand-on-click-node="false">
+            <template #default="{ node, data }">
+              <el-form-item
+                :label="data.label"
+                :prop="data.prop"
+                :label-width="node.level === 1 ? '80px' : '62px'"
+                class="w-full m-b-0!"
+              >
+                <el-slider
+                  v-model="formData[data.prop]"
+                  :max="100"
+                  :min="0"
+                  show-input
+                  input-size="small"
+                  :show-input-controls="false"
+                  @input="handleSliderChange(data.prop)"
+                />
+              </el-form-item>
+            </template>
+          </el-tree>
+          <slot name="style" :formData="formData"></slot>
+        </el-form>
+      </el-card>
+    </el-tab-pane>
+  </el-tabs>
+</template>
+
+<script setup lang="ts">
+import { ComponentStyle, usePropertyForm } from '@/components/DiyEditor/util'
+
+/**
+ * 组件容器属性
+ * 用于包裹组件,为组件提供 背景、外边距、内边距、边框等样式
+ */
+defineOptions({ name: 'ComponentContainer' })
+
+const props = defineProps<{ modelValue: ComponentStyle }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+
+const treeData = [
+  {
+    label: '外部边距',
+    prop: 'margin',
+    children: [
+      {
+        label: '上',
+        prop: 'marginTop'
+      },
+      {
+        label: '右',
+        prop: 'marginRight'
+      },
+      {
+        label: '下',
+        prop: 'marginBottom'
+      },
+      {
+        label: '左',
+        prop: 'marginLeft'
+      }
+    ]
+  },
+  {
+    label: '内部边距',
+    prop: 'padding',
+    children: [
+      {
+        label: '上',
+        prop: 'paddingTop'
+      },
+      {
+        label: '右',
+        prop: 'paddingRight'
+      },
+      {
+        label: '下',
+        prop: 'paddingBottom'
+      },
+      {
+        label: '左',
+        prop: 'paddingLeft'
+      }
+    ]
+  },
+  {
+    label: '边框圆角',
+    prop: 'borderRadius',
+    children: [
+      {
+        label: '上左',
+        prop: 'borderTopLeftRadius'
+      },
+      {
+        label: '上右',
+        prop: 'borderTopRightRadius'
+      },
+      {
+        label: '下右',
+        prop: 'borderBottomRightRadius'
+      },
+      {
+        label: '下左',
+        prop: 'borderBottomLeftRadius'
+      }
+    ]
+  }
+]
+
+const handleSliderChange = (prop: string) => {
+  switch (prop) {
+    case 'margin':
+      formData.value.marginTop = formData.value.margin
+      formData.value.marginRight = formData.value.margin
+      formData.value.marginBottom = formData.value.margin
+      formData.value.marginLeft = formData.value.margin
+      break
+    case 'padding':
+      formData.value.paddingTop = formData.value.padding
+      formData.value.paddingRight = formData.value.padding
+      formData.value.paddingBottom = formData.value.padding
+      formData.value.paddingLeft = formData.value.padding
+      break
+    case 'borderRadius':
+      formData.value.borderTopLeftRadius = formData.value.borderRadius
+      formData.value.borderTopRightRadius = formData.value.borderRadius
+      formData.value.borderBottomRightRadius = formData.value.borderRadius
+      formData.value.borderBottomLeftRadius = formData.value.borderRadius
+      break
+  }
+}
+</script>
+
+<style scoped lang="scss">
+:deep(.el-slider__runway) {
+  margin-right: 16px;
+}
+:deep(.el-input-number) {
+  width: 50px;
+}
+</style>

+ 2 - 1
src/components/DiyEditor/components/ComponentLibrary.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-aside class="editor-left" width="260px">
+  <el-aside class="editor-left" width="261px">
     <el-scrollbar>
       <el-collapse v-model="extendGroups">
         <el-collapse-item
@@ -11,6 +11,7 @@
           <draggable
             class="component-container"
             ghost-class="draggable-ghost"
+            item-key="index"
             :list="group.components"
             :sort="false"
             :group="{ name: 'component', pull: 'clone', put: false }"

+ 32 - 26
src/components/DiyEditor/components/mobile/Carousel/config.ts

@@ -1,27 +1,30 @@
-import { DiyComponent } from '@/components/DiyEditor/util'
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
 
 /** 轮播图属性 */
 export interface CarouselProperty {
-  // 选择模板
-  swiperType: number
-  // 图片圆角
-  borderRadius: number
-  // 页面边距
-  pageMargin: number
-  // 图片边距
-  imageMargin: number
-  // 分页类型
-  pagingType: 'bullets' | 'fraction' | 'progressbar'
-  // 一行个数
-  rowIndividual: number
-  // 添加图片
+  // 类型:默认 | 卡片
+  type: 'default' | 'card'
+  // 指示器样式:点 | 数字
+  indicator: 'dot' | 'number'
+  // 是否自动播放
+  autoplay: boolean
+  // 播放间隔
+  interval: number
+  // 轮播内容
   items: CarouselItemProperty[]
+  // 组件样式
+  style: ComponentStyle
 }
-
+// 轮播内容属性
 export interface CarouselItemProperty {
-  title: string
+  // 类型:图片 | 视频
+  type: 'img' | 'video'
+  // 图片链接
   imgUrl: string
-  link: string
+  // 视频链接
+  videoUrl: string
+  // 跳转链接
+  url: string
 }
 
 // 定义组件
@@ -30,15 +33,18 @@ export const component = {
   name: '轮播图',
   icon: 'system-uicons:carousel',
   property: {
-    swiperType: 0, // 选择模板
-    borderRadius: 0, // 图片圆角
-    pageMargin: 0, // 页面边距
-    imageMargin: 0, // 图片边距
-    pagingType: 'bullets', // 分页类型
-    rowIndividual: 2, // 一行个数
+    type: 'default',
+    indicator: 'dot',
+    autoplay: false,
+    interval: 3,
     items: [
-      { imgUrl: 'https://static.iocoder.cn/mall/banner-01.jpg' },
-      { imgUrl: 'https://static.iocoder.cn/mall/banner-02.jpg' }
-    ] as CarouselItemProperty[]
+      { type: 'img', imgUrl: 'https://static.iocoder.cn/mall/banner-01.jpg', videoUrl: '' },
+      { type: 'img', imgUrl: 'https://static.iocoder.cn/mall/banner-02.jpg', videoUrl: '' }
+    ] as CarouselItemProperty[],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
   }
 } as DiyComponent<CarouselProperty>

+ 27 - 59
src/components/DiyEditor/components/mobile/Carousel/index.vue

@@ -6,70 +6,38 @@
   >
     <Icon icon="tdesign:image" class="text-gray-8 text-120px!" />
   </div>
-  <!-- 一行一个 -->
-  <div
-    v-if="property.swiperType === 0"
-    class="flex flex-col"
-    :style="{
-      paddingLeft: property.pageMargin + 'px',
-      paddingRight: property.pageMargin + 'px'
-    }"
-  >
-    <div v-for="(item, index) in property.items" :key="index">
-      <div
-        class="img-item"
-        :style="{
-          marginBottom: property.imageMargin + 'px',
-          borderRadius: property.borderRadius + 'px'
-        }"
-      >
-        <img alt="" :src="item.imgUrl" />
-        <div v-if="item.title" class="title">{{ item.title }}</div>
-      </div>
-    </div>
+  <div v-else class="relative">
+    <el-carousel
+      height="174px"
+      :type="property.type === 'card' ? 'card' : ''"
+      :autoplay="property.autoplay"
+      :interval="property.interval * 1000"
+      :indicator-position="property.indicator === 'number' ? 'none' : undefined"
+      @change="handleIndexChange"
+    >
+      <el-carousel-item v-for="(item, index) in property.items" :key="index">
+        <el-image class="h-full w-full" :src="item.imgUrl" />
+      </el-carousel-item>
+    </el-carousel>
+    <div
+      v-if="property.indicator === 'number'"
+      class="absolute p-y-2px bottom-10px right-10px rounded-xl bg-black p-x-8px text-10px text-white opacity-40"
+      >{{ currentIndex }} / {{ property.items.length }}</div
+    >
   </div>
-  <el-carousel height="174px" v-else :type="property.swiperType === 3 ? 'card' : ''">
-    <el-carousel-item v-for="(item, index) in property.items" :key="index">
-      <div class="img-item" :style="{ borderRadius: property.borderRadius + 'px' }">
-        <img alt="" :src="item.imgUrl" />
-        <div v-if="item.title" class="title">{{ item.title }}</div>
-      </div>
-    </el-carousel-item>
-  </el-carousel>
 </template>
 <script setup lang="ts">
 import { CarouselProperty } from './config'
 
-/** 页面顶部导航栏 */
-defineOptions({ name: 'NavigationBar' })
+/** 轮播图 */
+defineOptions({ name: 'Carousel' })
 
-const props = defineProps<{ property: CarouselProperty }>()
-</script>
+defineProps<{ property: CarouselProperty }>()
 
-<style scoped lang="scss">
-.img-item {
-  width: 100%;
-  position: relative;
-  overflow: hidden;
-  &:last-child {
-    margin: 0 !important;
-  }
-  /* 图片 */
-  img {
-    width: 100%;
-    height: 100%;
-    display: block;
-  }
-  .title {
-    height: 36px;
-    width: 100%;
-    background-color: rgba(51, 51, 51, 0.8);
-    text-align: center;
-    line-height: 36px;
-    color: #fff;
-    position: absolute;
-    bottom: 0;
-    left: 0;
-  }
+const currentIndex = ref(0)
+const handleIndexChange = (index: number) => {
+  currentIndex.value = index + 1
 }
-</style>
+</script>
+
+<style scoped lang="scss"></style>

+ 116 - 99
src/components/DiyEditor/components/mobile/Carousel/property.vue

@@ -1,103 +1,120 @@
 <template>
-  <el-form label-width="80px" :model="formData">
-    <el-form-item label="选择模板" prop="swiperType">
-      <el-radio-group v-model="formData.swiperType">
-        <el-tooltip class="item" content="一行一个" placement="bottom">
-          <el-radio-button :label="0">
-            <Icon icon="icon-park-twotone:multi-picture-carousel" />
-          </el-radio-button>
-        </el-tooltip>
-        <el-tooltip class="item" content="轮播海报" placement="bottom">
-          <el-radio-button :label="1">
-            <Icon icon="system-uicons:carousel" />
-          </el-radio-button>
-        </el-tooltip>
-        <el-tooltip class="item" content="多图单行" placement="bottom">
-          <el-radio-button :label="2">
-            <Icon icon="icon-park-twotone:carousel" />
-          </el-radio-button>
-        </el-tooltip>
-        <el-tooltip class="item" content="立体轮播" placement="bottom">
-          <el-radio-button :label="3">
-            <Icon icon="ic:round-view-carousel" />
-          </el-radio-button>
-        </el-tooltip>
-      </el-radio-group>
-    </el-form-item>
-
-    <el-text tag="p">添加图片</el-text>
-    <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序 </el-text>
-
-    <!-- 图片广告 -->
-    <div v-if="formData.items[0]">
-      <draggable
-        :list="formData.items"
-        :force-fallback="true"
-        :animation="200"
-        handle=".drag-icon"
-        class="m-t-8px"
-      >
-        <template #item="{ element, index }">
-          <div class="mb-4px flex flex-row gap-4px rounded bg-gray-100 p-8px">
-            <div class="flex flex-col items-start justify-between">
-              <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
-              <Icon
-                icon="ep:delete"
-                class="cursor-pointer text-red-5"
-                @click="handleDeleteImage(index)"
-                v-if="formData.items.length > 1"
-              />
-            </div>
-            <div class="flex flex-1 flex-col items-center justify-between gap-8px">
-              <UploadImg
-                v-model="element.imgUrl"
-                draggable="false"
-                height="80px"
-                width="100%"
-                class="min-w-80px"
-              />
-              <!-- 标题 -->
-              <el-input v-model="element.title" placeholder="标题,选填" />
-              <!-- 输入链接 -->
-              <el-input placeholder="链接,选填" v-model="element.link" />
-            </div>
-          </div>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-form label-width="80px" :model="formData">
+      <el-card header="样式设置" class="property-group" shadow="never">
+        <el-form-item label="样式" prop="type">
+          <el-radio-group v-model="formData.type">
+            <el-tooltip class="item" content="默认" placement="bottom">
+              <el-radio-button label="default">
+                <Icon icon="system-uicons:carousel" />
+              </el-radio-button>
+            </el-tooltip>
+            <el-tooltip class="item" content="卡片" placement="bottom">
+              <el-radio-button label="card">
+                <Icon icon="ic:round-view-carousel" />
+              </el-radio-button>
+            </el-tooltip>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="指示器" prop="indicator">
+          <el-radio-group v-model="formData.indicator">
+            <el-radio label="dot">小圆点</el-radio>
+            <el-radio label="number">数字</el-radio>
+          </el-radio-group>
+        </el-form-item>
+        <el-form-item label="是否轮播" prop="autoplay">
+          <el-switch v-model="formData.autoplay" />
+        </el-form-item>
+        <el-form-item label="播放间隔" prop="interval" v-if="formData.autoplay">
+          <el-slider
+            v-model="formData.interval"
+            :max="10"
+            :min="0.5"
+            :step="0.5"
+            show-input
+            input-size="small"
+            :show-input-controls="false"
+          />
+          <el-text type="info">单位:秒</el-text>
+        </el-form-item>
+      </el-card>
+      <el-card header="内容设置" class="property-group" shadow="never">
+        <el-text type="info" size="small"> 拖动左上角的小圆点可对其排序 </el-text>
+        <template v-if="formData.items[0]">
+          <draggable
+            :list="formData.items"
+            :force-fallback="true"
+            :animation="200"
+            handle=".drag-icon"
+            class="m-t-8px"
+            item-key="index"
+          >
+            <template #item="{ element, index }">
+              <div class="content mb-4px flex flex-col gap-4px rounded bg-gray-50 p-8px">
+                <div
+                  class="m--8px m-b-8px flex flex-row items-center justify-between bg-gray-100 p-8px"
+                >
+                  <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+                  <Icon
+                    icon="ep:delete"
+                    class="cursor-pointer text-red-5"
+                    @click="handleDeleteImage(index)"
+                    v-if="formData.items.length > 1"
+                  />
+                </div>
+                <el-form-item label="类型" prop="type" class="m-b-8px!" label-width="50px">
+                  <el-radio-group v-model="element.type">
+                    <el-radio label="img">图片</el-radio>
+                    <el-radio label="video">视频</el-radio>
+                  </el-radio-group>
+                </el-form-item>
+                <el-form-item
+                  label="图片"
+                  class="m-b-8px!"
+                  label-width="50px"
+                  v-if="element.type === 'img'"
+                >
+                  <UploadImg
+                    v-model="element.imgUrl"
+                    draggable="false"
+                    height="80px"
+                    width="100%"
+                    class="min-w-80px"
+                  />
+                </el-form-item>
+                <template v-else>
+                  <el-form-item label="封面" class="m-b-8px!" label-width="50px">
+                    <UploadImg
+                      v-model="element.imgUrl"
+                      draggable="false"
+                      height="80px"
+                      width="100%"
+                      class="min-w-80px"
+                    />
+                  </el-form-item>
+                  <el-form-item label="视频" class="m-b-8px!" label-width="50px">
+                    <UploadFile
+                      v-model="element.videoUrl"
+                      :file-type="['mp4']"
+                      :limit="1"
+                      :file-size="100"
+                      class="min-w-80px"
+                    />
+                  </el-form-item>
+                </template>
+                <el-form-item label="链接" class="m-b-8px!" label-width="50px">
+                  <el-input placeholder="链接" v-model="element.url" />
+                </el-form-item>
+              </div>
+            </template>
+          </draggable>
         </template>
-      </draggable>
-    </div>
-    <el-button @click="handleAddImage" type="primary" plain class="w-full"> 添加图片 </el-button>
-    <el-form-item label="一行个数" prop="rowIndividual" v-show="formData.swiperType === 2">
-      <!-- 单选框 -->
-      <el-radio-group v-model="formData.rowIndividual">
-        <el-radio :label="2">2个</el-radio>
-        <el-radio :label="3">3个</el-radio>
-        <el-radio :label="4">4个</el-radio>
-        <el-radio :label="5">5个</el-radio>
-        <el-radio :label="6">6个</el-radio>
-      </el-radio-group>
-    </el-form-item>
-    <el-form-item label="分页类型" prop="pagingType">
-      <el-radio-group v-model="formData.pagingType">
-        <el-radio :label="0">不显示</el-radio>
-        <el-radio label="bullets">样式一</el-radio>
-        <el-radio label="fraction">样式二</el-radio>
-        <el-radio label="progressbar">样式三</el-radio>
-      </el-radio-group>
-    </el-form-item>
-    <el-form-item label="图片圆角" prop="borderRadius">
-      <el-slider v-model="formData.borderRadius" :max="30" />
-    </el-form-item>
-    <el-form-item label="页面边距" prop="pageMargin" v-show="formData.swiperType === 0">
-      <el-slider v-model="formData.pageMargin" :max="20" />
-    </el-form-item>
-    <el-form-item
-      label="图片边距"
-      prop="imageMargin"
-      v-show="formData.swiperType === 0 || formData.swiperType === 2"
-    >
-      <el-slider v-model="formData.imageMargin" :max="20" />
-    </el-form-item>
-  </el-form>
+        <el-button @click="handleAddImage" type="primary" plain class="w-full">
+          添加图片
+        </el-button>
+      </el-card>
+    </el-form>
+  </ComponentContainerProperty>
 </template>
 
 <script setup lang="ts">
@@ -117,7 +134,7 @@ const handleAddImage = () => {
   formData.value.items.push({} as CarouselItemProperty)
 }
 // 删除图片
-const handleDeleteImage = (index) => {
+const handleDeleteImage = (index: number) => {
   formData.value.items.splice(index, 1)
 }
 </script>

+ 27 - 0
src/components/DiyEditor/components/mobile/ImageBar/config.ts

@@ -0,0 +1,27 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 图片展示属性 */
+export interface ImageBarProperty {
+  // 图片链接
+  imgUrl: string
+  // 跳转链接
+  url: string
+  // 组件样式
+  style: ComponentStyle
+}
+
+// 定义组件
+export const component = {
+  id: 'ImageBar',
+  name: '图片展示',
+  icon: 'ep:picture',
+  property: {
+    imgUrl: '',
+    url: '',
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8
+    } as ComponentStyle
+  }
+} as DiyComponent<ImageBarProperty>

+ 24 - 0
src/components/DiyEditor/components/mobile/ImageBar/index.vue

@@ -0,0 +1,24 @@
+<template>
+  <!-- 无图片 -->
+  <div class="h-50px flex items-center justify-center bg-gray-3" v-if="!property.imgUrl">
+    <Icon icon="ep:picture" class="text-gray-8 text-30px!" />
+  </div>
+  <el-image class="min-h-30px" v-else :src="property.imgUrl" />
+</template>
+<script setup lang="ts">
+import { ImageBarProperty } from './config'
+
+/** 图片展示 */
+defineOptions({ name: 'ImageBar' })
+
+defineProps<{ property: ImageBarProperty }>()
+</script>
+
+<style scoped lang="scss">
+/* 图片 */
+img {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+</style>

+ 34 - 0
src/components/DiyEditor/components/mobile/ImageBar/property.vue

@@ -0,0 +1,34 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-form label-width="80px" :model="formData">
+      <el-form-item label="上传图片" prop="imgUrl">
+        <UploadImg
+          v-model="formData.imgUrl"
+          draggable="false"
+          height="80px"
+          width="100%"
+          class="min-w-80px"
+        >
+          <template #tip> 建议宽度750 </template>
+        </UploadImg>
+      </el-form-item>
+      <el-form-item label="链接" prop="url">
+        <el-input placeholder="链接" v-model="formData.url" />
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import { ImageBarProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+
+// 图片展示属性面板
+defineOptions({ name: 'ImageBarProperty' })
+
+const props = defineProps<{ modelValue: ImageBarProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style scoped lang="scss"></style>

+ 1 - 1
src/components/DiyEditor/components/mobile/NavigationBar/config.ts

@@ -29,7 +29,7 @@ export const component = {
     title: '页面标题',
     description: '',
     navBarHeight: 35,
-    backgroundColor: '#f5f5f5',
+    backgroundColor: '#fff',
     backgroundImage: '',
     styleType: 'default',
     alwaysShow: true,

+ 14 - 6
src/components/DiyEditor/components/mobile/SearchBar/config.ts

@@ -1,4 +1,4 @@
-import { DiyComponent } from '@/components/DiyEditor/util'
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
 
 /** 搜索框属性 */
 export interface SearchProperty {
@@ -7,10 +7,10 @@ export interface SearchProperty {
   borderRadius: number // 框体样式
   placeholder: string // 占位文字
   placeholderPosition: PlaceholderPosition // 占位文字位置
-  backgroundColor: string // 背景颜色
-  borderColor: string // 框体颜色
+  backgroundColor: string // 框体颜色
   textColor: string // 字体颜色
   hotKeywords: string[] // 热词
+  style: ComponentStyle
 }
 
 // 文字位置
@@ -27,9 +27,17 @@ export const component = {
     borderRadius: 0,
     placeholder: '搜索商品',
     placeholderPosition: 'left',
-    backgroundColor: 'rgb(249, 249, 249)',
-    borderColor: 'rgb(255, 255, 255)',
+    backgroundColor: 'rgb(238, 238, 238)',
     textColor: 'rgb(150, 151, 153)',
-    hotKeywords: []
+    hotKeywords: [],
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8,
+      paddingTop: 8,
+      paddingRight: 8,
+      paddingBottom: 8,
+      paddingLeft: 8
+    } as ComponentStyle
   }
 } as DiyComponent<SearchProperty>

+ 1 - 6
src/components/DiyEditor/components/mobile/SearchBar/index.vue

@@ -2,8 +2,6 @@
   <div
     class="search-bar"
     :style="{
-      background: property.backgroundColor,
-      border: `1px solid ${property.backgroundColor}`,
       color: property.textColor
     }"
   >
@@ -12,7 +10,7 @@
       class="inner"
       :style="{
         height: `${property.height}px`,
-        background: property.borderColor,
+        background: property.backgroundColor,
         borderRadius: `${property.borderRadius}px`
       }"
     >
@@ -44,13 +42,10 @@ defineProps<{ property: SearchProperty }>()
 
 <style scoped lang="scss">
 .search-bar {
-  position: relative;
   /* 搜索框 */
   .inner {
     position: relative;
-    width: calc(100% - 16px);
     min-height: 28px;
-    margin: 5px auto;
     display: flex;
     align-items: center;
     font-size: 14px;

+ 72 - 73
src/components/DiyEditor/components/mobile/SearchBar/property.vue

@@ -1,78 +1,77 @@
 <template>
-  <el-text tag="p"> 搜索热词 </el-text>
-  <el-text type="info" size="small"> 拖动左侧的小圆点可以调整热词顺序 </el-text>
+  <ComponentContainerProperty v-model="formData.style">
+    <el-text tag="p"> 搜索热词 </el-text>
+    <el-text type="info" size="small"> 拖动左侧的小圆点可以调整热词顺序 </el-text>
 
-  <!-- 表单 -->
-  <el-form label-width="80px" :model="formData" class="m-t-8px">
-    <div v-if="formData.hotKeywords.length">
-      <VueDraggable
-        :list="formData.hotKeywords"
-        item-key="index"
-        handle=".drag-icon"
-        :forceFallback="true"
-        :animation="200"
-      >
-        <template #item="{ index }">
-          <div class="mb-4px flex flex-row items-center gap-4px rounded bg-gray-100 p-8px">
-            <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
-            <el-input v-model="formData.hotKeywords[index]" placeholder="请输入热词" />
-            <Icon icon="ep:delete" class="text-red-500" @click="deleteHotWord(index)" />
-          </div>
-        </template>
-      </VueDraggable>
-    </div>
-    <el-form-item label-width="0">
-      <el-button @click="handleAddHotWord" type="primary" plain class="m-t-8px w-full">
-        添加热词
-      </el-button>
-    </el-form-item>
-    <el-form-item label="框体样式">
-      <el-radio-group v-model="formData!.borderRadius">
-        <el-tooltip content="方形" placement="top">
-          <el-radio-button :label="0">
-            <Icon icon="tabler:input-search" />
-          </el-radio-button>
-        </el-tooltip>
-        <el-tooltip content="圆形" placement="top">
-          <el-radio-button :label="10">
-            <Icon icon="iconoir:input-search" />
-          </el-radio-button>
-        </el-tooltip>
-      </el-radio-group>
-    </el-form-item>
-    <el-form-item label="提示文字" prop="placeholder">
-      <el-input v-model="formData.placeholder" />
-    </el-form-item>
-    <el-form-item label="文本位置" prop="placeholderPosition">
-      <el-radio-group v-model="formData!.placeholderPosition">
-        <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="showScan">
-      <el-switch v-model="formData!.showScan" />
-    </el-form-item>
-    <el-form-item label="框体高度" prop="height">
-      <el-slider v-model="formData!.height" :max="50" :min="28" show-input input-size="small" />
-    </el-form-item>
-    <el-form-item label="背景颜色" prop="backgroundColor">
-      <ColorInput v-model="formData.backgroundColor" />
-    </el-form-item>
-    <el-form-item label="框体颜色" prop="borderColor">
-      <ColorInput v-model="formData.borderColor" />
-    </el-form-item>
-    <el-form-item class="lef" label="文本颜色" prop="textColor">
-      <ColorInput v-model="formData.textColor" />
-    </el-form-item>
-  </el-form>
+    <!-- 表单 -->
+    <el-form label-width="80px" :model="formData" class="m-t-8px">
+      <div v-if="formData.hotKeywords.length">
+        <VueDraggable
+          :list="formData.hotKeywords"
+          item-key="index"
+          handle=".drag-icon"
+          :forceFallback="true"
+          :animation="200"
+        >
+          <template #item="{ index }">
+            <div class="mb-4px flex flex-row items-center gap-4px rounded bg-gray-100 p-8px">
+              <Icon icon="ic:round-drag-indicator" class="drag-icon cursor-move" />
+              <el-input v-model="formData.hotKeywords[index]" placeholder="请输入热词" />
+              <Icon icon="ep:delete" class="text-red-500" @click="deleteHotWord(index)" />
+            </div>
+          </template>
+        </VueDraggable>
+      </div>
+      <el-form-item label-width="0">
+        <el-button @click="handleAddHotWord" type="primary" plain class="m-t-8px w-full">
+          添加热词
+        </el-button>
+      </el-form-item>
+      <el-form-item label="框体样式">
+        <el-radio-group v-model="formData!.borderRadius">
+          <el-tooltip content="方形" placement="top">
+            <el-radio-button :label="0">
+              <Icon icon="tabler:input-search" />
+            </el-radio-button>
+          </el-tooltip>
+          <el-tooltip content="圆形" placement="top">
+            <el-radio-button :label="10">
+              <Icon icon="iconoir:input-search" />
+            </el-radio-button>
+          </el-tooltip>
+        </el-radio-group>
+      </el-form-item>
+      <el-form-item label="提示文字" prop="placeholder">
+        <el-input v-model="formData.placeholder" />
+      </el-form-item>
+      <el-form-item label="文本位置" prop="placeholderPosition">
+        <el-radio-group v-model="formData!.placeholderPosition">
+          <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="showScan">
+        <el-switch v-model="formData!.showScan" />
+      </el-form-item>
+      <el-form-item label="框体高度" prop="height">
+        <el-slider v-model="formData!.height" :max="50" :min="28" show-input input-size="small" />
+      </el-form-item>
+      <el-form-item label="框体颜色" prop="backgroundColor">
+        <ColorInput v-model="formData.backgroundColor" />
+      </el-form-item>
+      <el-form-item class="lef" label="文本颜色" prop="textColor">
+        <ColorInput v-model="formData.textColor" />
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
 </template>
 
 <script setup lang="ts">

+ 37 - 0
src/components/DiyEditor/components/mobile/VideoPlayer/config.ts

@@ -0,0 +1,37 @@
+import { ComponentStyle, DiyComponent } from '@/components/DiyEditor/util'
+
+/** 视频播放属性 */
+export interface VideoPlayerProperty {
+  // 视频链接
+  videoUrl: string
+  // 封面链接
+  posterUrl: string
+  // 是否自动播放
+  autoplay: boolean
+  // 组件样式
+  style: VideoPlayerStyle
+}
+
+// 视频播放样式
+export interface VideoPlayerStyle extends ComponentStyle {
+  // 视频高度
+  height: number
+}
+
+// 定义组件
+export const component = {
+  id: 'VideoPlayer',
+  name: '视频播放',
+  icon: 'ep:video-play',
+  property: {
+    videoUrl: '',
+    posterUrl: '',
+    autoplay: false,
+    style: {
+      bgType: 'color',
+      bgColor: '#fff',
+      marginBottom: 8,
+      height: 300
+    } as ComponentStyle
+  }
+} as DiyComponent<VideoPlayerProperty>

+ 30 - 0
src/components/DiyEditor/components/mobile/VideoPlayer/index.vue

@@ -0,0 +1,30 @@
+<template>
+  <div class="w-full" :style="{ height: `${property.style.height}px` }">
+    <el-image class="w-full w-full" :src="property.posterUrl" v-if="property.posterUrl" />
+    <video
+      v-else
+      class="w-full w-full"
+      :src="property.videoUrl"
+      :poster="property.posterUrl"
+      :autoplay="property.autoplay"
+      controls
+    ></video>
+  </div>
+</template>
+<script setup lang="ts">
+import { VideoPlayerProperty } from './config'
+
+/** 视频播放 */
+defineOptions({ name: 'VideoPlayer' })
+
+defineProps<{ property: VideoPlayerProperty }>()
+</script>
+
+<style scoped lang="scss">
+/* 图片 */
+img {
+  width: 100%;
+  height: 100%;
+  display: block;
+}
+</style>

+ 55 - 0
src/components/DiyEditor/components/mobile/VideoPlayer/property.vue

@@ -0,0 +1,55 @@
+<template>
+  <ComponentContainerProperty v-model="formData.style">
+    <template #style="{ formData }">
+      <el-form-item label="高度" prop="height">
+        <el-slider
+          v-model="formData.height"
+          :max="500"
+          :min="100"
+          show-input
+          input-size="small"
+          :show-input-controls="false"
+        />
+      </el-form-item>
+    </template>
+    <el-form label-width="80px" :model="formData">
+      <el-form-item label="上传视频" prop="videoUrl">
+        <UploadFile
+          v-model="formData.videoUrl"
+          :file-type="['mp4']"
+          :limit="1"
+          :file-size="100"
+          class="min-w-80px"
+        />
+      </el-form-item>
+      <el-form-item label="上传封面" prop="posterUrl">
+        <UploadImg
+          v-model="formData.posterUrl"
+          draggable="false"
+          height="80px"
+          width="100%"
+          class="min-w-80px"
+        >
+          <template #tip> 建议宽度750 </template>
+        </UploadImg>
+      </el-form-item>
+      <el-form-item label="自动播放" prop="autoplay">
+        <el-switch v-model="formData.autoplay" />
+      </el-form-item>
+    </el-form>
+  </ComponentContainerProperty>
+</template>
+
+<script setup lang="ts">
+import { VideoPlayerProperty } from './config'
+import { usePropertyForm } from '@/components/DiyEditor/util'
+
+// 视频播放属性面板
+defineOptions({ name: 'VideoPlayerProperty' })
+
+const props = defineProps<{ modelValue: VideoPlayerProperty }>()
+const emit = defineEmits(['update:modelValue'])
+const { formData } = usePropertyForm(props.modelValue, emit)
+</script>
+
+<style scoped lang="scss"></style>

+ 108 - 249
src/components/DiyEditor/index.vue

@@ -33,111 +33,63 @@
       <ComponentLibrary ref="componentLibrary" :list="libs" v-if="libs && libs.length > 0" />
       <!-- 中心设计区域 -->
       <div class="editor-center page-prop-area" @click="handlePageSelected">
-        <div class="editor-design">
-          <!-- 手机顶部 -->
-          <div class="editor-design-top">
-            <!-- 手机顶部状态栏 -->
-            <img src="@/assets/imgs/diy/statusBar.png" alt="" class="status-bar" />
-            <!-- 手机顶部导航栏 -->
-            <NavigationBar
-              v-if="showNavigationBar"
-              :property="navigationBarComponent.property"
-              @click="handleNavigationBarSelected"
-              :class="[
-                'component',
-                'cursor-pointer!',
-                { active: selectedComponent?.id === navigationBarComponent.id }
-              ]"
-            />
-          </div>
-          <!-- 手机页面编辑区域 -->
-          <el-scrollbar class="editor-design-center" height="100%" view-class="page-prop-area">
-            <div
-              class="phone-container"
-              :style="{
-                backgroundColor: pageConfigComponent.property.backgroundColor,
-                backgroundImage: `url(${pageConfigComponent.property.backgroundImage})`
-              }"
-            >
-              <draggable
-                class="page-prop-area drag-area"
-                v-model="pageComponents"
-                item-key="index"
-                :animation="200"
-                filter=".component-toolbar"
-                ghost-class="draggable-ghost"
-                :force-fallback="true"
-                group="component"
-                @change="handleComponentChange"
-              >
-                <template #item="{ element, index }">
-                  <div class="component-container" @click="handleComponentSelected(element, index)">
-                    <!-- 左侧组件名 -->
-                    <div
-                      :class="['component-name', { active: selectedComponentIndex === index }]"
-                      v-if="element.name"
-                    >
-                      {{ element.name }}
-                    </div>
-                    <!-- 组件内容区 -->
-                    <div :class="['component', { active: selectedComponentIndex === index }]">
-                      <component
-                        :is="element.id"
-                        :property="element.property"
-                        :data-type="element.id"
-                      />
-                    </div>
-                    <!-- 左侧:组件操作工具栏 -->
-                    <div
-                      class="component-toolbar"
-                      v-if="element.name && selectedComponentIndex === index"
-                    >
-                      <el-button-group type="primary">
-                        <el-tooltip content="上移" placement="right">
-                          <el-button
-                            :disabled="index === 0"
-                            @click.stop="handleMoveComponent(index, -1)"
-                          >
-                            <Icon icon="ep:arrow-up" />
-                          </el-button>
-                        </el-tooltip>
-                        <el-tooltip content="下移" placement="right">
-                          <el-button
-                            :disabled="index === pageComponents.length - 1"
-                            @click.stop="handleMoveComponent(index, 1)"
-                          >
-                            <Icon icon="ep:arrow-down" />
-                          </el-button>
-                        </el-tooltip>
-                        <el-tooltip content="复制" placement="right">
-                          <el-button @click.stop="handleCopyComponent(index)">
-                            <Icon icon="ep:copy-document" />
-                          </el-button>
-                        </el-tooltip>
-                        <el-tooltip content="删除" placement="right">
-                          <el-button @click.stop="handleDeleteComponent(index)">
-                            <Icon icon="ep:delete" />
-                          </el-button>
-                        </el-tooltip>
-                      </el-button-group>
-                    </div>
-                  </div>
-                </template>
-              </draggable>
-            </div>
-          </el-scrollbar>
-          <!-- 手机底部导航 -->
-          <div
-            v-if="showTabBar"
-            :class="[
-              'editor-design-bottom',
-              'component',
-              'cursor-pointer!',
-              { active: selectedComponent?.id === tabBarComponent.id }
-            ]"
+        <!-- 手机顶部 -->
+        <div class="editor-design-top">
+          <!-- 手机顶部状态栏 -->
+          <img src="@/assets/imgs/diy/statusBar.png" alt="" class="status-bar" />
+          <!-- 手机顶部导航栏 -->
+          <ComponentContainer
+            v-if="showNavigationBar"
+            :component="navigationBarComponent"
+            :show-toolbar="false"
+            :active="selectedComponent?.id === navigationBarComponent.id"
+            @click="handleNavigationBarSelected"
+            class="cursor-pointer!"
+          />
+        </div>
+        <!-- 手机页面编辑区域 -->
+        <el-scrollbar
+          height="100%"
+          wrap-class="editor-design-center page-prop-area"
+          view-class="phone-container"
+          :view-style="{
+            backgroundColor: pageConfigComponent.property.backgroundColor,
+            backgroundImage: `url(${pageConfigComponent.property.backgroundImage})`
+          }"
+        >
+          <draggable
+            class="page-prop-area drag-area"
+            v-model="pageComponents"
+            item-key="index"
+            :animation="200"
+            filter=".component-toolbar"
+            ghost-class="draggable-ghost"
+            :force-fallback="true"
+            group="component"
+            @change="handleComponentChange"
           >
-            <TabBar :property="tabBarComponent.property" @click="handleTabBarSelected" />
-          </div>
+            <template #item="{ element, index }">
+              <ComponentContainer
+                :component="element"
+                :active="selectedComponentIndex === index"
+                :can-move-up="index > 0"
+                :can-move-down="index < pageComponents.length - 1"
+                @move="(direction) => handleMoveComponent(index, direction)"
+                @copy="handleCopyComponent(index)"
+                @delete="handleDeleteComponent(index)"
+                @click="handleComponentSelected(element, index)"
+              />
+            </template>
+          </draggable>
+        </el-scrollbar>
+        <!-- 手机底部导航 -->
+        <div v-if="showTabBar" :class="['editor-design-bottom', 'component', 'cursor-pointer!']">
+          <ComponentContainer
+            :component="tabBarComponent"
+            :show-toolbar="false"
+            :active="selectedComponent?.id === tabBarComponent.id"
+            @click="handleTabBarSelected"
+          />
         </div>
       </div>
       <!-- 右侧属性面板 -->
@@ -178,8 +130,6 @@ export default {
 <script lang="ts" setup>
 import draggable from 'vuedraggable'
 import ComponentLibrary from './components/ComponentLibrary.vue'
-import NavigationBar from './components/mobile/NavigationBar/index.vue'
-import TabBar from './components/mobile/TabBar/index.vue'
 import { cloneDeep, includes } from 'lodash-es'
 import { component as PAGE_CONFIG_COMPONENT } from '@/components/DiyEditor/components/mobile/PageConfig/config'
 import { component as NAVIGATION_BAR_COMPONENT } from './components/mobile/NavigationBar/config'
@@ -256,6 +206,9 @@ const handleSave = () => {
       return { id: component.id, property: component.property }
     })
   } as PageConfig
+  if (!props.showTabBar) {
+    delete pageConfig.tabBar
+  }
   // 发送数据更新通知
   const modelValue = isString(props.modelValue) ? JSON.stringify(pageConfig) : pageConfig
   emits('update:modelValue', modelValue)
@@ -383,6 +336,7 @@ onMounted(() => setDefaultSelectedComponent())
 <style lang="scss" scoped>
 /* 手机宽度 */
 $phone-width: 375px;
+$toolbar-height: 42px;
 /* 根节点样式 */
 .editor {
   height: 100%;
@@ -394,7 +348,7 @@ $phone-width: 375px;
     display: flex;
     align-items: center;
     justify-content: space-between;
-    height: auto;
+    height: $toolbar-height;
     padding: 0;
     border-bottom: solid 1px var(--el-border-color);
     background-color: var(--el-bg-color);
@@ -416,176 +370,81 @@ $phone-width: 375px;
   /* 中心操作区 */
   .editor-container {
     height: calc(
-      100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) - 42px
+      100vh - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height) -
+        $toolbar-height
     );
     /* 右侧属性面板 */
     .editor-right {
       flex-shrink: 0;
       box-shadow: -8px 0 8px -8px rgba(0, 0, 0, 0.12);
+      overflow: hidden;
       /* 属性面板顶部:减少内边距 */
       :deep(.el-card__header) {
         padding: 8px 16px;
       }
       /* 属性面板分组 */
-      .property-group {
-        /* 属性分组 */
-        :deep(.el-card__header) {
+      :deep(.property-group) {
+        margin: 0 -20px;
+        &.el-card {
+          border: none;
+        }
+        /* 属性分组名称 */
+        .el-card__header {
           border: none;
           background: var(--el-bg-color-page);
+          padding: 8px 32px;
+        }
+        .el-card__body {
+          border: none;
         }
       }
     }
 
     /* 中心区域 */
     .editor-center {
+      position: relative;
       flex: 1 1 0;
-      padding: 16px 0;
       background-color: var(--app-content-bg-color);
       display: flex;
+      flex-direction: column;
       justify-content: center;
-      /* 中心设计区域 */
-      .editor-design {
-        position: relative;
-        height: 100%;
-        width: 100%;
+      margin: 16px 0 0 0;
+      overflow: hidden;
+      width: 100%;
+
+      /* 手机顶部 */
+      .editor-design-top {
+        width: $phone-width;
+        margin: 0 auto;
         display: flex;
         flex-direction: column;
-        align-items: center;
-        overflow: hidden;
-
-        /* 组件 */
-        .component {
-          border: 1px solid #fff;
-          width: $phone-width;
-          cursor: move;
-          /* 鼠标放到组件上时 */
-          &:hover {
-            border: 1px dashed var(--el-color-primary);
-          }
-        }
-        /* 组件选中 */
-        .component.active {
-          border: 2px solid var(--el-color-primary);
-        }
-        /* 手机顶部 */
-        .editor-design-top {
-          width: $phone-width;
-          /* 手机顶部状态栏 */
-          .status-bar {
-            height: 20px;
-            width: $phone-width;
-            background-color: #fff;
-          }
-        }
-        /* 手机底部导航 */
-        .editor-design-bottom {
+        /* 手机顶部状态栏 */
+        .status-bar {
+          height: 20px;
           width: $phone-width;
+          background-color: #fff;
         }
-        /* 手机页面编辑区域 */
-        .editor-design-center {
-          width: 100%;
-          flex: 1 1 0;
-
-          :deep(.el-scrollbar__view) {
-            height: 100%;
-          }
+      }
+      /* 手机底部导航 */
+      .editor-design-bottom {
+        width: $phone-width;
+        margin: 0 auto;
+      }
+      /* 手机页面编辑区域 */
+      :deep(.editor-design-center) {
+        width: 100%;
 
-          /* 主体内容 */
-          .phone-container {
+        /* 主体内容 */
+        .phone-container {
+          position: relative;
+          background-repeat: no-repeat;
+          background-size: 100% 100%;
+          height: 100%;
+          width: $phone-width;
+          margin: 0 auto;
+          .drag-area {
             height: 100%;
-            box-sizing: border-box;
-            position: relative;
-            background-repeat: no-repeat;
-            background-size: 100% 100%;
-            width: $phone-width;
-            margin: 0 auto;
-            .drag-area {
-              height: 100%;
-            }
-
-            /* 组件容器(左侧:组件名称,中间:组件,右侧:操作工具栏) */
-            .component-container {
-              width: 100%;
-              position: relative;
-              /* 左侧:组件名称 */
-              .component-name {
-                position: absolute;
-                width: 80px;
-                text-align: center;
-                line-height: 25px;
-                height: 25px;
-                background: #fff;
-                font-size: 12px;
-                left: -85px;
-                top: 0;
-                box-shadow:
-                  0 0 4px #00000014,
-                  0 2px 6px #0000000f,
-                  0 4px 8px 2px #0000000a;
-                /* 右侧小三角 */
-                &:after {
-                  position: absolute;
-                  top: 7.5px;
-                  right: -10px;
-                  content: ' ';
-                  height: 0;
-                  width: 0;
-                  border: 5px solid transparent;
-                  border-left-color: #fff;
-                }
-              }
-              /* 组件选中按钮 */
-              .component-name.active {
-                background: var(--el-color-primary);
-                color: #fff;
-                &:after {
-                  border-left-color: var(--el-color-primary);
-                }
-              }
-              /* 右侧:组件操作工具栏 */
-              .component-toolbar {
-                position: absolute;
-                top: 0;
-                right: -57px;
-                /* 左侧小三角 */
-                &:before {
-                  position: absolute;
-                  top: 10px;
-                  left: -10px;
-                  content: ' ';
-                  height: 0;
-                  width: 0;
-                  border: 5px solid transparent;
-                  border-right-color: #2d8cf0;
-                }
-
-                /* 重写 Element 按钮组的样式(官方只支持水平显示,增加垂直显示的样式) */
-                .el-button-group {
-                  display: inline-flex;
-                  flex-direction: column;
-                }
-                .el-button-group > .el-button:first-child {
-                  border-bottom-left-radius: 0;
-                  border-bottom-right-radius: 0;
-                  border-top-right-radius: var(--el-border-radius-base);
-                  border-bottom-color: var(--el-button-divide-border-color);
-                }
-                .el-button-group > .el-button:last-child {
-                  border-top-left-radius: 0;
-                  border-top-right-radius: 0;
-                  border-bottom-left-radius: var(--el-border-radius-base);
-                  border-top-color: var(--el-button-divide-border-color);
-                }
-                .el-button-group .el-button--primary:not(:first-child):not(:last-child) {
-                  border-top-color: var(--el-button-divide-border-color);
-                  border-bottom-color: var(--el-button-divide-border-color);
-                }
-                .el-button-group > .el-button:not(:last-child) {
-                  margin-bottom: -1px;
-                  margin-right: 0;
-                }
-              }
-            }
+            width: 100%;
           }
         }
       }

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

@@ -3,19 +3,56 @@ import { PageConfigProperty } from '@/components/DiyEditor/components/mobile/Pag
 import { NavigationBarProperty } from '@/components/DiyEditor/components/mobile/NavigationBar/config'
 import { TabBarProperty } from '@/components/DiyEditor/components/mobile/TabBar/config'
 
+// 页面装修组件
 export interface DiyComponent<T> {
+  // 组件唯一标识
   id: string
+  // 组件名称
   name: string
+  // 组件图标
   icon: string
+  // 组件属性
   property: T
 }
 
+// 页面装修组件库
 export interface DiyComponentLibrary {
+  // 组件库名称
   name: string
+  // 是否展开
   extended: boolean
+  // 组件列表
   components: string[]
 }
 
+// 组件样式
+export interface ComponentStyle {
+  // 背景类型
+  bgType: 'color' | 'img'
+  // 背景颜色
+  bgColor: string
+  // 背景图片
+  bgImg: string
+  // 外边距
+  margin: number
+  marginTop: number
+  marginRight: number
+  marginBottom: number
+  marginLeft: number
+  // 内边距
+  padding: number
+  paddingTop: number
+  paddingRight: number
+  paddingBottom: number
+  paddingLeft: number
+  // 边框圆角
+  borderRadius: number
+  borderTopLeftRadius: number
+  borderTopRightRadius: number
+  borderBottomRightRadius: number
+  borderBottomLeftRadius: number
+}
+
 // 页面配置
 export interface PageConfig {
   // 页面属性
@@ -23,7 +60,7 @@ export interface PageConfig {
   // 顶部导航栏属性
   navigationBar: NavigationBarProperty
   // 底部导航菜单属性
-  tabBar: TabBarProperty
+  tabBar?: TabBarProperty
   // 页面组件列表
   components: PageComponent[]
 }
@@ -57,3 +94,27 @@ export function usePropertyForm<T>(modelValue: T, emit: Function): { formData: R
 
   return { formData }
 }
+
+// 页面组件库
+export const PAGE_LIBS = [
+  {
+    name: '基础组件',
+    extended: true,
+    components: [
+      'SearchBar',
+      'NoticeBar',
+      'GridNavigation',
+      'ListNavigation',
+      'Divider',
+      'TitleBar'
+    ]
+  },
+  { name: '图文组件', extended: true, components: ['ImageBar', 'Carousel', 'VideoPlayer'] },
+  { name: '商品组件', extended: true, components: ['ProductCard'] },
+  {
+    name: '会员组件',
+    extended: true,
+    components: ['UserCard', 'OrderCard', 'WalletCard', 'CouponCard']
+  },
+  { name: '营销组件', extended: true, components: ['Combination', 'Seckill', 'Point', 'Coupon'] }
+] as DiyComponentLibrary[]

+ 39 - 16
src/components/UploadFile/src/UploadFile.vue

@@ -33,11 +33,10 @@
   </div>
 </template>
 <script lang="ts" setup>
-import { PropType } from 'vue'
-
 import { propTypes } from '@/utils/propTypes'
 import { getAccessToken, getTenantId } from '@/utils/auth'
 import type { UploadInstance, UploadUserFile, UploadProps, UploadRawFile } from 'element-plus'
+import { isArray, isString } from '@/utils/is'
 
 defineOptions({ name: 'UploadFile' })
 
@@ -45,10 +44,7 @@ const message = useMessage() // 消息弹窗
 const emit = defineEmits(['update:modelValue'])
 
 const props = defineProps({
-  modelValue: {
-    type: Array as PropType<UploadUserFile[]>,
-    required: true
-  },
+  modelValue: propTypes.oneOfType<string | string[]>([String, Array<String>]).isRequired,
   title: propTypes.string.def('文件上传'),
   updateUrl: propTypes.string.def(import.meta.env.VITE_UPLOAD_URL),
   fileType: propTypes.array.def(['doc', 'xls', 'ppt', 'txt', 'pdf']), // 文件类型, 例如['png', 'jpg', 'jpeg']
@@ -62,7 +58,7 @@ const props = defineProps({
 const valueRef = ref(props.modelValue)
 const uploadRef = ref<UploadInstance>()
 const uploadList = ref<UploadUserFile[]>([])
-const fileList = ref<UploadUserFile[]>(props.modelValue)
+const fileList = ref<UploadUserFile[]>([])
 const uploadNumber = ref<number>(0)
 const uploadHeaders = ref({
   Authorization: 'Bearer ' + getAccessToken(),
@@ -109,7 +105,7 @@ const handleFileSuccess: UploadProps['onSuccess'] = (res: any): void => {
     fileList.value = fileList.value.concat(uploadList.value)
     uploadList.value = []
     uploadNumber.value = 0
-    emit('update:modelValue', listToString(fileList.value))
+    emitUpdateModelValue()
   }
 }
 // 文件数超出提示
@@ -125,20 +121,47 @@ const handleRemove = (file) => {
   const findex = fileList.value.map((f) => f.name).indexOf(file.name)
   if (findex > -1) {
     fileList.value.splice(findex, 1)
-    emit('update:modelValue', listToString(fileList.value))
+    emitUpdateModelValue()
   }
 }
 const handlePreview: UploadProps['onPreview'] = (uploadFile) => {
   console.log(uploadFile)
 }
-// 对象转成指定字符串分隔
-const listToString = (list: UploadUserFile[], separator?: string) => {
-  let strs = ''
-  separator = separator || ','
-  for (let i in list) {
-    strs += list[i].url + separator
+
+// 监听模型绑定值变动
+watch(
+  () => props.modelValue,
+  () => {
+    const files: string[] = []
+    // 情况1:字符串
+    if (isString(props.modelValue)) {
+      // 情况1.1:逗号分隔的多值
+      if (props.modelValue.includes(',')) {
+        files.concat(props.modelValue.split(','))
+      } else if (props.modelValue.length > 0) {
+        files.push(props.modelValue)
+      }
+    } else if (isArray(props.modelValue)) {
+      // 情况2:字符串
+      files.concat(props.modelValue)
+    } else {
+      throw new Error('不支持的 modelValue 类型')
+    }
+    fileList.value = files.map((url: string) => {
+      return { url, name: url.substring(url.lastIndexOf('/') + 1) } as UploadUserFile
+    })
+  },
+  { immediate: true }
+)
+// 发送文件链接列表更新
+const emitUpdateModelValue = () => {
+  // 情况1:数组结果
+  let result: string | string[] = fileList.value.map((file) => file.url!)
+  // 情况2:逗号分隔的字符串
+  if (isString(props.modelValue)) {
+    result = result.join(',')
   }
-  return strs != '' ? strs.substr(0, strs.length - 1) : ''
+  emit('update:modelValue', result)
 }
 </script>
 <style scoped lang="scss">

+ 40 - 0
src/components/VerticalButtonGroup/index.vue

@@ -0,0 +1,40 @@
+<template>
+  <el-button-group v-bind="$attrs">
+    <slot></slot>
+  </el-button-group>
+</template>
+
+<script setup lang="ts">
+/**
+ * 垂直按钮组
+ * Element官方的按钮组只支持水平显示,通过重写样式实现垂直布局
+ */
+defineOptions({ name: 'VerticalButtonGroup' })
+</script>
+
+<style scoped lang="scss">
+.el-button-group {
+  display: inline-flex;
+  flex-direction: column;
+}
+.el-button-group > :deep(.el-button:first-child) {
+  border-bottom-left-radius: 0;
+  border-bottom-right-radius: 0;
+  border-top-right-radius: var(--el-border-radius-base);
+  border-bottom-color: var(--el-button-divide-border-color);
+}
+.el-button-group > :deep(.el-button:last-child) {
+  border-top-left-radius: 0;
+  border-top-right-radius: 0;
+  border-bottom-left-radius: var(--el-border-radius-base);
+  border-top-color: var(--el-button-divide-border-color);
+}
+.el-button-group :deep(.el-button--primary:not(:first-child):not(:last-child)) {
+  border-top-color: var(--el-button-divide-border-color);
+  border-bottom-color: var(--el-button-divide-border-color);
+}
+.el-button-group > :deep(.el-button:not(:last-child)) {
+  margin-bottom: -1px;
+  margin-right: 0;
+}
+</style>

+ 1 - 1
src/layout/components/Footer/src/Footer.vue

@@ -19,6 +19,6 @@ const title = computed(() => appStore.getTitle)
     :class="prefixCls"
     class="h-[var(--app-footer-height)] bg-[var(--app-content-bg-color)] text-center leading-[var(--app-footer-height)] text-[var(--el-text-color-placeholder)] dark:bg-[var(--el-bg-color)]"
   >
-    <p style="font-size: 14px">Copyright ©2022-{{ title }}</p>
+    <span class="text-14px">Copyright ©2022-{{ title }}</span>
   </div>
 </template>

+ 2 - 26
src/views/mall/promotion/diy/page/decorate.vue

@@ -3,7 +3,7 @@
     v-if="formData && !formLoading"
     v-model="formData.property"
     :title="formData.name"
-    :libs="componentLibs"
+    :libs="PAGE_LIBS"
     :show-page-config="true"
     :show-navigation-bar="true"
     :show-tab-bar="false"
@@ -13,35 +13,11 @@
 <script setup lang="ts">
 import * as DiyPageApi from '@/api/mall/promotion/diy/page'
 import { useTagsViewStore } from '@/store/modules/tagsView'
-import { DiyComponentLibrary } from '@/components/DiyEditor/util'
+import { PAGE_LIBS } from '@/components/DiyEditor/util'
 
 /** 装修页面表单 */
 defineOptions({ name: 'DiyPageDecorate' })
 
-// 组件库
-const componentLibs = [
-  {
-    name: '基础组件',
-    extended: true,
-    components: [
-      'SearchBar',
-      'NoticeBar',
-      'GridNavigation',
-      'ListNavigation',
-      'Divider',
-      'TitleBar'
-    ]
-  },
-  { name: '图文组件', extended: true, components: ['Carousel'] },
-  { name: '商品组件', extended: true, components: ['ProductCard'] },
-  {
-    name: '会员组件',
-    extended: true,
-    components: ['UserCard', 'OrderCard', 'WalletCard', 'CouponCard']
-  },
-  { name: '营销组件', extended: true, components: ['Combination', 'Seckill', 'Point', 'Coupon'] }
-] as DiyComponentLibrary[]
-
 const message = useMessage() // 消息弹窗
 
 const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用

+ 2 - 25
src/views/mall/promotion/diy/template/decorate.vue

@@ -28,7 +28,7 @@
 import * as DiyTemplateApi from '@/api/mall/promotion/diy/template'
 import * as DiyPageApi from '@/api/mall/promotion/diy/page'
 import { useTagsViewStore } from '@/store/modules/tagsView'
-import { DiyComponentLibrary } from '@/components/DiyEditor/util'
+import { DiyComponentLibrary, PAGE_LIBS } from '@/components/DiyEditor/util'
 
 /** 装修模板表单 */
 defineOptions({ name: 'DiyTemplateDecorate' })
@@ -62,29 +62,6 @@ const getPageDetail = async (id: any) => {
 
 // 模板组件库
 const templateLibs = [] as DiyComponentLibrary[]
-// 页面组件库
-const pageLibs = [
-  {
-    name: '基础组件',
-    extended: true,
-    components: [
-      'SearchBar',
-      'NoticeBar',
-      'GridNavigation',
-      'ListNavigation',
-      'Divider',
-      'TitleBar'
-    ]
-  },
-  { name: '图文组件', extended: true, components: ['Carousel'] },
-  { name: '商品组件', extended: true, components: ['ProductCard'] },
-  {
-    name: '会员组件',
-    extended: true,
-    components: ['UserCard', 'OrderCard', 'WalletCard', 'CouponCard']
-  },
-  { name: '营销组件', extended: true, components: ['Combination', 'Seckill', 'Point', 'Coupon'] }
-] as DiyComponentLibrary[]
 // 当前组件库
 const libs = ref<DiyComponentLibrary[]>(templateLibs)
 // 模板选项切换
@@ -97,7 +74,7 @@ const handleTemplateItemChange = () => {
   }
 
   // 编辑页面
-  libs.value = pageLibs
+  libs.value = PAGE_LIBS
   currentFormData.value = formData.value!.pages.find(
     (page: DiyPageApi.DiyPageVO) => page.name === templateItems[selectedTemplateItem.value].name
   )