Appearance
Transfer 数据穿梭框
在两列列表之间移动数据,用于权限分配、成员分组和资源授权。
何时使用
- 同时查看候选与已分配数据,进行批量分配或取消分配。
- 选项包含较多业务信息,Select 展开空间不足。
- 自定义列表可以接入表格或树;层级关联与列表键盘逻辑由业务组合实现。
基础用法与移动事件
先勾选,再点击方向按钮。change 的第二参数是移动目的侧,第三参数仅包含实际移动值。
请勾选权限并移动
目标值:[ "audit" ]
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const basicValue = ref<TransferValue[]>(['audit']);
const basicData: TransferItem[] = [
{ value: 'read', label: '查看数据' },
{ value: 'edit', label: '编辑数据' },
{ value: 'audit', label: '审核数据' },
{ value: 'admin', label: '管理配置(不可分配)', disabled: true },
];
const basicLog = ref('请勾选权限并移动');
function basicChange(value: TransferValue[], type: TransferType, moved: TransferValue[]) {
basicLog.value = `移至 ${type}:${moved.join(', ')};当前值 ${JSON.stringify(value)}`;
}
</script>
<template>
<Transfer
v-model="basicValue"
:data="basicData"
:title="['可分配权限', '已分配权限']"
@change="basicChange"
/>
<p aria-live="polite">{{ basicLog }}</p>
<p>目标值:{{ basicValue }}</p>
</template>受控勾选与目标排序
v-model:selected-keys 控制两侧临时勾选,与目标值独立。target-order="append" 以模型顺序展示目标项。
目标:[ 2 ];勾选:[ 1 ]
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer, Button } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const checkedValue = ref<TransferValue[]>([2]);
const checkedKeys = ref<TransferValue[]>([1]);
const checkedData: TransferItem[] = Array.from({ length: 6 }, (_, index) => ({
value: index + 1,
label: `成员 ${index + 1}`,
}));
const checkedLog = ref('');
function checkedSelect(source: TransferValue[], target: TransferValue[]) {
checkedLog.value = `左侧 ${JSON.stringify(source)};右侧 ${JSON.stringify(target)}`;
}
</script>
<template>
<p
><Button @click="checkedKeys = [1, 2, 3]">勾选成员 1 / 2 / 3</Button>
<Button @click="checkedKeys = []">取消所有勾选</Button></p
>
<Transfer
v-model="checkedValue"
v-model:selected-keys="checkedKeys"
:data="checkedData"
target-order="append"
@select="checkedSelect"
/>
<p>目标:{{ checkedValue }};勾选:{{ checkedKeys }}</p>
<p aria-live="polite">{{ checkedLog }}</p>
</template>搜索与过滤后的全选
全选只作用于当前搜索结果,禁用项不参与;已勾选的隐藏项仍可以移动。自定义过滤可搜索附加业务字段。
暂无数据
尝试搜索“华东”,然后全选
目标值:[]
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const searchValue = ref<TransferValue[]>([]);
const searchData: TransferItem[] = [
{ value: 'east-1', label: '杭州', region: '华东' },
{ value: 'east-2', label: '上海', region: '华东' },
{ value: 'north', label: '北京', region: '华北' },
{ value: 'south', label: '深圳', region: '华南', disabled: true },
];
const searchLog = ref('尝试搜索“华东”,然后全选');
function searchFilter(input: string, item: TransferItem) {
return `${item.label} ${String(item.region ?? '')}`.includes(input.trim());
}
function searchChanged(input: string, type: TransferType) {
searchLog.value = `${type} 搜索:${input || '已清空'}`;
}
</script>
<template>
<Transfer
v-model="searchValue"
:data="searchData"
show-search
search-placeholder="搜索城市或大区"
:filter-option="searchFilter"
@search="searchChanged"
/>
<p aria-live="polite">{{ searchLog }}</p>
<p>目标值:{{ searchValue }}</p>
</template>禁用、加载与错误状态
加载时显示真实加载组件并阻止操作。单项禁用只影响该项;error 同时支持从 Form 继承。
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer, Checkbox } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const stateValue = ref<TransferValue[]>([1]);
const stateDisabled = ref(false);
const stateLoading = ref(false);
const stateError = ref(false);
const stateData: TransferItem[] = [
{ value: 1, label: '业务成员' },
{ value: 2, label: '运维成员' },
{ value: 3, label: '只读成员(禁用)', disabled: true },
{ value: 4, label: '这是一个用于检查溢出省略和原生标题的超长成员名称' },
];
</script>
<template>
<p
><Checkbox v-model="stateDisabled">禁用</Checkbox>
<Checkbox v-model="stateLoading">加载中</Checkbox>
<Checkbox v-model="stateError">错误</Checkbox></p
>
<Transfer
v-model="stateValue"
:data="stateData"
:disabled="stateDisabled"
:loading="stateLoading"
:error="stateError"
show-search
/>
</template>单向分配与逐项移除
只保留向右按钮,目标侧使用可通过键盘到达的移除按钮。已分配的禁用项不能移除。
已选项
0 / 2固定负责人
查看成员
已分配:[ "owner", "viewer" ]
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const oneWayValue = ref<TransferValue[]>(['owner', 'viewer']);
const oneWayData: TransferItem[] = [
{ value: 'owner', label: '固定负责人', disabled: true },
{ value: 'viewer', label: '查看成员' },
{ value: 'editor', label: '编辑成员' },
];
</script>
<template>
<Transfer v-model="oneWayValue" :data="oneWayData" one-way :operations="['分配']" />
<p>已分配:{{ oneWayValue }}</p>
</template>简单模式
点击选项直接移动,也支持 Tab 与 Enter/Space。与 one-way 组合时,目标侧仍使用移除按钮。
可选项
0 / 3已选项
0 / 1点击组名即可移动:[ "b" ]
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const simpleValue = ref<TransferValue[]>(['b']);
const simpleData: TransferItem[] = [
{ value: 'a', label: '开发组' },
{ value: 'b', label: '产品组' },
{ value: 'c', label: '测试组' },
{ value: 'd', label: '归档组', disabled: true },
];
</script>
<template>
<Transfer v-model="simpleValue" :data="simpleData" simple />
<p>点击组名即可移动:{{ simpleValue }}</p>
</template>自定义选项、标题、图标和页脚
item 仅替换内容,保留原生勾选交互。标题通过 onSelectAllChange 调用当前搜索结果/页内的全选。
暂无数据
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer, Checkbox } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const slotValue = ref<TransferValue[]>([]);
const slotData: TransferItem[] = [
{ value: 1, label: '林老师', role: '产品设计' },
{ value: 2, label: '陈同学', role: '前端研发' },
{ value: 3, label: '张同学', role: '质量保障' },
];
</script>
<template>
<Transfer
v-model="slotValue"
:data="slotData"
show-search
:title="['团队成员', '项目成员']"
:list-style="{ width: '260px' }"
>
<template #item="{ item }"
><span>{{ item.label }} · {{ item.role }}</span></template
>
<template #source-title="{ title, checked, indeterminate, disabled, onSelectAllChange }">
<Checkbox
:model-value="checked"
:indeterminate="indeterminate"
:disabled="disabled"
@change="onSelectAllChange($event === true)"
>{{ title }}</Checkbox
>
</template>
<template #to-target-icon><span aria-hidden="true">→</span></template>
<template #to-source-icon><span aria-hidden="true">←</span></template>
<template #footer="{ type, data, selectedKeys }"
><small
>{{ type === 'source' ? '可选' : '已选' }} {{ data.length }} 人,勾选
{{ selectedKeys.length }} 人</small
></template
>
</Transfer>
</template>自定义列表
source/target 替换列表内容。回调接入表格、树或业务卡片,保持共享移动规则。示例按钮支持键盘,并用 aria-pressed 表达勾选状态。
当前项目任务:[ "release" ]
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const customValue = ref<TransferValue[]>(['release']);
const customData: TransferItem[] = [
{ value: 'design', label: '设计评审' },
{ value: 'develop', label: '研发联调' },
{ value: 'release', label: '上线验证' },
{ value: 'archived', label: '已归档', disabled: true },
];
</script>
<template>
<Transfer v-model="customValue" :data="customData" show-search>
<template #source="{ pageData, selectedKeys, disabled, onSelect }">
<div style="padding: 12px; display: grid; gap: 8px">
<button
v-for="item in pageData"
:key="item.value"
type="button"
:disabled="disabled || item.disabled"
:aria-pressed="selectedKeys.includes(item.value)"
@click="onSelect(item.value, !selectedKeys.includes(item.value))"
>
{{ selectedKeys.includes(item.value) ? '✓ ' : '+ ' }}{{ item.label }}
</button>
<span v-if="!pageData.length">没有可选任务</span>
</div>
</template>
<template #target="{ pageData, selectedKeys, disabled, onSelect }">
<div style="padding: 12px; display: grid; gap: 8px">
<button
v-for="item in pageData"
:key="item.value"
type="button"
:disabled="disabled || item.disabled"
:aria-pressed="selectedKeys.includes(item.value)"
@click="onSelect(item.value, !selectedKeys.includes(item.value))"
>
{{ selectedKeys.includes(item.value) ? '✓ ' : '+ ' }}{{ item.label }}
</button>
<span v-if="!pageData.length">尚未分配任务</span>
</div>
</template>
</Transfer>
<p>当前项目任务:{{ customValue }}</p>
</template>分页
两侧页码独立,全选仅当前页。搜索或每页条数变化后回到第一页,移动使页码超出范围时自动收敛。
已分配:[ 2, 4 ]
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const pagedValue = ref<TransferValue[]>([2, 4]);
const pagedData: TransferItem[] = Array.from({ length: 30 }, (_, index) => ({
value: index + 1,
label: `候选成员 ${index + 1}`,
disabled: index === 8,
}));
</script>
<template>
<Transfer v-model="pagedValue" :data="pagedData" :pagination="{ pageSize: 5 }" show-search />
<p>已分配:{{ pagedValue }}</p>
</template>虚拟滚动
大数据列表复用库内 VirtualList。分页优先于虚拟滚动;自定义列表自行决定渲染方式。
尝试滚动列表或搜索数据项 999
目标值:[ 100, 500 ]
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const virtualValue = ref<TransferValue[]>([100, 500]);
const virtualData: TransferItem[] = Array.from({ length: 1000 }, (_, index) => ({
value: index + 1,
label: `数据项 ${index + 1}`,
}));
const virtualLog = ref('尝试滚动列表或搜索数据项 999');
function virtualScroll(_event: Event, type: TransferType) {
virtualLog.value = `${type} 列表正在滚动`;
}
</script>
<template>
<Transfer
v-model="virtualValue"
:data="virtualData"
show-search
:virtual-list-props="{
height: 240,
threshold: 30,
fixedSize: true,
estimatedSize: 36,
buffer: 5,
}"
@scroll="virtualScroll"
/>
<p aria-live="polite">{{ virtualLog }}</p>
<p>目标值:{{ virtualValue }}</p>
</template>Form 校验联动
勾选和搜索不改变字段值;实际移动才触发 change 校验。标签/必填/错误说明绑定到整个组,内部控件不会重复注册。
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer, Button, FormItem, Form } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const transferFormRef = ref<FormInstance>();
const transferForm = reactive({ members: [] as TransferValue[] });
const transferRules: Record<string, FieldRule[]> = {
members: [{ type: 'array', required: true, minLength: 2, message: '请至少分配 2 个成员' }],
};
const transferFormData: TransferItem[] = [
{ value: 'lin', label: '林老师' },
{ value: 'chen', label: '陈同学' },
{ value: 'zhang', label: '张同学' },
];
const transferFormLog = ref('');
async function validateTransfer() {
transferFormLog.value = (await transferFormRef.value?.validate()) ? '请补齐成员' : '校验通过';
}
</script>
<template>
<Form ref="transferFormRef" :model="transferForm" :rules="transferRules" layout="vertical">
<FormItem field="members" label="项目成员" required>
<Transfer v-model="transferForm.members" :data="transferFormData" show-search />
</FormItem>
<Button type="primary" @click="validateTransfer">校验成员</Button>
<Button @click="transferFormRef?.resetFields()">重置</Button>
<p aria-live="polite">{{ transferFormLog }}</p>
</Form>
</template>空态与异步数据
模型中暂时没有对应数据的值会保留,数据到达后展示。空态插槽能区分源侧、目标侧和搜索条件。
暂无候选成员
暂无已加载成员
模型值保留:[ "remote" ]
vue
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { Transfer, Button } from 'x-next';
import type { TransferItem, TransferValue, TransferType, FormInstance, FieldRule } from 'x-next';
const emptyValue = ref<TransferValue[]>(['remote']);
const emptyData = ref<TransferItem[]>([]);
function loadTransferData() {
emptyData.value = [
{ value: 'remote', label: '已分配的远程成员' },
{ value: 'new', label: '新加载的候选成员' },
];
}
</script>
<template>
<p
><Button @click="loadTransferData">加载模拟数据</Button>
<Button @click="emptyData = []">清空数据源</Button></p
>
<Transfer v-model="emptyValue" :data="emptyData" show-search>
<template #empty="{ type, searchValue }"
><span>{{
searchValue ? '没有匹配的成员' : type === 'source' ? '暂无候选成员' : '暂无已加载成员'
}}</span></template
>
</Transfer>
<p>模型值保留:{{ emptyValue }}</p>
</template>键盘与行为边界
- Tab 到达搜索、全选、选项和方向按钮;Space 勾选复选框,Enter/Space 激活方向/移除/简单模式按钮。
- 默认列表支持 ↑ / ↓、Home / End 定位,跳过禁用项。自定义列表由业务实现键盘语义。
- 勾选值与目标值分别受控;传入模型而不回写时保持原值。默认值只在创建时初始化。
value必须全局唯一,数字1和字符串"1"不同。重复项保留首项,非法值不展示;不原地修改输入数组。- 默认搜索不区分大小写。全选仅当前搜索结果,分页时仅当前页;已勾选的搜索隐藏项仍参与移动。
- 禁用项不能全选、移入或移除。缺失数据的目标值在移动其他项时保留。
targetOrder="original"按数据源排序;append按模型顺序,移入值追加在已有目标值之后。pagination优先于virtualListProps。source/target 插槽接收过滤数据和当前页数据,自行实现虚拟渲染与键盘。- 数组控件没有 readonly 属性;只读展示可使用 disabled。字段映射、远程分页、拖拽排序需业务适配。
按需导入
ts
import { Transfer } from 'x-next';
import 'x-next/style/transfer';样式入口自动包含 Checkbox、Input、Button、Empty、Spin、Pagination 和滚动条的运行时样式。
Props
| 参数 | 说明 | 类型 | 默认值 |
|---|---|---|---|
data | 数据源,value 唯一,支持附加字段 | TransferItem[] | [] |
modelValue / v-model | 目标侧的值 | (string | number)[] | undefined |
defaultValue | 非受控目标初始值 | (string | number)[] | [] |
selectedKeys / v-model:selected-keys | 两侧临时勾选值 | (string | number)[] | undefined |
defaultSelectedKeys | 非受控勾选初始值 | (string | number)[] | [] |
disabled | 禁用,继承 Form | boolean | false |
loading | 加载并阻止操作 | boolean | false |
error | 错误,继承 Form | boolean | false |
simple | 点击立即移动 | boolean | false |
oneWay | 单向分配,目标逐项移除 | boolean | false |
showSearch | 两侧搜索 | boolean | false |
showSelectAll | 显示全选;simple/oneWay 目标侧不显示 | boolean | true |
title | 两侧标题,缺项按语言包回退 | string[] | undefined |
operations | 向右、向左操作文案 | string[] | [] |
searchPlaceholder | 搜索占位 | string | 语言包 |
filterOption | 非空搜索的过滤函数 | (input: string, item: TransferItem, type: TransferType) => boolean | 标签搜索 |
targetOrder | 目标排序 | 'original' | 'append' | 'original' |
listStyle | 面板样式,可设置 width 或组件 CSS token | CSSProperties | undefined |
pagination | 分页,非法 pageSize 回退 10 | boolean | { pageSize?: number } | false |
virtualListProps | VirtualList 配置,默认高度 240 | VirtualListProps | undefined |
Events
| 事件 | 说明 | 参数 |
|---|---|---|
update:modelValue | 请求更新目标模型 | (values: TransferValue[]) |
update:selectedKeys | 请求更新临时勾选模型 | (values: TransferValue[]) |
change | 实际移动,type 为目的侧 | (values, type, movedValues) |
select | 临时勾选变化或移动清除勾选 | (sourceSelected, targetSelected) |
search | 输入/清空/隐藏搜索重置 | (inputValue: string, type: TransferType) |
scroll | 列表滚动 | (event: Event, type: TransferType) |
Slots
| 插槽 | 说明 | 作用域 |
|---|---|---|
item | 选项内容 | { item, type, selected, disabled } |
source / target | 替换对应侧列表 | { type, data, pageData, selectedKeys, disabled, onSelect, onSelectAll, onMove } |
source-title / target-title | 替换标题 | { type, title, countTotal, countSelected, searchValue, checked, indeterminate, disabled, onSelectAllChange } |
footer | 两侧页脚 | 与列表作用域相同 |
empty | 默认列表空态,自定义列表自行处理 | { type, searchValue } |
to-target-icon / to-source-icon | 方向图标 | 无 |
onSelect(value, checked) 选择单项;onSelectAll(values, checked) 选择指定项;onMove(values) 移动同侧项。回调校验数据存在、所属侧、禁用和加载状态。onSelectAllChange(checked) 仅选择当前搜索/页内可用项。selectedKeys 是本侧存在且未禁用的勾选值,包含搜索隐藏项。
Methods
没有额外公开方法。通过模型、props 与作用域回调控制状态。
数据类型
ts
type TransferValue = string | number;
type TransferType = 'source' | 'target';
interface TransferItem {
value: TransferValue;
label: string;
disabled?: boolean;
[key: string]: unknown;
}