基于 elementPlus 穿梭框封装

95 阅读2分钟

穿梭框主部分

<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>