由于业务需要,这里实现了一个简单的可拖动的排序列表,同时支持快速检索列表项,被检索的列表项可高亮显示并置顶
具体效果如下图:
代码中使用到
element-plus
,请根据需要引入;其中还用到了ClickOutside
,主要作用是为了实现点击弹窗外关闭弹窗的功能;拖动动画的实现使用了transition-group
组件,ClickOutside 的使用是在<div v-click-outside="onClickOutside"></div>
中,当用户点击该 div 元素外部时,会触发回调,可在方法中实现关闭弹窗的逻辑
import { ClickOutside as vClickOutside } from "element-plus";
废话不多说,全部代码如下(vue3+ts):
<template>
<div class="sortable-list-container">
<el-popover :visible="state.visible" placement="bottom" :width="250" trigger="click">
<template #default>
<div v-click-outside="onClickOutside">
<el-empty v-if="state.deepCopyList.length===0" :image-size="150" description="暂无数据" />
<div v-else>
<div class="search-box">
<el-input
placeholder="搜索菜名"
v-model="state.searchText"
size="default"
clearable
@input="handleSearchInput"
/>
</div>
<div class="action-button">
<div class="left-action">
<el-button size="small" type="primary" @click="resetListOrder">默认排序</el-button>
</div>
<div class="right-action">
<el-button size="small" plain @click="upTop">置顶</el-button>
<el-button size="small" plain @click="downBottom">置底</el-button>
</div>
</div>
<ol class="sortable-list">
<transition-group
name="list"
class="list"
tag="ol">
<!-- tag 设置为 ol 有序列表,意味着会生成一个有序列表包裹住所有 li 元素 -->
<li v-for="(item, index) in state.deepCopyList"
:class="item === state.activeItem ? 'list-item item-activeStyle' : 'list-item'"
@click="clickItemHandle(item)"
@dragenter="dragenter($event, index)"
@dragover="dragover($event)"
@dragstart="dragstart(index,item)"
@dragend="dropFinish"
draggable="true"
:key="item">
<!-- key 唯一且不能为index否则动画不生效 -->
<span v-html="highlightedLabel(item, state.searchText)" />
</li>
</transition-group>
</ol>
</div>
</div>
<div style="text-align: right; margin: 0; padding: 5px">
<el-button round text size="small" @click="state.visible = false" >取 消</el-button>
<el-button round text size="small" @click="onSubmitSort" type="primary">确 定</el-button>
</div>
</template>
<template #reference>
<el-button @click="openList" size="default" type="primary">
上菜顺序
</el-button>
</template>
</el-popover>
</div>
</template>
<script setup lang="ts" name="sortableList">
import {reactive, onMounted, nextTick} from 'vue';
// ClickOutside 点击页面上的某个元素之外时触发特定的回调函数
import { ElMessage, ClickOutside as vClickOutside } from "element-plus";
const list = ["蒸羊羔", "蒸熊掌", "蒸鹿尾儿", "烧花鸭", "烧雏鸡", "烧子鹅", "卤煮咸鸭", "酱鸡", "腊肉", "松花", "小肚儿", "晾肉", "香肠", "什锦苏盘", "熏鸡", "白肚儿", "清蒸八宝猪", "江米酿鸭子", "罐儿野鸡", "罐儿鹌鹑", "卤什件儿", "卤子鹅", "山鸡", "兔脯", "菜蟒", "银鱼", "清蒸哈什蚂", "炒腰丝", "炒鸭腰", "炒鸭条", "清拌鸭丝儿", "黄心管儿", "炯白鲜", "炯黄鲜", "豆豉鱼占鱼", "锅烧鲤鱼", "锅烧绘鱼", "清蒸甲鱼", "抓炒鲤鱼", "抓炒对虾", "软炸里脊", "软炸鸡", "什锦套肠儿", "麻酥油卷儿", "卤煮寒鸦儿", "熠鲜蘑", "熠鱼脯", "熠鱼肚", "熠鱼骨", "爆鱼片儿", "醋爆肉片儿"]
const state = reactive({
list: list, // 原始列表数据
deepCopyList: [], // 深克隆数据(所有操作在这个数组上进行),如果数据改变但是未保存 恢复到初始数据
activeItem: '', // 当前选中(激活)的元素
dragIndex: '', // 单前拖动项目的索引
visible: false, // 是否显示弹窗
searchText: '', // 输入框搜索的内容
searchTextTimeout: null, // 记录搜索的超时时间,避免频繁触发搜索
});
/**
* 处理点击当前区域之外位置的事件。 需要引入 ClickOutside
* import { ClickOutside as vClickOutside } from "element-plus";
*/
const onClickOutside = () => {
// 关闭弹窗
state.visible = false;
}
/**
* 重置列表顺序
*/
const resetListOrder = () => {
state.deepCopyList = [...state.list];
}
/**
* 置顶选中的某一项元素
*/
const upTop = () => {
if (state.activeItem) {
const index = state.deepCopyList.indexOf(state.activeItem);
if (index === 0) {
ElMessage.success("已经置顶了~")
return;
}
//将 当前index的元素通过unshift()方法添加到数组最前面,添加之后长度+1,之后并删除原本index+1 元素
state.deepCopyList.unshift(state.deepCopyList[index]);
state.deepCopyList.splice(index + 1, 1);
}
}
/**
* 置底选中的某一项元素
*/
const downBottom = () => {
if (state.activeItem) {
const index = state.deepCopyList.indexOf(state.activeItem);
if (index === state.deepCopyList.length-1) {
ElMessage.success("已经置底了~")
return;
}
//置底操作的原理,将 当前index元素 用push() 添加到数组最后 ,并删除原本index 元素
state.deepCopyList.push(state.deepCopyList[index]);
state.deepCopyList.splice(index, 1);
}
}
/**
* 点击列表选中某元素
* @param item 选中的元素
*/
const clickItemHandle = (item) => {
state.activeItem = item;
}
/**
* 事件在可拖动的元素进入一个有效的放置目标时触发,也就是拖动后鼠标松开时触发
* @param e
* @param index
*/
const dragenter = (e, index) => {
// 避免源对象触发自身的dragenter事件
e.preventDefault();
// 如果当前拖动的元素和目标元素不是同一个元素,则进行排序,完成拖放
if (state.dragIndex !== index) {
const source =state.deepCopyList[state.dragIndex];
state.deepCopyList.splice(state.dragIndex, 1);
state.deepCopyList.splice(index, 0, source);
// 排序变化后目标对象的索引变成源对象的索引
state.dragIndex = index;
}
}
/**
* 事件在用户开始拖动元素时调用。拖动开始
* @param index
* @param item 拖动的元素,用于拖动给时选中该项
*/
const dragstart = (index,item) => {
state.activeItem = item;
state.dragIndex = index;
}
/**
* 事件在可拖动的元素被拖进一个有效的放置目标时触发,也就是拖动结束在dragenter之后触发
* @param e
*/
const dragover = (e) => {
e.preventDefault();
}
/**
* 移动结束后
*/
const dropFinish = () => {
}
/**
* 处理搜索输入事件
*/
const handleSearchInput = () => {
clearTimeout(state.searchTextTimeout);
state.searchTextTimeout = setTimeout(() => {
// 查找第一个匹配项
const firstMatch = document.querySelector('.list-item span.match');
// 如果存在匹配项,则滚动到该元素的顶部位置
if (firstMatch) {
//smooth:滚动应该是平滑的动画。instant:滚动应该通过一次跳跃立刻发生。auto:滚动行为由 scroll-behavior 的计算值决定。
// start、center、end block 垂直方向对齐
firstMatch.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 300); // 延迟执行防止频繁搜索
}
/**
* 生成带有高亮的列表项
* @param item 列表项
* @param query 检索字符串
*/
const highlightedLabel = (item, query) => {
// 如果查询为空,则直接返回标签
if (!query) return item;
// 创建正则表达式,使用查询字符串进行全局和不区分大小写的匹配
const regex = new RegExp(query.trim(), 'gi');
// 替换标签中匹配的字符串,并返回带有高亮的列表项
return item.replace(regex, (matched) => `<span class="match" style="background-color: yellow; color: #000000">${matched}</span>`);
}
/**
* 保存排序
*/
const onSubmitSort = () => {
// 将修改后的数据保存到原始数据中
state.list = state.deepCopyList;
ElMessage.success('保存成功');
console.log(state.list)
}
/**
* 打开排序列表
*/
const openList = () => {
// 深克隆数据,如果数据改变但是未保存 恢复到初始数据
state.deepCopyList = [...state.list];
// nextTick 等DOM加载完成后再打开窗口 防止打开窗口时列表出现进入的动画
nextTick(()=>{
state.visible = true;
})
}
/**
* 页面加载时
*/
onMounted(() => {
});
</script>
<style scoped lang="scss">
.action-button {
display: flex;
justify-content: space-between;
padding-bottom: 10px;
}
.left-action,
.right-action {
display: flex;
align-items: center;
}
.search-box {
padding: 10px 0 15px 0;
}
// 列表样式
.sortable-list {
list-style-type:decimal;
list-style-position:inside;
max-height: 400px;
min-height: 200px;
font-size: 14px;
padding: 0;
overflow: auto;
}
.list {
padding: 0;
// 列表移动动画
.list-move{
transition: all 0.5s;
}
.list-item {
border-radius: 4px;
height: 20px;
padding: 5px 10px;
&:hover {
background: #f5f7fa;
}
}
.item-activeStyle{
background-color:#ecf5ff;
}
}
</style>