Skip to content

v-drag

简介

v-drag 指令用于创建可拖动的元素,支持设置范围、设置回掉函数。增强版本现已支持列表拖拽排序和拖拽把手功能。

基本用法

通过给元素添加 v-drag 指令来创建可拖动的元素。

查看代码
vue
<script setup lang="ts">
import { vDrag } from '@cp-vuedir/core'
</script>

<template>
  <div class="drag_box">
    <a-button v-drag type="primary" shape="round">Drag me</a-button>
    <a-button v-drag type="primary" shape="round" style="transform: translate(120%, 120%)"> Drag me </a-button>
  </div>
</template>

<style scoped>
.drag_box {
  padding: 10px;
  height: 40vh;
  border: 2px dashed var(--vp-c-divider);
  border-radius: 8px;
  background-color: var(--vp-c-bg);
}
</style>

设置回调函数

通过给 v-drag 指令支持startDrag, onDrag, endDrag三个回调函数,分别在拖拽开始,拖拽中,拖拽结束时触发。

startNum: 0

dragNum: 0

endNum: 0

查看代码
vue
<template>
  <div class="drag_box" ref="containerRef">
    <a-button
      v-drag="{
        startDrag: startDragFn,
        onDrag: {
          event: onDargFn
        },
        endDrag: endDragFn
      }"
      type="primary"
      shape="round"
    >
      Drag me
    </a-button>
  </div>
  <div class="info_box">
    <p>startNum: {{ demoInfo.startNum }}</p>
    <p>dragNum: {{ demoInfo.dragNum }}</p>
    <p>endNum: {{ demoInfo.endNum }}</p>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { vDrag } from '@cp-vuedir/core'

const containerRef = ref<HTMLElement | null>(null)
const demoInfo = ref<{
  startNum: number
  endNum: number
  dragNum: number
}>({
  startNum: 0,
  endNum: 0,
  dragNum: 0
})

function startDragFn() {
  demoInfo.value.startNum += 1
}

function onDargFn() {
  demoInfo.value.dragNum += 1
}

function endDragFn() {
  demoInfo.value.endNum += 1
}
</script>

<style scoped>
.drag_box {
  padding: 10px;
  height: 40vh;
  border: 2px dashed var(--vp-c-divider);
  border-radius: 8px;
  background-color: var(--vp-c-bg);
}

.info_box {
  margin-top: 10px;
  padding: 10px;
  border: 2px solid var(--vp-c-divider);
  border-radius: 8px;
  background-color: var(--vp-c-bg);
}

.info_box p {
  margin: 0;
}
</style>

增强功能

最新版本的 v-drag 指令增加了以下功能:

基础拖拽增强

支持更多拖拽选项,如方向限制和边界约束等。

基础拖拽增强演示

拖动我
可任意方向拖动
限制在父容器内
查看代码
vue
<template>
  <div class="basic-drag-container">
    <h3>基础拖拽增强演示</h3>

    <div class="drag-options">
      <div class="option-group">
        <label>拖拽方向限制:</label>
        <select v-model="axisOption">
          <option value="both">两个方向 (x和y)</option>
          <option value="x">仅水平方向 (x)</option>
          <option value="y">仅垂直方向 (y)</option>
        </select>
      </div>

      <div class="option-group">
        <label>边界限制:</label>
        <select v-model="boundsOption">
          <option value="none">无限制</option>
          <option value="parent">父容器内</option>
        </select>
      </div>
    </div>

    <div class="drag-playground" ref="playgroundRef">
      <div
        class="drag-element"
        v-drag="{
          axis: axisOption,
          bounds: boundsOption === 'parent' ? 'parent' : null,
          startDrag: onStartDrag
        }"
        ref="dragElement"
      >
        <div class="drag-content">
          <div class="drag-title">拖动我</div>
          <div class="drag-info">
            {{ axisOption === 'both' ? '可任意方向拖动' : axisOption === 'x' ? '只能水平拖动' : '只能垂直拖动' }}
            <br />
            {{ boundsOption === 'parent' ? '限制在父容器内' : '无边界限制' }}
          </div>
        </div>
      </div>
    </div>

    <div class="controls">
      <button @click="resetPosition">重置位置</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue'

// 定义拖拽选项类型
const axisOption = ref<'both' | 'x' | 'y'>('both')
const boundsOption = ref<'none' | 'parent'>('parent')

// 获取DOM引用
const playgroundRef = ref<HTMLElement | null>(null)
const dragElement = ref<HTMLElement | null>(null)

function resetPosition() {
  if (dragElement.value && playgroundRef.value) {
    // 确保元素处于 absolute 定位
    dragElement.value.style.position = 'absolute'

    // 计算居中位置
    const playgroundRect = playgroundRef.value.getBoundingClientRect()
    const elementRect = dragElement.value.getBoundingClientRect()

    const left = (playgroundRect.width - elementRect.width) / 2
    const top = (playgroundRect.height - elementRect.height) / 2

    // 直接设置实际位置,而不是使用 transform
    dragElement.value.style.left = `${left}px`
    dragElement.value.style.top = `${top}px`

    // 清除可能影响定位的其他样式
    dragElement.value.style.transform = ''
    dragElement.value.style.margin = '0'
  }
}

function onStartDrag() {
  // 拖拽开始时确保定位方式正确
  if (dragElement.value) {
    dragElement.value.style.position = 'absolute'
  }
}

// 在组件挂载后延迟设置位置,确保 DOM 渲染完毕
onMounted(() => {
  nextTick(() => {
    setTimeout(() => {
      resetPosition()
    }, 100)
  })
})

// 监听 boundsOption 改变时重置位置,确保边界限制生效
watch(boundsOption, () => {
  nextTick(() => {
    setTimeout(() => {
      resetPosition()
    }, 50)
  })
})
</script>

<style scoped>
.basic-drag-container {
  background-color: var(--vp-c-bg-soft);
  border-radius: 8px;
  padding: 20px;
  margin-bottom: 30px;
  border: 1px solid var(--vp-c-divider);
  transition:
    background-color 0.3s,
    border-color 0.3s;
}

h3 {
  margin-top: 0;
  color: var(--vp-c-text-1);
}

.drag-options {
  display: flex;
  gap: 20px;
  margin-bottom: 20px;
  flex-wrap: wrap;
}

.option-group {
  display: flex;
  align-items: center;
  gap: 10px;
}

.option-group label {
  font-weight: 500;
  color: var(--vp-c-text-2);
}

.option-group select {
  padding: 6px 10px;
  border-radius: 4px;
  border: 1px solid var(--vp-c-divider);
  background-color: var(--vp-c-bg);
  color: var(--vp-c-text-1);
}

.drag-playground {
  position: relative;
  height: 300px;
  background-color: var(--vp-c-bg);
  border: 1px dashed var(--vp-c-divider);
  border-radius: 6px;
  margin-bottom: 15px;
  overflow: hidden;
}

.drag-element {
  /* 确保使用绝对定位,而非 transform 定位 */
  position: absolute;
  width: 150px;
  height: 150px;
  background-color: var(--vp-c-brand);
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  color: white;
  display: flex;
  justify-content: center;
  align-items: center;
  text-align: center;
  user-select: none;
  transition:
    background-color 0.3s,
    box-shadow 0.3s;
  cursor: move;
  /* 使用实际边距替代 transform */
  margin: 0;
}

.drag-element:hover {
  background-color: var(--vp-c-brand-dark);
}

.drag-element:active {
  background-color: var(--vp-c-brand-darker);
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.drag-content {
  padding: 15px;
  width: 100%;
}

.drag-title {
  font-size: 18px;
  font-weight: bold;
  margin-bottom: 10px;
  color: white;
}

.drag-info {
  font-size: 13px;
  opacity: 0.9;
  line-height: 1.5;
  color: white;
}

.controls {
  display: flex;
  justify-content: center;
}

.controls button {
  background-color: var(--vp-c-brand);
  color: white;
  border: none;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: background-color 0.2s;
}

.controls button:hover {
  background-color: var(--vp-c-brand-dark);
}
</style>

列表拖拽排序

允许容器内的元素通过拖拽交互重新排序,非常适合创建可排序的列表、表格和看板等。

可排序列表

拖动列表项可以重新排序

当前排序:

查看代码
vue
<template>
  <div class="list-container">
    <h3>可排序列表</h3>
    <div class="instruction">拖动列表项可以重新排序</div>

    <div class="debug-info" v-if="showDebug">
      <p>最后一次排序: {{ lastSortInfo }}</p>
      <p>当前数据顺序: {{ items.map((item) => item.text).join(', ') }}</p>
    </div>

    <ul class="sort-list">
      <li
        v-for="(item, index) in items"
        :key="item.id"
        class="list-item"
        v-drag="{
          isList: true,
          onSort: handleSort
        }"
      >
        <div class="item-content">
          <span class="item-number">{{ index + 1 }}</span>
          <span class="item-text">{{ item.text }}</span>
        </div>
      </li>
    </ul>

    <div class="result">
      <h4>当前排序:</h4>
      <div class="order-info">{{ items.map((item) => item.text).join(' → ') }}</div>
      <div class="controls">
        <button class="reset-btn" @click="resetItems">重置顺序</button>
        <button class="debug-btn" @click="toggleDebug">{{ showDebug ? '隐藏调试' : '显示调试' }}</button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'

interface Item {
  id: number
  text: string
}

// 原始数据
const originalItems: Item[] = [
  { id: 1, text: '项目 1' },
  { id: 2, text: '项目 2' },
  { id: 3, text: '项目 3' },
  { id: 4, text: '项目 4' },
  { id: 5, text: '项目 5' }
]

// 响应式数据
const items = ref<Item[]>([])
const lastSortInfo = ref<string>('无')
const showDebug = ref<boolean>(false)

// 排序处理函数
function handleSort({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) {
  console.log(`排序: 从 ${oldIndex} 到 ${newIndex}`)
  lastSortInfo.value = `从位置 ${oldIndex + 1} 到位置 ${newIndex + 1}`

  // 检查索引合法性
  if (oldIndex >= 0 && newIndex >= 0 && oldIndex < items.value.length && newIndex <= items.value.length) {
    // 移除旧项
    const item = items.value.splice(oldIndex, 1)[0]
    // 插入到新位置
    items.value.splice(newIndex, 0, item)
    console.log(
      '排序后的数据:',
      items.value.map((item) => item.text)
    )
  } else {
    console.warn('排序索引超出范围:', oldIndex, newIndex, items.value.length)
  }
}

// 重置数据顺序
function resetItems() {
  // 使用 JSON 深拷贝原始数据
  items.value = JSON.parse(JSON.stringify(originalItems))
  lastSortInfo.value = '无'
}

// 切换调试面板显示
function toggleDebug() {
  showDebug.value = !showDebug.value
}

// 模拟 created 钩子,在组件挂载后重置数据
onMounted(() => {
  resetItems()
})

// 为调试和 DevTools 定义组件名称
defineOptions({
  name: 'ListSort'
})
</script>

<style scoped>
.list-container {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
  background-color: var(--vp-c-bg-soft);
  border-radius: 8px;
  border: 1px solid var(--vp-c-divider);
  transition:
    background-color 0.3s,
    border-color 0.3s;
}

h3 {
  margin-top: 0;
  color: var(--vp-c-text-1);
  font-size: 18px;
}

.instruction {
  color: var(--vp-c-text-2);
  margin-bottom: 15px;
  font-size: 14px;
  font-style: italic;
}

.sort-list {
  list-style-type: none;
  padding: 0;
  margin: 0;
  position: relative;
  min-height: 250px;
  border: 1px solid var(--vp-c-divider);
  border-radius: 4px;
  background-color: var(--vp-c-bg);
}

.list-item {
  background-color: var(--vp-c-bg);
  margin-bottom: 8px;
  padding: 12px 15px;
  border-radius: 4px;
  cursor: move;
  user-select: none;
  border: 1px solid var(--vp-c-divider);
  transition:
    transform 0.15s,
    box-shadow 0.15s,
    background-color 0.15s;
}

.list-item:hover {
  box-shadow: 0 2px 5px var(--vp-c-divider);
  background-color: var(--vp-c-bg-soft);
}

/* 拖拽中的样式 */
.list-item[data-dragging='true'] {
  background-color: var(--vp-c-brand-dimm);
  border-color: var(--vp-c-brand);
  transform: scale(1.02);
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
  color: var(--vp-c-brand);
  z-index: 100;
}

.item-content {
  display: flex;
  align-items: center;
}

.item-number {
  display: inline-block;
  width: 28px;
  height: 28px;
  line-height: 28px;
  text-align: center;
  background-color: var(--vp-c-brand);
  color: white;
  border-radius: 50%;
  margin-right: 15px;
  font-weight: bold;
}

.item-text {
  font-size: 16px;
  color: var(--vp-c-text-1);
}

.result {
  margin-top: 25px;
  padding: 15px;
  background-color: var(--vp-c-brand-dimm);
  border-radius: 4px;
  border-left: 4px solid var(--vp-c-brand);
}

.result h4 {
  margin-top: 0;
  color: var(--vp-c-brand-dark);
  font-size: 16px;
}

.order-info {
  font-family: monospace;
  padding: 8px;
  background-color: var(--vp-c-bg);
  border-radius: 4px;
  margin-bottom: 10px;
  color: var(--vp-c-text-1);
}

.reset-btn {
  background-color: var(--vp-c-brand);
  color: white;
  border: none;
  padding: 8px 15px;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.reset-btn:hover {
  background-color: var(--vp-c-brand-dark);
}

/* 占位符样式增强 */
:deep(.v-drag-placeholder) {
  border: 2px dashed var(--vp-c-brand) !important;
  background-color: var(--vp-c-brand-dimm) !important;
  box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05) !important;
  transition: all 0.15s ease;
  animation: pulse 1.5s infinite;
  margin-bottom: 8px !important;
  height: 52px !important;
  box-sizing: border-box;
  border-radius: 4px !important;
}

@keyframes pulse {
  0% {
    opacity: 0.5;
  }
  50% {
    opacity: 0.8;
  }
  100% {
    opacity: 0.5;
  }
}

.debug-info {
  background-color: var(--vp-c-yellow-dimm);
  border-left: 4px solid var(--vp-c-yellow);
  padding: 10px;
  margin-bottom: 10px;
  font-family: monospace;
  font-size: 14px;
  color: var(--vp-c-text-1);
}

.controls {
  display: flex;
  gap: 10px;
}

.debug-btn {
  background-color: var(--vp-c-text-3);
  color: var(--vp-c-bg);
  border: none;
  padding: 8px 15px;
  border-radius: 4px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.debug-btn:hover {
  background-color: var(--vp-c-text-2);
}

/* 确保占位符有正确的样式 */
:deep(.v-drag-placeholder) {
  margin: 8px 0;
  height: 52px;
  box-sizing: border-box;
}
</style>

拖拽把手

可指定元素的某部分作为拖拽触发区域,使界面更加直观和专业。

拖拽把手示例

拖拽把手示例

点击左侧图标拖动整张卡片。点击此文本区域不会触发拖动。

点此拖动
-×

窗口风格

这个卡片模拟了一个窗口,你可以通过顶部栏拖动它。

标准拖拽(无把手)

对照组:点击卡片的任何位置均可拖动整个卡片。

使用 handle 选项可以指定拖拽把手元素,只有当用户与该元素交互时才能触发拖拽。

v-drag="{ handle: '.card-handle' }"
查看代码
vue
<template>
  <div class="handle-demo-container">
    <h3>拖拽把手示例</h3>
    <div class="cards-container">
      <!-- 卡片 1:使用图标作为拖拽把手 -->
      <div class="card" v-drag="{ handle: '.card-handle' }">
        <div class="card-handle">
          <svg class="handle-icon" viewBox="0 0 24 24">
            <path d="M8,18h8v-2H8V18z M8,14h8v-2H8V14z M8,10h8V8H8V10z M4,22V2h16v20H4z" />
          </svg>
        </div>
        <div class="card-content">
          <h4>拖拽把手示例</h4>
          <p>点击左侧图标拖动整张卡片。点击此文本区域不会触发拖动。</p>
        </div>
      </div>

      <!-- 卡片 2:使用顶部栏作为拖拽把手 -->
      <div class="card" v-drag="{ handle: '.card-header' }">
        <div class="card-header">
          <span>点此拖动</span>
          <div class="header-controls">
            <span class="control">-</span>
            <span class="control">□</span>
            <span class="control">×</span>
          </div>
        </div>
        <div class="card-content">
          <h4>窗口风格</h4>
          <p>这个卡片模拟了一个窗口,你可以通过顶部栏拖动它。</p>
        </div>
      </div>

      <!-- 卡片 3:无把手的对照组 -->
      <div class="card" v-drag>
        <div class="card-content full-width">
          <h4>标准拖拽(无把手)</h4>
          <p>对照组:点击卡片的任何位置均可拖动整个卡片。</p>
        </div>
      </div>
    </div>

    <div class="info-panel">
      <p>使用 <code>handle</code> 选项可以指定拖拽把手元素,只有当用户与该元素交互时才能触发拖拽。</p>
      <pre><code>v-drag="{ handle: '.card-handle' }"</code></pre>
    </div>
  </div>
</template>

<script setup lang="ts">
defineOptions({
  name: 'DragHandle'
})
</script>

<style scoped>
.handle-demo-container {
  padding: 20px;
  background-color: var(--vp-c-bg-soft);
  border-radius: 8px;
  border: 1px solid var(--vp-c-divider);
  transition:
    background-color 0.3s,
    border-color 0.3s;
}

h3 {
  margin-top: 0;
  margin-bottom: 15px;
  color: var(--vp-c-text-1);
}

.cards-container {
  position: relative;
  height: 400px;
  background-color: var(--vp-c-bg);
  border-radius: 6px;
  padding: 20px;
  overflow: hidden;
  border: 1px solid var(--vp-c-divider);
}

.card {
  position: absolute;
  width: 300px;
  background-color: var(--vp-c-bg);
  border-radius: 6px;
  box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
  overflow: hidden;
  display: flex;
  border: 1px solid var(--vp-c-divider);
}

.card:nth-child(1) {
  top: 30px;
  left: 40px;
  height: 150px;
}

.card:nth-child(2) {
  top: 60px;
  left: 380px;
  height: 150px;
}

.card:nth-child(3) {
  top: 240px;
  left: 210px;
  height: 120px;
}

.card-handle {
  width: 40px;
  height: 100%;
  background-color: var(--vp-c-bg-soft);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: grab;
  border-right: 1px solid var(--vp-c-divider);
}

.handle-icon {
  width: 24px;
  height: 24px;
  fill: var(--vp-c-text-3);
}

.card-header {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 36px;
  background-color: var(--vp-c-bg-soft);
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 10px;
  border-bottom: 1px solid var(--vp-c-divider);
  cursor: grab;
}

.header-controls {
  display: flex;
  gap: 8px;
}

.control {
  width: 16px;
  height: 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-size: 14px;
  color: var(--vp-c-text-3);
}

.card-content {
  padding: 15px;
  flex-grow: 1;
}

.card-content.full-width {
  width: 100%;
}

.card:nth-child(2) .card-content {
  margin-top: 36px;
}

.card h4 {
  margin-top: 0;
  margin-bottom: 10px;
  font-size: 16px;
  color: var(--vp-c-text-1);
}

.card p {
  margin: 0;
  font-size: 14px;
  color: var(--vp-c-text-2);
  line-height: 1.4;
}

.info-panel {
  margin-top: 20px;
  padding: 15px;
  background-color: var(--vp-c-brand-dimm);
  border-radius: 4px;
  border-left: 4px solid var(--vp-c-brand);
}

.info-panel code {
  background-color: var(--vp-c-bg);
  padding: 2px 4px;
  border-radius: 3px;
  font-family: monospace;
  color: var(--vp-c-brand);
}

.info-panel pre {
  margin: 10px 0 0;
  background-color: var(--vp-c-bg);
  padding: 10px;
  border-radius: 4px;
  overflow-x: auto;
  color: var(--vp-c-text-1);
}
</style>

综合实例:任务看板

结合列表排序和拖拽把手功能,实现一个简单的任务看板。

任务看板(综合示例)

待办任务
  • 设计登录页面
    为新应用设计登录界面和注册表单
    #101
  • 实现数据验证
    对所有用户输入添加表单验证功能
    #102
  • 编写单元测试
    为认证模块编写单元测试
    #103
进行中
  • 重构API模块
    使用新的请求库重构API请求模块
    #201
  • 优化图片加载
    添加图片懒加载和渐进式加载功能
    #202
已完成
  • 修复导航栏bug
    解决移动端导航栏折叠问题
    #301
  • 更新依赖包
    更新所有npm包到最新版本
    #302
  • 添加深色模式
    实现应用的深色模式切换功能
    #303

功能说明

  • 通过左侧拖拽把手可以重新排序每一列中的任务卡片
  • 仅点击卡片左侧带有线条图标的区域才能拖动卡片
  • 每列内的任务可以独立排序
  • 实现了重排时的视觉反馈和平滑动画

此示例展示了 v-drag 指令的列表排序和拖拽把手功能的结合使用。

查看代码
vue
<template>
  <div class="task-board-container">
    <h3>任务看板(综合示例)</h3>
    <div class="board-columns">
      <!-- 待办任务列 -->
      <div class="board-column">
        <div class="column-header">待办任务</div>
        <ul class="task-list">
          <li
            v-for="(task, index) in todoTasks"
            :key="task.id"
            class="task-card"
            v-drag="{
              isList: true,
              handle: '.task-handle',
              onSort: (event) => handleSort('todo', event)
            }"
          >
            <div class="task-handle">
              <svg class="handle-icon" viewBox="0 0 24 24">
                <path d="M3,15h18v-2H3V15z M3,19h18v-2H3V19z M3,11h18V9H3V11z M3,5v2h18V5H3z" />
              </svg>
            </div>
            <div class="task-content">
              <div class="task-title">{{ task.title }}</div>
              <div class="task-description">{{ task.description }}</div>
              <div class="task-meta">
                <span class="priority" :class="'priority-' + task.priority">{{ priorityLabels[task.priority] }}</span>
                <span class="task-id">#{{ task.id }}</span>
              </div>
            </div>
          </li>
        </ul>
      </div>

      <!-- 进行中任务列 -->
      <div class="board-column">
        <div class="column-header">进行中</div>
        <ul class="task-list">
          <li
            v-for="(task, index) in inProgressTasks"
            :key="task.id"
            class="task-card"
            v-drag="{
              isList: true,
              handle: '.task-handle',
              onSort: (event) => handleSort('inProgress', event)
            }"
          >
            <div class="task-handle">
              <svg class="handle-icon" viewBox="0 0 24 24">
                <path d="M3,15h18v-2H3V15z M3,19h18v-2H3V19z M3,11h18V9H3V11z M3,5v2h18V5H3z" />
              </svg>
            </div>
            <div class="task-content">
              <div class="task-title">{{ task.title }}</div>
              <div class="task-description">{{ task.description }}</div>
              <div class="task-meta">
                <span class="priority" :class="'priority-' + task.priority">{{ priorityLabels[task.priority] }}</span>
                <span class="task-id">#{{ task.id }}</span>
              </div>
            </div>
          </li>
        </ul>
      </div>

      <!-- 已完成任务列 -->
      <div class="board-column">
        <div class="column-header">已完成</div>
        <ul class="task-list">
          <li
            v-for="(task, index) in doneTasks"
            :key="task.id"
            class="task-card"
            v-drag="{
              isList: true,
              handle: '.task-handle',
              onSort: (event) => handleSort('done', event)
            }"
          >
            <div class="task-handle">
              <svg class="handle-icon" viewBox="0 0 24 24">
                <path d="M3,15h18v-2H3V15z M3,19h18v-2H3V19z M3,11h18V9H3V11z M3,5v2h18V5H3z" />
              </svg>
            </div>
            <div class="task-content">
              <div class="task-title">{{ task.title }}</div>
              <div class="task-description">{{ task.description }}</div>
              <div class="task-meta">
                <span class="priority" :class="'priority-' + task.priority">{{ priorityLabels[task.priority] }}</span>
                <span class="task-id">#{{ task.id }}</span>
              </div>
            </div>
          </li>
        </ul>
      </div>
    </div>

    <div class="feature-description">
      <h4>功能说明</h4>
      <ul>
        <li>通过左侧拖拽把手可以重新排序每一列中的任务卡片</li>
        <li>仅点击卡片左侧带有线条图标的区域才能拖动卡片</li>
        <li>每列内的任务可以独立排序</li>
        <li>实现了重排时的视觉反馈和平滑动画</li>
      </ul>
      <p>此示例展示了 v-drag 指令的列表排序和拖拽把手功能的结合使用。</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, defineOptions } from 'vue'

interface Task {
  id: number
  title: string
  description: string
  priority: 'high' | 'medium' | 'low'
}

const priorityLabels = {
  high: '高',
  medium: '中',
  low: '低'
}

const todoTasks = ref<Task[]>([
  { id: 101, title: '设计登录页面', description: '为新应用设计登录界面和注册表单', priority: 'high' },
  { id: 102, title: '实现数据验证', description: '对所有用户输入添加表单验证功能', priority: 'medium' },
  { id: 103, title: '编写单元测试', description: '为认证模块编写单元测试', priority: 'low' }
])

const inProgressTasks = ref<Task[]>([
  { id: 201, title: '重构API模块', description: '使用新的请求库重构API请求模块', priority: 'medium' },
  { id: 202, title: '优化图片加载', description: '添加图片懒加载和渐进式加载功能', priority: 'high' }
])

const doneTasks = ref<Task[]>([
  { id: 301, title: '修复导航栏bug', description: '解决移动端导航栏折叠问题', priority: 'high' },
  { id: 302, title: '更新依赖包', description: '更新所有npm包到最新版本', priority: 'low' },
  { id: 303, title: '添加深色模式', description: '实现应用的深色模式切换功能', priority: 'medium' }
])

function handleSort(columnType: 'todo' | 'inProgress' | 'done', event: { oldIndex: number; newIndex: number }) {
  let targetList: Task[] = []
  if (columnType === 'todo') {
    targetList = todoTasks.value
  } else if (columnType === 'inProgress') {
    targetList = inProgressTasks.value
  } else if (columnType === 'done') {
    targetList = doneTasks.value
  }

  const { oldIndex, newIndex } = event
  if (
    oldIndex !== newIndex &&
    oldIndex >= 0 &&
    newIndex >= 0 &&
    oldIndex < targetList.length &&
    newIndex <= targetList.length
  ) {
    const [movedItem] = targetList.splice(oldIndex, 1)
    targetList.splice(newIndex, 0, movedItem)
  }
}

defineOptions({
  name: 'TaskBoard'
})
</script>

<style scoped>
.task-board-container {
  padding: 20px;
  background-color: var(--vp-c-bg-soft);
  border-radius: 8px;
  border: 1px solid var(--vp-c-divider);
  transition:
    background-color 0.3s,
    border-color 0.3s;
}

h3 {
  margin-top: 0;
  margin-bottom: 20px;
  color: var(--vp-c-text-1);
}

.board-columns {
  display: flex;
  gap: 20px;
  margin-bottom: 25px;
  flex-wrap: wrap;
}

.board-column {
  flex: 1;
  background-color: var(--vp-c-bg);
  border-radius: 6px;
  min-width: 250px;
  max-width: 330px;
  display: flex;
  flex-direction: column;
  box-shadow: 0 2px 4px var(--vp-c-divider);
  border: 1px solid var(--vp-c-divider);
}

.column-header {
  padding: 12px 15px;
  font-size: 16px;
  font-weight: 600;
  color: var(--vp-c-text-1);
  background-color: var(--vp-c-bg-soft);
  border-radius: 6px 6px 0 0;
  border-bottom: 1px solid var(--vp-c-divider);
}

.task-list {
  list-style-type: none;
  padding: 10px;
  margin: 0;
  flex-grow: 1;
  min-height: 200px;
  position: relative;
}

.task-card {
  background-color: var(--vp-c-bg);
  border-radius: 4px;
  margin-bottom: 10px;
  box-shadow: 0 1px 3px var(--vp-c-divider);
  display: flex;
  overflow: hidden;
  transition:
    transform 0.15s,
    box-shadow 0.15s;
  border: 1px solid var(--vp-c-divider);
}

.task-card[data-dragging='true'] {
  z-index: 100;
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
  opacity: 0.9;
  background-color: var(--vp-c-brand-dimm);
  border-color: var(--vp-c-brand);
}

:deep(.v-drag-placeholder) {
  opacity: 0.7;
  border: 2px dashed var(--vp-c-brand) !important;
  background-color: var(--vp-c-brand-dimm) !important;
  box-shadow: none !important;
}

.task-handle {
  width: 40px;
  background-color: var(--vp-c-bg-soft);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: grab;
  border-right: 1px solid var(--vp-c-divider);
}

.handle-icon {
  width: 24px;
  height: 24px;
  fill: var(--vp-c-text-3);
}

.task-content {
  padding: 12px;
  flex-grow: 1;
}

.task-title {
  font-weight: 600;
  font-size: 15px;
  margin-bottom: 6px;
  color: var(--vp-c-text-1);
}

.task-description {
  font-size: 13px;
  color: var(--vp-c-text-2);
  margin-bottom: 10px;
  line-height: 1.4;
}

.task-meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.priority {
  font-size: 12px;
  padding: 2px 6px;
  border-radius: 3px;
  font-weight: 500;
}

.priority-high {
  background-color: var(--vp-c-danger-dimm);
  color: var(--vp-c-danger);
}

.priority-medium {
  background-color: var(--vp-c-warning-dimm);
  color: var(--vp-c-warning);
}

.priority-low {
  background-color: var(--vp-c-info-dimm);
  color: var(--vp-c-info);
}

.task-id {
  font-size: 12px;
  color: var(--vp-c-text-3);
}

.feature-description {
  background-color: var(--vp-c-info-dimm);
  border-radius: 6px;
  padding: 15px;
  border-left: 4px solid var(--vp-c-info);
  margin-top: 15px;
}

.feature-description h4 {
  margin-top: 0;
  font-size: 16px;
  color: var(--vp-c-info);
}

.feature-description ul {
  margin-bottom: 10px;
}

.feature-description li {
  margin-bottom: 5px;
  font-size: 14px;
  color: var(--vp-c-text-1);
}

.feature-description p {
  margin-bottom: 0;
  font-size: 14px;
  color: var(--vp-c-text-2);
}

.v-drag-placeholder {
  opacity: 0.5;
  background-color: #e0f2fe !important;
  border: 1px dashed #0ea5e9 !important;
}
</style>

使用示例

列表排序示例

vue
<template>
  <ul class="sort-list">
    <li
      v-for="(item, index) in items"
      :key="item.id"
      v-drag="{
        isList: true,
        onSort: handleSort
      }"
    >
      {{ item.text }}
    </li>
  </ul>
</template>

<script>
export default {
  methods: {
    handleSort({ oldIndex, newIndex }) {
      // 移动元素
      const item = this.items.splice(oldIndex, 1)[0]
      this.items.splice(newIndex, 0, item)
    }
  }
}
</script>

拖拽把手示例

vue
<template>
  <div class="card" v-drag="{ handle: '.card-handle' }">
    <div class="card-handle">
      <!-- 拖拽把手图标 -->
    </div>
    <div class="card-content">
      <!-- 卡片内容,点击此区域不会触发拖动 -->
    </div>
  </div>
</template>

API

属性名说明类型是否必选默认值
startDrag
拖拽开始时触发的回掉函数
Function
null
onDrag
拖拽中触发的回掉函数
Function
null
endDrag
拖拽结束时触发的回掉函数
Function
null
isList
是否启用列表拖拽排序功能
Boolean
false
onSort
列表排序完成时的回调函数,接收 oldIndex 和 newIndex 参数
Function
null
handle
拖拽把手的CSS选择器,指定后只有点击该元素才能触发拖拽
String
null
axis
限制拖拽方向,可选值为 'x'、'y'、'both'
String
both
bounds
限制拖拽边界,可设置为 'parent' 或一个HTML元素
String|HTMLElement
null

注意事项

注意

  • 请在配置onDrag时,请同时配置throttle节流的时间,这将用于限制event执行的频率。虽然throttle是个可选项,但如果不设置,默认不限制频率,在拖拽时会一直执行,这可能会导致性能问题,从而导致页面崩溃。
  • 使用列表拖拽功能时,请确保列表项有唯一的 key,这对于排序后的渲染很重要。
  • 拖拽把手选择器应该指向元素内的有效子元素,否则拖拽功能将无法正常工作。