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>
综合实例:任务看板
结合列表排序和拖拽把手功能,实现一个简单的任务看板。
任务看板(综合示例)
待办任务
- 设计登录页面为新应用设计登录界面和注册表单
- 实现数据验证对所有用户输入添加表单验证功能
- 编写单元测试为认证模块编写单元测试
进行中
- 重构API模块使用新的请求库重构API请求模块
- 优化图片加载添加图片懒加载和渐进式加载功能
已完成
- 修复导航栏bug解决移动端导航栏折叠问题
- 更新依赖包更新所有npm包到最新版本
- 添加深色模式实现应用的深色模式切换功能
功能说明
- 通过左侧拖拽把手可以重新排序每一列中的任务卡片
- 仅点击卡片左侧带有线条图标的区域才能拖动卡片
- 每列内的任务可以独立排序
- 实现了重排时的视觉反馈和平滑动画
此示例展示了 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,这对于排序后的渲染很重要。
- 拖拽把手选择器应该指向元素内的有效子元素,否则拖拽功能将无法正常工作。