# 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進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。