# Ant Design Vue中如何实现省市穿梭框 ## 前言 在Web应用开发中,地区选择器是常见的表单控件需求。当需要用户选择省市级联数据时,穿梭框(Transfer)组件能够提供直观的双栏选择交互体验。Ant Design Vue作为企业级UI框架,其穿梭框组件结合中国行政区划数据,可以构建出高效的省市选择器。 本文将详细介绍在Ant Design Vue中实现省市穿梭框的完整方案,涵盖以下核心内容: 1. Ant Design Vue穿梭框组件基础用法 2. 中国行政区划数据获取与处理 3. 省市级联数据结构的实现 4. 完整可复用的省市穿梭框组件封装 5. 性能优化与特殊场景处理 6. 实际应用案例与扩展思路 ## 一、Ant Design Vue穿梭框组件基础 ### 1.1 穿梭框组件介绍 穿梭框(Transfer)是Ant Design Vue提供的用于在两栏中移动元素的控件,常用于多项选择场景。其核心特点包括: - 双栏布局(源列表/目标列表) - 搜索过滤功能 - 自定义渲染支持 - 全选/反选操作 ### 1.2 基础使用示例 ```html <template> <a-transfer :data-source="dataSource" :target-keys="targetKeys" :render="item => item.title" @change="handleChange" /> </template> <script> export default { data() { return { dataSource: [ { key: '1', title: '选项1' }, { key: '2', title: '选项2' }, // ... ], targetKeys: [] } }, methods: { handleChange(targetKeys) { this.targetKeys = targetKeys } } } </script>
属性 | 说明 | 类型 | 默认值 |
---|---|---|---|
dataSource | 数据源 | Array | [] |
targetKeys | 显示在右侧框数据的key集合 | Array | [] |
render | 每行数据渲染函数 | function(record) | - |
filterOption | 搜索过滤函数 | function(inputValue, option) | - |
showSearch | 是否显示搜索框 | boolean | false |
实现省市穿梭框需要可靠的行政区划数据源,常见选择:
推荐使用china-division
包:
npm install china-division
原始数据通常为嵌套结构,需要转换为扁平化结构供穿梭框使用:
import { provinces, cities } from 'china-division' // 处理省份数据 const processProvinceData = () => { return provinces.map(province => ({ key: province.code, title: province.name, isLeaf: false // 标记为非叶子节点(有下级城市) })) } // 处理城市数据 const processCityData = () => { return cities.map(city => ({ key: city.code, title: city.name, pcode: city.provinceCode, // 关联父级省份 isLeaf: true // 标记为叶子节点 })) }
考虑到行政区划数据较大,应采用缓存策略:
// 在Vuex中存储处理后的数据 const store = new Vuex.Store({ state: { provinceData: [], cityData: [] }, mutations: { SET_REGION_DATA(state, { provinces, cities }) { state.provinceData = provinces state.cityData = cities } }, actions: { async loadRegionData({ commit }) { if (this.state.provinceData.length > 0) return const provinces = processProvinceData() const cities = processCityData() commit('SET_REGION_DATA', { provinces, cities }) } } })
创建ProvinceCityTransfer.vue
组件:
<template> <div class="province-city-transfer"> <a-transfer :data-source="formattedData" :target-keys="selectedKeys" :render="renderItem" :show-search="true" :filter-option="filterOption" @change="handleChange" /> </div> </template> <script> export default { name: 'ProvinceCityTransfer', props: { value: { type: Array, default: () => [] } }, data() { return { allProvinces: [], allCities: [], loadedKeys: [], // 已加载的省份key selectedKeys: this.value } }, computed: { formattedData() { // 合并省份和已加载城市数据 return [...this.allProvinces, ...this.loadedCities] }, loadedCities() { return this.allCities.filter(city => this.loadedKeys.includes(city.pcode) ) } } } </script>
实现省份展开时动态加载对应城市:
methods: { async loadData() { await this.$store.dispatch('loadRegionData') this.allProvinces = this.$store.state.provinceData this.allCities = this.$store.state.cityData }, handleExpand(expandedKeys) { // 找出新展开的省份key const newKeys = expandedKeys.filter( key => !this.loadedKeys.includes(key) ) if (newKeys.length > 0) { this.loadedKeys = [...this.loadedKeys, ...newKeys] } } }
methods: { renderItem(item) { return ( <span class={`transfer-item ${item.isLeaf ? 'is-city' : 'is-province'}`}> {item.title} </span> ) }, filterOption(inputValue, option) { return ( option.title.includes(inputValue) || this.getProvinceName(option.pcode).includes(inputValue) ) }, getProvinceName(pcode) { const province = this.allProvinces.find(p => p.key === pcode) return province ? province.title : '' } }
methods: { handleChange(targetKeys, direction, moveKeys) { this.selectedKeys = targetKeys this.$emit('input', targetKeys) this.$emit('change', targetKeys, direction, moveKeys) } }, watch: { value(newVal) { this.selectedKeys = newVal } }
<!-- ProvinceCityTransfer.vue --> <template> <div class="province-city-transfer"> <a-transfer :data-source="formattedData" :target-keys="selectedKeys" :render="renderItem" :show-search="true" :filter-option="filterOption" :list-style="listStyle" :titles="['待选区', '已选区']" :operations="['添加选择', '移除选择']" @change="handleChange" @expand="handleExpand" > <template #footer="{ direction }"> <div class="transfer-footer"> {{ direction === 'left' ? `共${allProvinces.length}个省份` : `已选${selectedKeys.length}项` }} </div> </template> </a-transfer> </div> </template> <script> import { mapState } from 'vuex' export default { name: 'ProvinceCityTransfer', props: { value: { type: Array, default: () => [] }, maxSelected: { type: Number, default: 10 } }, data() { return { loadedKeys: [], selectedKeys: [...this.value], listStyle: { width: '300px', height: '400px' } } }, computed: { ...mapState(['provinceData', 'cityData']), allProvinces() { return this.provinceData || [] }, allCities() { return this.cityData || [] }, formattedData() { return [...this.allProvinces, ...this.loadedCities] }, loadedCities() { return this.allCities.filter(city => this.loadedKeys.includes(city.pcode) ) } }, created() { this.loadData() }, methods: { async loadData() { await this.$store.dispatch('loadRegionData') }, handleExpand(expandedKeys) { const newKeys = expandedKeys.filter( key => !this.loadedKeys.includes(key) ) if (newKeys.length > 0) { this.loadedKeys = [...this.loadedKeys, ...newKeys] } }, handleChange(targetKeys, direction, moveKeys) { if (this.maxSelected && targetKeys.length > this.maxSelected) { this.$message.warning(`最多只能选择${this.maxSelected}项`) return } this.selectedKeys = targetKeys this.$emit('input', targetKeys) this.$emit('change', { keys: targetKeys, direction, movedKeys: moveKeys, provinces: this.getSelectedProvinces(), cities: this.getSelectedCities() }) }, getSelectedProvinces() { return this.allProvinces.filter( p => this.selectedKeys.includes(p.key) ) }, getSelectedCities() { return this.allCities.filter( c => this.selectedKeys.includes(c.key) ) }, renderItem(item) { const isCity = item.isLeaf return ( <span class={`transfer-item ${isCity ? 'is-city' : 'is-province'}`}> {isCity && ( <span class="city-prefix"> {this.getProvinceName(item.pcode)} - </span> )} {item.title} </span> ) }, filterOption(inputValue, option) { if (!inputValue) return true const matchesTitle = option.title.includes(inputValue) if (option.isLeaf) { return ( matchesTitle || this.getProvinceName(option.pcode).includes(inputValue) ) } return matchesTitle }, getProvinceName(pcode) { const province = this.allProvinces.find(p => p.key === pcode) return province ? province.title : '' } }, watch: { value(newVal) { if (JSON.stringify(newVal) !== JSON.stringify(this.selectedKeys)) { this.selectedKeys = [...newVal] } } } } </script> <style scoped> .province-city-transfer { margin: 20px 0; } .transfer-item.is-province { font-weight: bold; } .transfer-item.is-city { padding-left: 12px; color: #666; } .city-prefix { color: #999; margin-right: 4px; } .transfer-footer { padding: 8px; text-align: center; background: #fafafa; border-top: 1px solid #e8e8e8; } </style>
a-virtual-scroll
优化// 在组件中添加防抖搜索 import { debounce } from 'lodash' methods: { filterOption: debounce(function(inputValue, option) { // 过滤逻辑 }, 300) }
watch: { allProvinces(newVal) { if (newVal.length > 0 && this.selectedKeys.length === 0) { // 默认选中第一个省份 this.handleChange([newVal[0].key], 'right', [newVal[0].key]) } } }
props: { value: { type: Array, default: () => [], validator: value => Array.isArray(value) && value.every(item => typeof item === 'string') } }
handleChange(targetKeys) { if (this.maxSelected && targetKeys.length > this.maxSelected) { this.$message.warning(`最多选择${this.maxSelected}项`) // 保持原状态 return false } // 正常处理 }
<template> <a-form :form="form" @submit="handleSubmit"> <a-form-item label="服务覆盖区域"> <province-city-transfer v-decorator="['regions', { rules: [{ required: true }] }]" /> </a-form-item> <a-form-item> <a-button type="primary" html-type="submit">提交</a-button> </a-form-item> </a-form> </template> <script> import ProvinceCityTransfer from './ProvinceCityTransfer.vue' export default { components: { ProvinceCityTransfer }, beforeCreate() { this.form = this.$form.createForm(this) }, methods: { handleSubmit(e) { e.preventDefault() this.form.validateFields((err, values) => { if (!err) { console.log('提交数据:', values) } }) } } } </script>
// 从API获取已选区域 async fetchSelectedRegions() { const res = await api.getUserRegions() if (res.success) { this.selectedKeys = res.data.map(item => item.regionCode) } }, // 提交选中区域 async submitRegions() { const payload = { regions: this.selectedKeys, regionNames: this.getSelectedNames() } const res = await api.updateUserRegions(payload) if (res.success) { this.$message.success('保存成功') } }, // 获取选中区域的名称组合 getSelectedNames() { const provinces = this.getSelectedProvinces() const cities = this.getSelectedCities() return [ ...provinces.map(p => p.title), ...cities.map(c => `${this.getProvinceName(c.pcode)}-${c.title}`) ].join(', ') }
<template> <div> <province-city-transfer v-model="selectedRegions" /> <a-divider /> <h3>地区统计</h3> <a-table :columns="statsColumns" :data-source="regionStats" :pagination="false" /> </div> </template> <script> const statsColumns = [ { title: '省份', dataIndex: 'province' }, { title: '城市数量', dataIndex: 'cityCount' }, { title: '覆盖率', dataIndex: 'coverage' } ] export default { data() { return { selectedRegions: [], statsColumns, regionStats: [] } }, watch: { selectedRegions: { handler() { this.calculateStats() }, deep: true } }, methods: { calculateStats() { const selectedProvinces = this.getSelectedProvinces() const selectedCities = this.getSelectedCities() this.regionStats = selectedProvinces.map(province => { const provinceCities = selectedCities.filter( c => c.pcode === province.key ) const allCities = this.allCities.filter( c => c.pcode === province.key ) return { key: province.key, province: province.title, cityCount: `${provinceCities.length}/${allCities.length}`, coverage: `${Math.round( (provinceCities.length / allCities.length) * 100 )}%` } }) } } } </script>
扩展数据结构支持区县级选择:
// 数据处理 const processDistrictData = () => { return districts.map(district => ({ key: district.code, title: district.name, ccode: district.cityCode, // 关联父级城市 isLeaf: true })) } // 修改加载逻辑 handleExpand(expandedKeys) { expandedKeys.forEach(key => { if (!this.loadedKeys.includes(key)) { // 加载下级区域 if (this.allProvinces.some(p => p.key === key)) { // 加载城市 this.loadedKeys.push(key) } else if (this.allCities.some(c => c.key === key)) { // 加载区县 this.loadedDistricts = [ ...this.loadedDistricts, ...this.allDistricts.filter(d => d.ccode === key) ] } } }) }
”`javascript // 使用百度地图API示例 methods: { highlightOnMap() { const map = this.$refs.map.instance this.getSelectedCities().forEach(city => { const point = new BMap.Point(city.lng, city.lat) const marker = new BMap.Marker(point
免责声明:本站发布的内容(图片、视频和文字)以原创、转载和分享为主,文章观点不代表本网站立场,如果涉及侵权请联系站长邮箱:is@yisu.com进行举报,并提供相关证据,一经查实,将立刻删除涉嫌侵权内容。