vue拖拽实现app或小程序装修

一、最终效果图

参考引用作者:作者:李白不吃茶v
原作者源代码git地址:大神的源代码

这里是引用
在这里插入图片描述

二、需要安装的依赖

安装:vuedraggable
语法:npm install vuedraggable

三、先了解一下拖拽

  • 下面是HTML5的拖拽事件
    dragstart: 开始拖元素触发
    dragenter:元素拖进可drop元素(绑定drop事件的元素)时触发
    dragover:当元素拖动到drop元素上时触发
    drop:当元素放下到drop元素触发
    dragleave:当元素离开drop元素时触发
    drag:每次元素被拖动时会触发
    dragend:放开拖动元素时触发
    完成一次拖放的事件过程是:dragstart -> dragenter->dragover -> drop->dragend
    注意:想要在DOM元素上使用拖拽事件,需要在标签上添加draggable=“true”,否则是无效的。

四、实现左侧拖拽至中间视图区域

  • 效果
    在这里插入图片描述
    在这里插入图片描述
    组件可自己定义

draggable.vue的代码:

<template>
  <!-- section标签定义文档中的节(section、区段),比如章节、页眉、页脚或文档中的其他部分 -->
  <section class="decoration-edit">
    <!-- 页面的左侧内容 -->
    <section class="l">
      <ul @dragstart="dragStart" @dragend="dragEnd">
        <li v-for="(val, key, index) in typeList" draggable :key="index + 1" :data-type="key">
          <span :class="val.icon"></span>
          <p>{{ val.name }}</p>
        </li>
      </ul>
    </section>
    <!-- 页面的中间内容 -->
    <section class="c">
      <!-- header(导航栏) 不可拖拽 -->
      <div class="top-nav" @click="selectType(0)">
        <img src="../../assets/images/topNavBlack.png" alt="" />
        <span class="tit">{{ info.title }}</span>
      </div>
      <div class="view-content" @drop="drog" @dragover="dragOver" :style="{ backgroundColor: info.backgroundColor }">
        <Draggable v-model="view" draggable=".item">
          <template v-for="(item, index) in view">
            <div v-if="index > 0" :data-index="index" :key="index" class="item" @click="selectType(index)">
              <!-- waiting 可拖拽配置区域 -->
              <template v-if="item.status && item.status === 2">
                <div class="wait">
                  {{ item.type }}
                </div>
              </template>
              <template v-else>
                <component :is="typeList[item.type]['com']" :data="item" :className="className[item.tabType]"></component>
              </template>
              <i @click="deleteItem($event, index)" class="el-icon-error"></i>
            </div>
          </template>
        </Draggable>
      </div>
    </section>
    <!-- 页面的右侧内容 -->
    <section class="r"></section>
  </section>
</template>

<script>
// 导入draggable组件
import Draggable from 'vuedraggable'
// 导入商品视图组件
import Product from '@/components/draggView/Product.vue'
// 导入图片视图组件
import Images from '@/components/draggView/Images.vue'
// 导入轮播图组件
import Banner from '@/components/draggView/Banner.vue'
export default {
  // 注册组件
  components: {
    Draggable,
    Product,
    Images,
    Banner
  },
  data() {
    return {
      // 定义左侧循环展示的内容以及拖拽后中间区域所显示的组件
      // 为什么是对象不是数组,应为循环的时候,对象的键值有用处
      typeList: {
        banner: {
          name: '轮播图',
          icon: 'el-icon-picture',
          com: Banner
        },
        product: {
          name: '商品',
          icon: 'el-icon-s-goods',
          com: Product
        },
        images: {
          name: '图片',
          icon: 'el-icon-picture',
          com: Images
        }
      },
      view: [
        {
          type: 'info',
          title: '页面标题'
        }
      ], // 中间视图所存下的数组
      title: '页面标题', // 中间导航栏上的标题
      type: '', // 进行拖拽的类型
      index: null, // 当前组件的索引
      isPush: false, // 是否已添加组件
      props: {}, // 传值
      isRight: false,
      className: {
        1: 'one',
        2: 'two',
        3: 'three'
      }
    }
  },
  computed: {
    info() {
      return this.view[0]
    }
  },
  methods: {
    // 切换视图组件
    selectType(index) {
      this.isRight = false
      this.props = this.view[index]
      this.$nextTick(() => (this.isRight = true))
    },
    // 删除已经拖拽进入的组件
    deleteItem(e, index) {
      e.preventDefault()
      e.stopPropagation()
      this.view.splice(index, 1)
      this.isRight = false
      this.props = {}
    },
    // 拖拽类型
    dragStart(e) {
      console.log(e)
      console.log(e.target.dataset.type)
      this.type = e.target.dataset.type
      console.log(this.type)
      console.log(this.index)
    },
    // 结束拖拽
    dragEnd(e) {
      console.log(e)
      console.log(this.index)
      this.$delete(this.view[this.index], 'status')
      this.isPush = false
      this.type = null
    },
    // 当元素放下到drop元素触发
    drog(e) {
      console.log(e)
      console.log(this.type)
      if (!this.type) {
        // 内容拖拽
        return
      }
      e.preventDefault()
      e.stopPropagation()
      this.dragEnd()
    },
    // 当元素拖动到drop元素上时触发
    dragOver(e) {
      console.log(e)
      if (!this.type) {
        // 内容拖拽
        return
      }
      e.preventDefault()
      e.stopPropagation()

      const className = e.target.className
      const name = className !== 'view-content' ? 'item' : 'view-content'
      console.log(className)

      const defaultData = {
        type: this.type, // 组件类型
        status: 2, // 默认状态
        data: [], // 数据
        options: {} // 选项操作
      }

      if (name === 'view-content') {
        if (!this.isPush) {
          this.index = this.view.length
          console.log(this.view.length)
          this.isPush = true
          this.view.push(defaultData)
          console.log(this.view)
        }
      } else if (name === 'item') {
        const target = e.target
        var [y, h, curIndex] = [e.offsetY, target.offsetHeight, target.dataset.index]
        const direction = y < h / 2

        if (!this.isPush) {
          // push to top or bottom
          if (direction) {
            if (curIndex === 0) {
              this.view.unshift(defaultData)
            } else {
              this.view.splice(curIndex, 0, defaultData)
            }
          } else {
            curIndex = +curIndex + 1
            this.view.splice(curIndex, 0, defaultData)
          }
        } else {
          // Moving
          if (direction) {
            var i = curIndex === 0 ? 0 : curIndex - 1
            var result = this.view[i].status === 2
          } else {
            i = +curIndex + 1
            result = this.view.length > i && this.view[i].status === 2
          }
          if (result) return
          const temp = this.view.splice(this.index, 1)
          this.view.splice(curIndex, 0, temp[0])
        }
        this.index = curIndex
        this.isPush = true
      }
    }
  }
}
</script>

<style lang="scss" scoped>
// 页面
.decoration-edit {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 0;
  background: #f7f8f9;
  height: calc(100vh - 200px);
  // 此处的height注意不能写成 height: calc(100vh-200px);注意中间的空格
  position: relative;
}
// 左侧拖拽区域
.l,
.r {
  width: 340px;
  height: 100%;
  padding: 15px 0;
  background: #fff;
}
.l {
  ul {
    margin: 0;
    padding: 0;
    li {
      width: 80px;
      height: 80px;
      display: flex;
      flex-direction: column;
      cursor: default;
      justify-content: center;
      align-items: center;
      list-style: none;
      font-size: 14px;
      color: #666;
      float: left;
      margin: 0 10px;
      border-radius: 6px;
      transition: all 0.3s;
      cursor: pointer;
      // & 表示在嵌套层次中回溯一层 此处:&:hover => li:hover
      &:hover {
        background: #efefef;
      }
      span {
        display: block;
        font-size: 40px;
        margin-bottom: 8px;
        color: #999;
      }
      p {
        display: block;
        margin: 0;
        font-size: 12px;
      }
    }
  }
}
.c {
  width: auto;
  // 子元素(包括content+padding+border+margin) 撑满这个父级元素的content区域
  // 子元素有 margin、border、padding时,会减去子元素content区域相对应的width值
  // 父元素的content = 子元素(content +padding + border + margin)
  max-width: 400px;
  position: relative;
  .top-nav {
    position: absolute;
    top: 0;
    background: #fff;
    z-index: 999;
    transition: all 0.3s;
    &* {
      // 表示top-nav下的所有元素
      pointer-events: none;
      // 表示能阻止点击、状态变化和鼠标指针变化
    }
    &:hover {
      transform: scale(0.95);
      // 默认为1 缩小为 0.95
      border-radius: 10px;
      overflow: hidden;
      box-shadow: 0 0 10px #afafaf;
    }
    .tit {
      position: absolute;
      left: 50%;
      bottom: 10px;
      transform: translateX(-50%);
    }
    img {
      max-width: 100%;
      image-rendering: -moz-crisp-edges;
      image-rendering: -o-crisp-edges;
      image-rendering: -webkit-optimize-contrast;
      image-rendering: crisp-edges;
      -ms-interpolation-mode: nearest-neighbor;
    }
  }
  .view-content {
    width: 400px;
    height: 700px;
    background: #f5f5f5;
    overflow-y: auto;
    overflow-x: hidden;
    padding-top: 72px;
    box-shadow: 0 2px 6px #ccc;
    &::-webkit-scrollbar {
      width: 6px;
    }
    &::-webkit-scrollbar-thumb {
      background: #dbdbdb;
    }
    &::-webkit-scrollbar-track {
      background: #f6f6f6;
    }
    .item {
      transition: all 0.3s;
      background: #fff;
      &:hover {
        transform: scale(0.95);
        border-radius: 10px;
        box-shadow: 0 0 10px #afafaf;
        .el-icon-error {
          display: block;
        }
      }
      div {
        pointer-events: none;
      }
      .wait {
        background: #deedff;
        height: 35px;
        text-align: center;
        line-height: 35px;
        font-size: 12px;
        color: #666;
      }
      .el-icon-error {
        position: absolute;
        right: -10px;
        top: -6px;
        color: red;
        font-size: 25px;
        cursor: pointer;
        display: none;
        z-index: 9999;
      }
    }
  }
}
</style>

总结:先定义三个组件,Product、Banner、Images,导入进来,根据拖拽的类型进行判断,动态渲染组件。

五、三中间视图组件(商品、图片、轮播,后续可以自己添加其他)

components目录下定义draggView文件,该目录底下存放中间区域组件。Banner.vue、Images.vue、Product.vue三个视图组件。
Product.vue

<template>
  <div class="product" :class="className">
    <!-- 商品有数据情况下 -->
    <template v-if="data.data && data.data.length > 0">
      <div class="product-item" v-for="(item, index) in data.data" :key="index">
        <div class="image">
          <img :src="item.productImg" alt="" />
        </div>
        <div class="info">
          <p class="name">{{ item.productName }}</p>
          <p class="num">{{ options.volumeStr ? item.volumeStr + '已购买' : '' }} {{ line }}{{ options.goodRatio ? item.goodRatio + '99%' : '' }}</p>
          <p class="price">
            <span>{{ item.productPrice }}</span>
            <span v-if="options.origonalPrice">{{ item.originalPrice }}</span>
          </p>
        </div>
      </div>
    </template>
    <!-- 商品静态未选择商品情况下 -->
    <template v-else>
      <div class="product-item product-default" v-for="index in 3" :key="index">
        <div class="image">
          <img src="https://img.quanminyanxuan.com/other/21188f7a1e9340759c113aa569f96699.jpg?x-oss-process=image/resize,h_600,m_lfit" alt="" />
        </div>
        <div class="info">
          <p class="name">这是商品名称</p>
          <p class="num">12124 已经购买 | 99%</p>
          <p class="price">
            <span>9.99</span>
            <span>9.99</span>
          </p>
        </div>
      </div>
    </template>
  </div>
</template>

<script>
export default {
  name: 'Product',
  props: ['data', 'className'],
  computed: {
    options() {
      return this.data.options
    },
    line() {
      return this.options.volumeStr && this.options.goodRatio ? ' | ' : ''
    }
  }
}
</script>

<style lang="scss" scoped>
.product {
  display: flex;
  flex-wrap: wrap;
  padding: 4px 8px;
  box-sizing: border-box;
  * {
    box-sizing: border-box;
  }
  &.one .product-item {
    width: 100%;
    padding: 10px;
    display: flex;
    border-bottom: 1px dashed #eee;
    .image {
      width: 100px;
      border-radius: 5px;
      overflow: hidden;
      margin-right: 10px;
    }
    .info {
      padding: 0 5px;
      display: flex;
      flex-direction: space-between;
      flex: 1;
      .price {
        font-size: 20px;
        margin: 0;
      }
      .num {
        margin: 12px 0 0;
      }
    }
  }
  &.three .product-item {
    width: 33.33%;
    .info .price {
      font-size: 16px;
    }
    &.product-default:nth-of-type(3) {
      display: block;
    }
  }
  .product-item {
    width: 50%;
    padding: 5px;
    &.product-default:nth-of-type(3) {
      display: none;
    }
    .image {
      font-size: 0;
      img {
        max-width: 100%;
      }
    }
    .info {
      padding: 10px 5px 0;
      .name {
        font-size: 14px;
        margin: 0;
        color: #333;
        text-overflow: ellipsis;
        word-break: break-all;
        display: -webkit-box;
        -webkit-line-clamp: 2;
        -webkit-box-orient: vertical;
        overflow: hidden;
        height: 38px;
        line-height: 18px;
      }
      .num {
        font-size: 12px;
        color: #d23000;
        font-weight: 600;
      }
      .price {
        font-weight: 600;
        margin: 12px 0 0;
        font-size: 18px;
        span:nth-of-type(1) {
          color: red;
        }
        span:nth-of-type(2) {
          color: #b5b5b5;
          font-weight: 400;
          font-size: 12px;
          margin-left: 4px;
          text-decoration: line-through;
        }
      }
    }
  }
}
</style>

在这里插入图片描述
Images和Banner的后续补充进来

六、右侧拖拽编辑表单内容

在components目录下新建draggEdit目录,该目录下新建index.vue、Product.vue、Images.vue和Info.vue目录。
index.vue 表示 右侧表单编辑父级组件。
Product.vue 表示商品编辑子组件。
Images.vue 表示图片和轮播的子组件。
Info.vue 便是页面头部编辑的子组件。
index.vue

<template>
  <section>
    <div class="tab-content">
      <h2>{{ type && list[type]['tit'] }}</h2>
      <div class="tab" v-if="type != 'info'">
        <span v-for="(val, key, index) in tabType" :key="index" @click="tab(key)" :class="{ active: val }">
          <i class="el-icon-s-data">{{ key }}</i>
        </span>
      </div>
    </div>
    <component :is="type && list[type]['com']" :data="data" @changeTab="tab"> </component>
  </section>
</template>

<script>
import Product from '@/components/draggEdit/Product.vue'
export default {
  name: 'EditForm',
  components: {
    Product
  },
  props: {
    data: {
      type: Object,
      default: () => {}
    }
  },
  data() {
    return {
      type: '',
      list: {
        info: {
          tit: '页面信息',
          com: 'Info'
        },
        images: {
          tit: '图片',
          com: 'Images'
        },
        banner: {
          tit: '轮播图',
          com: 'Images'
        },
        product: {
          tit: '商品',
          com: 'Product'
        }
      },
      tabType: {
        1: true,
        2: false,
        3: false
      }
    }
  },
  mounted() {
    console.log(this.data)
    console.log(this.data.type)
    this.type = this.data.type
    if (this.data.tabType) {
      // this.tab(this.data.tabType)
    }
  },
  methods: {
    tab(key) {
      for (const i in this.tabType) {
        if (key === i) {
          this.tabType[key] = true
          this.$set(this.data, 'tabType', key)
        } else {
          this.tabType[i] = false
        }
      }
    }
  }
}
</script>

<style lang="scss" scoped>
section {
  height: 100%;
  overflow: hidden;
  display: flex;
  flex-wrap: nowrap;
  flex-direction: column;
}
.tab-content {
  margin: 0 15px;
  h2 {
    font-size: 16px;
    color: #333;
  }
  .tab {
    display: flex;
    justify-content: space-around;
    border: 1px solid #ddd;
    border-radius: 6px;
    span {
      width: 33.33%;
      text-align: center;
      font-size: 14px;
      color: #666;
      display: block;
      height: 36px;
      line-height: 36px;
      cursor: pointer;
      &.active {
        color: #fff;
        background: #409eff;
        border-radius: 2px;
      }
      &:nth-of-type(2) {
        border-left: 1px solid #ddd;
        border-right: 1px solid #ddd;
      }
    }
  }
}
</style>

Product.vue

<template>
  <div class="product-content">
    <p class="tit">商品列表<span>(可拖动排序)</span></p>
    <el-button class="add-btn" type="primary" @click="toggleSearchPopup"><i class="el-icon-plus"></i>添加商品</el-button>
    <template v-if="list.data && list.data.length > 0">
      <vuedraggable v-model="list.data" tag="ul" draggable="li" v-if="list.data && list.data.length > 0" class="list">
        <li class="item" v-for="(item, index) in list.data" :key="index">
          <img :src="item.productImg" alt="" />
          <i class="el-icon-error" @click="deleteItem(index)"></i>
        </li>
      </vuedraggable>
    </template>
    <div class="options">
      <el-form label-width="80px">
        <template v-for="(key, val, index) in options">
          <el-form-item :label="key" :key="index" v-if="loadingOption">
            <el-switch v-model="list['options'][val]" :name="val" @change="optionsChange(val, $event)"></el-switch>
          </el-form-item>
        </template>
      </el-form>
    </div>
    <el-dialog title="添加商品" :visible.sync="show" @close="close">
      <el-form label-width="100px">
        <el-form-item label="选择商品">
          <el-select v-model="selectProduct" filterable remote reserve-keyword placeholder="请输入商品名称" :remote-method="searchProductList" @change="addProduct" :loading="loading">
            <el-option v-for="item in productList" :key="item.productId" :label="item.productName" :value-key="item.productName" :value="item"></el-option>
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" @click="confirm">确定</el-button>
        </el-form-item>
      </el-form>
    </el-dialog>
  </div>
</template>

<script>
import vuedraggable from 'vuedraggable'
export default {
  name: 'Product',
  components: {
    vuedraggable
  },
  props: {
    data: {
      type: Object,
      default: () => {}
    }
  },
  data() {
    return {
      list: {},
      productList: [],
      loading: false,
      show: false,
      selectItem: null,
      selectProduct: '',
      options: {
        originalPrice: '划线价',
        goodRatio: '好评率',
        volumeStr: '销售量'
      },
      loadingOption: false
    }
  },
  mounted() {
    this.list = this.data
    if (!this.data.tabType) {
      this.$emit('changeTab', 2)
    }
    // 默认开启所有选项
    for (const key in this.options) {
      if (this.data.options[key] === undefined) {
        this.$set(this.list.options, key, true)
      }
      this.loadingOption = true
    }
  },
  methods: {
    optionsChange(key, result) {
      this.$set(this.list.options, key, result)
    },
    deleteItem(index) {
      this.list.data.splice(index, 1)
    },
    // 搜索商品
    searchProductList(productName) {
      this.productList = productList
    },
    confirm() {
      this.list.data.push(this.selectItem)
      this.close()
    },
    toggleSearchPopup() {
      this.show = true
    },
    close() {
      this.show = false
      this.selectItem = null
      this.selectProduct = ''
    },
    addProduct(data) {
      this.selectItem = data
    }
  }
}
// 模拟产品列表
var productList = [
  {
    productId: 3601,
    productName: '驼大大新疆正宗骆驼奶粉初乳骆驼乳粉蓝罐礼盒装120g*4罐',
    productImg: 'https://img.quanminyanxuan.com/excel/f6860885547648d9996474bbf21fdca9.jpg',
    productPrice: 299,
    originalPrice: 598,
    volumeStr: '741',
    goodRatio: 98
  },
  {
    productId: 3268,
    productName: '百合28件套新骨质瓷餐具',
    productImg: 'https://img.quanminyanxuan.com/excel/185e7365f65543f2b4ebc67036d6a78f.jpg',
    productPrice: 370,
    originalPrice: 1388,
    volumeStr: '400',
    goodRatio: 99
  },
  {
    productId: 3343,
    productName: '和商臻品槐花蜜250克/瓶',
    productImg: 'https://img.quanminyanxuan.com/excel/4626c8c628d04935b0262d04991416b2.jpg',
    productPrice: 34.5,
    originalPrice: 72,
    volumeStr: '258',
    goodRatio: 98
  },
  {
    productId: 3330,
    productName: '鲍参翅肚浓羹350g袋装',
    productImg: 'https://img.quanminyanxuan.com/excel/58a0c968dc7d42c3ac21e09d1862aa6f.jpg',
    productPrice: 75,
    originalPrice: 128,
    volumeStr: '258',
    goodRatio: 98
  }
]
</script>

<style lang="scss" scoped>
.product-content {
  .tit {
    text-align: center;
    font-size: 12px;
    color: #666;
    margin: 18px 0;
    padding-bottom: 10px;
    border-bottom: 1px dashed #ddd;
  }
  .add-btn {
    width: calc(100% - 30px);
    height: 34px;
    line-height: 34px;
    padding: 0;
    font-size: 12px;
    margin-left: 15px;
    margin-top: 5px;
  }
  .list {
    display: flex;
    flex-wrap: wrap;
    padding: 12px;
    margin: 0;
    .item {
      width: 70px;
      height: 70px;
      border-radius: 6px;
      margin: 4px;
      position: relative;
      transition: all 0.3s;
      list-style: none;
      img {
        width: 100%;
        height: 100%;
        border-radius: 4px;
      }
      i {
        position: absolute;
        top: -6px;
        right: -6px;
        cursor: pointer;
        opacity: 0;
        transition: all 0.3s;
        color: red;
      }
      &::before {
        content: '';
        height: 100%;
        width: 100%;
        position: absolute;
        top: 0;
        right: 0;
        background: rgba(0, 0, 0, 0.4);
        border-radius: 4px;
        opacity: 0;
        transition: all 0.3s;
      }
      &:hover {
        cursor: grab;
        &::before,
        i {
          opacity: 1;
        }
      }
    }
  }
  .options {
    padding: 15px;
    border-radius: 6px;
    .el-form {
      background: #f7f8f9;
      overflow: hidden;
      padding: 10px 0;
      .el-form-item {
        margin: 0;
        label {
          font-size: 12px;
        }
      }
    }
  }
}
</style>

Info.vue

<template>
  <div class="info-content">
    <el-form label-width="80px">
      <el-form-item label="页面标题">
        <el-input v-model="list.title"></el-input>
      </el-form-item>
      <el-form-item label="页面备注">
        <el-input type="textarea" :rows="4" v-model="list.remarks"></el-input>
      </el-form-item>
      <el-form-item label="页面背景">
        <el-color-picker v-model="list.backgroundColor" show-alpha></el-color-picker>
      </el-form-item>
    </el-form>
  </div>
</template>

<script>
export default {
  name: 'Info',
  props: ['data', 'className'],
  data() {
    return {
      list: {}
    }
  },
  mounted() {
    this.list = this.data
  }
}
</script>

<style lang="scss" scoped></style>

至此,实现效果如下图:
在这里插入图片描述
在这里插入图片描述

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐