穿梭框主部分
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import TransferPanel from "./TransferPanel.vue";
interface Item {
key: string | number;
label: string;
disabled?: boolean;
[key: string]: any;
}
const props = defineProps<{
modelValue: Item[]; // 选中的数据
sourceData: Item[]; // 全部数据
labelField?: string; // 指定 label 字段
hideSelected?: boolean; // 是否隐藏已选项
fetchSource?: (query: string) => Promise<Item[]>; // 远程搜索接口
}>();
const emit = defineEmits(["update:modelValue"]);
const selectedKeys = ref(new Set(props.modelValue.map(item => item.key)));
const sourceList = ref<Item[]>(props.sourceData);
const searchQuery = ref("");
// 远程搜索
watch(searchQuery, async query => {
if (props.fetchSource) {
sourceList.value = await props.fetchSource(query);
}
});
// 左侧列表(去掉已选项)
const displaySourceList = computed(() => {
return props.hideSelected ? sourceList.value.filter(item => !selectedKeys.value.has(item.key)) : sourceList.value;
});
// 右侧列表(已选项)
const targetList = computed(() => {
return props.sourceData.filter(item => selectedKeys.value.has(item.key));
});
// 选中 → 移动到右侧
const moveToRight = (keys: (string | number)[]) => {
keys.forEach(key => selectedKeys.value.add(key));
emit(
"update:modelValue",
props.sourceData.filter(item => selectedKeys.value.has(item.key))
);
};
// 移除 → 移动到左侧
const moveToLeft = (keys: (string | number)[]) => {
keys.forEach(key => selectedKeys.value.delete(key));
emit(
"update:modelValue",
props.sourceData.filter(item => selectedKeys.value.has(item.key))
);
};
</script>
<template>
<div class="transfer-box">
<!-- 左侧面板 -->
<TransferPanel
title="可选项"
:data="displaySourceList"
:searchable="true"
:fetch-source="fetchSource"
@move="moveToRight"
>
<template #default="{ item }">
<slot name="left-item" v-bind:item="item">
<span>{{ item[labelField || "label"] }}</span>
</slot>
</template>
</TransferPanel>
<!-- 按钮 -->
<div class="transfer-controls">
<button @click="moveToRight([...selectedKeys])">▶</button>
<button @click="moveToLeft([...selectedKeys])">◀</button>
</div>
<!-- 右侧面板 -->
<TransferPanel title="已选项" :data="targetList" :searchable="true" :client-search="true" @move="moveToLeft">
<template #default="{ item }">
<slot name="right-item" v-bind:item="item">
<span>{{ item[labelField || "label"] }}</span>
</slot>
</template>
</TransferPanel>
</div>
</template>
<style scoped>
.transfer-box {
display: flex;
gap: 16px;
align-items: center;
}
.transfer-controls {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
}
</style>
穿梭框面板部分
<script setup lang="ts">
import { ref, computed } from "vue";
interface Item {
key: string | number;
label: string;
disabled?: boolean;
}
const props = defineProps<{
title: string;
data: Item[];
searchable?: boolean;
clientSearch?: boolean;
fetchSource?: (query: string) => Promise<Item[]>; // 远程搜索
}>();
const emit = defineEmits(["move"]);
const searchQuery = ref("");
const selectedKeys = ref<Set<string | number>>(new Set());
// 本地搜索过滤
const filteredData = computed(() => {
if (!props.searchable || !props.clientSearch) return props.data;
return props.data.filter(item => item.label.toLowerCase().includes(searchQuery.value.toLowerCase()));
});
// 处理勾选
const toggleSelect = (key: string | number) => {
selectedKeys.value.has(key) ? selectedKeys.value.delete(key) : selectedKeys.value.add(key);
};
// 触发移动
const handleMove = () => {
emit("move", [...selectedKeys.value]);
selectedKeys.value.clear();
};
</script>
<template>
<div class="transfer-panel">
<h3>{{ title }}</h3>
<input
v-if="searchable"
v-model="searchQuery"
placeholder="搜索..."
@input="fetchSource && fetchSource(searchQuery)"
/>
<ul>
<li
v-for="item in filteredData"
:key="item.key"
:class="{ disabled: item.disabled }"
@click="!item.disabled && toggleSelect(item.key)"
>
<slot name="default" v-bind:item="item">
<span>{{ item.label }}</span>
</slot>
</li>
</ul>
<button @click="handleMove">移动选中</button>
</div>
</template>
<style scoped>
.transfer-panel {
width: 200px;
border: 1px solid #ddd;
padding: 8px;
}
.transfer-panel ul {
list-style: none;
padding: 0;
margin: 0;
}
.transfer-panel li {
padding: 4px;
cursor: pointer;
}
.transfer-panel li.disabled {
color: gray;
cursor: not-allowed;
}
</style>
穿梭框demo01
<script setup lang="ts">
import { ref } from "vue";
import TransferBox from "@/components/TransferBox.vue";
const sourceData = ref([
{ key: 1, label: "苹果" },
{ key: 2, label: "香蕉", disabled: true },
{ key: 3, label: "橘子" },
{ key: 4, label: "西瓜" },
]);
const selectedItems = ref([]);
const fetchSource = async (query: string) => {
return sourceData.value.filter(item => item.label.includes(query));
};
</script>
<template>
<TransferBox v-model="selectedItems" :sourceData="sourceData" :fetchSource="fetchSource" labelField="label" />
</template>
穿梭框demo02
<!-- TransferBoxDemo.vue -->
<script setup lang="ts">
import TransferBox from "@/components/TransferBox.vue"; // 假设你已经有 TransferBox 基础结构
import { ref } from "vue";
const leftKeyword = ref("");
const rightKeyword = ref("");
const leftData = ref([]);
const selectedData = ref([]);
const loadLeftData = async (keyword = "") => {
const all = Array.from({ length: 20 }, (_, i) => ({
id: i,
name: `Item ${i}`,
value: `Value ${i}`,
}));
leftData.value = all.filter(item => item.name.includes(keyword));
};
loadLeftData();
const handleLeftSearch = (val: string) => {
leftKeyword.value = val;
loadLeftData(val);
};
const handleRightSearch = (val: string) => {
rightKeyword.value = val;
};
</script>
<template>
<TransferBox
:left-data="leftData"
:right-data="selectedData"
:left-search="leftKeyword"
:right-search="rightKeyword"
:on-left-search="handleLeftSearch"
:on-right-search="handleRightSearch"
:on-add="rows => selectedData.push(...rows.filter(r => !selectedData.find(s => s.id === r.id)))"
:on-remove="rows => (selectedData = selectedData.filter(s => !rows.find(r => r.id === s.id)))"
>
<template #left-table="{ data, selection, onSelectionChange }">
<el-table :data="data" height="400" @selection-change="onSelectionChange">
<el-table-column type="selection" width="50" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="value" label="值" />
</el-table>
</template>
<template #right-table="{ data, selection, onSelectionChange }">
<el-table
:data="data.filter(d => d.name.includes(rightKeyword))"
height="400"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" width="50" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="value" label="值" />
</el-table>
</template>
</TransferBox>
</template>