华南、华北...等地理大区分组选择区域插件

922 阅读15分钟

基于vue3+elementplus的一个根据中国地区大区分区的选择器,效果图如下,需求说实话还是挺合(bian)理(tai)的

image.png

需求如下

  1. 根据中国地理大区划分显示对应的省份
  2. 如果选择了广东省,那就代表着选择了这个省下面的所有市,所有区,但是发请求的时候不要传,说是为了减少请求的数据量,举例说明,界面中勾选了广东省,保存之后,在表格中看到的数据就只有广东省,但是如果展开广东省下面的市级,包括区级,都是全选中的状态,但是如果去掉一个区,也就是不是全选的情况下,那展示的时候就要变成广东省-广州市-天河区....全部一一展开
  3. 因为业务是电商的指定配送区域,并且设置快递费用,所以需要 去重,就是上一条数据选择过了广东省,那就不能再选择广东省了,如果整个华北被选择过了,也需要在新增的时候不显示整个华北区域

需求明确之后,那就是一步一步的去实现了

1,根据中国地理大区划分显示对应的省份


这个数据我感觉也是应该是一个树状的数据结构的,我找了一下,发现并没有发现现成的JSON数据,所以也只能通过一个通用的省市区JSON数据,然后通过node写一个脚本来实现了,脚本如下,也相对简单


const fs = require('fs');

// 读取省市区数据的 JSON 文件
fs.readFile('./pca-code.json', 'utf8', (err, jsonString) => {
  if (err) {
    console.log("Error reading file from disk:", err);
    return;
  }
  try {
    // 解析 JSON 数据
    const jsonData = JSON.parse(jsonString);

    let data = [
      {
        name:'东北',
        code:-1,
        includeProvinces:['辽宁省','吉林省','黑龙江省'],
        children:[]
      },
      {
        name:'华北',
        code:-2,
        includeProvinces:['北京市','天津市','河北省','山西省','内蒙古自治区'],
        children:[]
      },
      {
        name:'华东',
        code:-3,
        includeProvinces:['上海市','江苏省','浙江省','安徽省','福建省','江西省','山东省'],
        children:[]
      },
      {
        name:'华南',
        code:-3,
        includeProvinces:['广东省','广西壮族自治区','海南省'],
        children:[]
      },
      {
        name:'华中',
        code:-4,
        includeProvinces:['河南省','湖北省','湖南省'],
        children:[]
      },
      {
        name:'西南',
        code:-5,
        includeProvinces:['重庆市','四川省','贵州省','云南省','西藏自治区'],
        children:[]
      },
      {
        name:'西北',
        code:-6,
        includeProvinces:['陕西省','甘肃省','青海省','宁夏回族自治区','新疆维吾尔自治区'],
        children:[]
      },
      {
        name:'港澳台',
        code:-7,
        includeProvinces:['台湾省','香港特别行政区','澳门特别行政区'],
        children:[]
      },
    ]



    let result = {};


    data.map(t=>{
      let provinces = t.includeProvinces;
      jsonData.map((t2,index)=>{
        if(provinces.includes(t2.name)){
          t.children.push(t2)
        }
      })
    })


    // 将结果转换为 JSON 字符串并输出
    const resultJsonString = JSON.stringify(data, null, 2);

    // 将结果写入新的 JSON 文件
    fs.writeFile('group-pca.json', resultJsonString, 'utf8', (err) => {
      if (err) {
        console.log("Error writing file:", err);
      } else {
        console.log("New JSON file has been saved!");
      }
    });

    console.log(result);
  } catch (err) {
    console.log("Error parsing JSON data:", err);
  }
});

第一个需求我们就已经完成了

2,当全选子级的时候,显示与数据传输都只是当前级,如果不是全选,那就要平铺显示


全选广东省,传输与显示的时候就只有广东省,但是如果把广州市的越秀区去掉的话,那传输与展示的时候就是平铺,广东省-广州市-天河区,广东省-广州市-黄埔区...但是仅仅只是广州市的区级平铺,其他的市级由于是区级全选,所以其他的市也只显示到市级,所以数据是这样的:广东省-广州市-天河区...,广东省-深圳市,广东省-清远市...

需求理清楚了,先把界面展示出来,根据效果图我们可以看出是一个弹窗的方式,一开始我想要使用el-tree组件实现,但是效果太卡了,每次打开弹窗都好几秒,因为整体的数据量还是挺大的,无奈放弃,只能自己写了

1,新建一个area-form.vue,使用el-checkbox来进行嵌套地区大区的划分,子级的显示的话,使用el-popover来进行嵌套展示

<template>
  <el-dialog
    v-model="dialogVisible"
    title="指定可配送区域"
    width="80%"
    :before-close="handleClose"
    :close-on-click-modal="false"
  >
    <div>
      <template v-for="group in areaData" :key="group.code">
        <div class="flex flex-wrap">
          <el-checkbox
            :value="group.code"
            v-model="group.selected"
            class="w-100px"
          >
            {{ group.name }}
          </el-checkbox>
          <el-checkbox-group
            v-model="group.selectedChildren"
          >
            <template v-for="province in group.children" :key="province.code">
              <el-checkbox
                :value="province"
              >
                <el-popover width="300" placement="right" trigger="click" :teleported="false">
                  <template #reference>
                    <div>
                      {{ province.name }}
                      <el-icon>
                        <ArrowDown />
                      </el-icon>
                    </div>
                  </template>
                </el-popover>
              </el-checkbox>
            </template>
          </el-checkbox-group>
        </div>
      </template>
    </div>

    <template #footer>
      <div class="dialog-footer">
        <el-button @click="handleClose">取消</el-button>
        <el-button type="primary" @click="save">确定</el-button>
      </div>
    </template>
  </el-dialog>
</template>
<script setup>
  import {ref,defineEmits} from "vue"
  import groupPca from './group-pca.json'
  import {ArrowDown} from "@element-plus/icons-vue"
  import {cloneDeep} from "lodash-es"
  const areaData = ref(cloneDeep(groupPca))
  const dialogVisible = ref(true)
  const emits = defineEmits(['close'])

  const handleClose = () => {
    emits('close')
  }


  const save = ()=>{

  }
</script>
<style scoped lang="scss">
  .flex{
    display: flex;
  }
  .flex-wrap{
    flex-wrap: wrap;
  }
  .items-center{
    align-items: center;
  }
  .w-100px{
    width:100px;
  }

</style>

华北,华南等大区是不需要传输到后台的,所以作用只是作为前端的一个快捷选择,单独把它拿出来,使用el-checkbox-group为了更好的拿子级选择的一个数据

第一层的省级数据我们已经展示出来了,接下来展示市级跟区级,树状结构的数据展示,最直接方式的就是写一个组件出来,然后递归式的调用组件本身,所以我们新建一个area-popover.vue组件

1, 界面的话,通过传入的区域数据来进行checkbox,并且需要判断是否还有子级,有的话就展示箭头图标,并且再调用组件本身,没有的话,就直接显示就好了

<template>
  <div>
    <el-checkbox-group>
      <template v-for="item in areaData" :key="item.code">
        <el-checkbox
          style="display: block"
          :value="item"
        >
          <el-popover
            v-if="item.children?.length"
            width="300"
            placement="right"
            trigger="click"
            :teleported="false"
          >
            <template #reference>
              <div class="inline-block">
                {{ item.name }}
                <el-icon v-if="item.children?.length"><ArrowDown /></el-icon>
              </div>
            </template>
            <div v-if="item.children?.length">
              <areaPopover
                :area-data="item.children"
                :indeterminate="item.isIndeterminate"
              ></areaPopover>
            </div>
          </el-popover>
          <div v-else>
            {{ item.name }}
          </div>
        </el-checkbox>
      </template>
    </el-checkbox-group>
  </div>
</template>
<script setup name="areaPopover">

import {defineProps,onMounted,ref,computed} from 'vue'
import {ArrowDown} from "@element-plus/icons-vue"

const props = defineProps({
  areaData: {
    type: Array,
    required: true
  },
})
</script>
<style scoped lang="scss"></style>

在组件中使用它,修改的area-form组件

<template>
  <el-dialog
    v-model="dialogVisible"
    title="指定可配送区域"
    width="80%"
    :before-close="handleClose"
    :close-on-click-modal="false"
  >
    <div>
      <template v-for="group in areaData" :key="group.code">
        <div class="flex flex-wrap">
          <el-checkbox
            :value="group.code"
            v-model="group.selected"
            class="w-100px"
          >
            {{ group.name }}
          </el-checkbox>
          <el-checkbox-group
            v-model="group.selectedChildren"
          >
            <template v-for="province in group.children" :key="province.code">
              <el-checkbox
                :value="province"
              >
                <el-popover width="300" placement="right" trigger="click" :teleported="false">
                  <template #reference>
                    <div>
                      {{ province.name }}
                      <el-icon>
                        <ArrowDown />
                      </el-icon>
                    </div>
                  </template>

                  <div>
<!--                    引用子级区域的组件-->
                    <areaPopover
                      :area-data="province.children"
                    >
                    </areaPopover>
                  </div>
                </el-popover>
              </el-checkbox>
            </template>
          </el-checkbox-group>
        </div>
      </template>
    </div>

    <template #footer>
      <div class="dialog-footer">
        <el-button @click="handleClose">取消</el-button>
        <el-button type="primary" @click="save">确定</el-button>
      </div>
    </template>
  </el-dialog>
</template>
<script setup>
import {ref,defineEmits} from "vue"
import groupPca from './group-pca.json'
import {ArrowDown} from "@element-plus/icons-vue"
import {cloneDeep} from "lodash-es"
import areaPopover from './area-popover.vue'
const areaData = ref(cloneDeep(groupPca))
const dialogVisible = ref(true)
const emits = defineEmits(['close'])

const handleClose = () => {
  emits('close')
}


const save = ()=>{

}
</script>
<style scoped lang="scss">
.flex{
  display: flex;
}
.flex-wrap{
  flex-wrap: wrap;
}
.items-center{
  align-items: center;
}
.w-100px{
  width:100px;
}

</style>

到目前为止显示是正常的,但是很卡...dialog显示都好几秒,思考了一下,是因为el-popover其实它不显示,但是它的dom树却会生成,所以数据量大的时候会导致卡顿

优化卡顿问题


上面提到了是因为数据量大量的同时渲染导致的卡顿,所以我们优化的逻辑就是"懒加载"的方式,交互的逻辑是点击某个区域,展示它的子区域,所以我的思路就是从点击事件入手,记录唯一的code值来进行判断,是否渲染子级的el-popover,并且由于要在多个组件中用到,所以我们直接封装成一个hook

useShowAreaCode的hook

import {ref} from "vue"
export const useShowAreaCode = () => {
  const currentAreaCode = ref('')
  const showPopoverHandle = code => {
    currentAreaCode.value = code
  }

  return {
    currentAreaCode,
    showPopoverHandle
  }
}

修改area-form.vue

<template>
  ...
  <areaPopover
    v-if="currentAreaCode === province.code"
    :area-data="province.children"
  >
  </areaPopover>
</template>

<script setup>
  import { useShowAreaCode } from './hooks'
  const { currentAreaCode, showPopoverHandle } = useShowAreaCode()
</script>

修改area-popover.vue组件

<template>
  <div>
    <el-checkbox-group>
      <template v-for="item in areaData" :key="item.code">
        <el-checkbox
          style="display: block"
          :value="item"
        >
          <el-popover
            v-if="item.children?.length"
            width="300"
            placement="right"
            trigger="click"
            :teleported="false"
          >
            <template #reference>
              <div class="inline-block" @click="showPopoverHandle(item.code)">
                {{ item.name }}
                <el-icon v-if="item.children?.length"><ArrowDown /></el-icon>
              </div>
            </template>
            <div v-if="item.children?.length && currentAreaCode === item.code">
              <areaPopover
                :area-data="item.children"
              ></areaPopover>
            </div>
          </el-popover>
          <div v-else>
            {{ item.name }}
          </div>
        </el-checkbox>
      </template>
    </el-checkbox-group>
  </div>
</template>
<script setup>
  import { useShowAreaCode } from './hooks'
  const { currentAreaCode, showPopoverHandle } = useShowAreaCode();
  ...
</script>

再次运行,发现打开的弹窗的时间的确快的多了,而且子级的显示也没有问题,但是交互上好像有一点点冲突,点击区域名称的时候应该是展开子区域,复选框自身不受影响

解决交互冲突,这里我直接给showPopoverHandle这个点击事件换成lick.prevent来禁止默认事件即可


...
<div @click.prevent="showPopoverHandle(province.code)">
  {{ province.name }}
  <el-icon>
    <ArrowDown />
  </el-icon>
</div>

到目前为止,弹窗的展示跟选择暂时是没有问题了,接下来开始做比较麻烦的数据处理的问题了

数据处理


首先先确认界面有多少种状态,因为需要通过数据来驱动界面,所以界面的状态关系到我们需要怎么改造我们的数据

  1. 选中状态,也就是自身打钩,这个状态代表所有后代全选
  2. 半选状态,代表后代区域有被勾选.
  3. 不显示,也就是数据去重的时候,发现该区域已经被选中过了,是直接不在界面中展示,但是同时需要是否为真的全选中了子级,所以做法是给这个区域数据打一个标识

先做第一个,选中状态,因为是el-checkbox-group的方式,所以直接给数据加上一个selectedChildren的数据来控制,同时也方便保存时拿到数据,修改area-form.vue组件

const initData = () => {
  areaData.value.map(t => {
    t.selectedChildren = []
    t.children.map(t2 => {
      t2.selectedChildren = []
    })
  })
}

onMounted(() => {
  initData()
})

这里因为弹窗默认的情况下,其实只是展示到了省级,所以只需要给省级加上我们需要的字段就好,子级的话,在用户操作展示出子级的时候再进行添加,这样可以减少循环的次数

areaPopover.vue加上v-model的方式来通过父级传输的selectedChildren来进行显示并且数据的统一性


<template>
  <el-checkbox-group v-model="selectedData">
    <template v-for="item in areaData" :key="item.code">
      <el-checkbox
        style="display: block"
        :value="item"
      >
        <el-popover
          v-if="item.children?.length"
          width="300"
          placement="right"
          trigger="click"
          :teleported="false"
        >
          <template #reference>
            <div class="inline-block" @click.prevent="showPopoverHandle(item.code)">
              {{ item.name }}
              <el-icon v-if="item.children?.length"><ArrowDown /></el-icon>
            </div>
          </template>
          <div v-if="item.children?.length && currentAreaCode === item.code">
            <areaPopover
              :area-data="item.children"
              v-model="item.selectedChildren"
            ></areaPopover>
          </div>
        </el-popover>
        <div v-else>
          {{ item.name }}
        </div>
      </el-checkbox>
    </template>
  </el-checkbox-group>
</template>

<script setup>
  ...
  const props = defineProps({
    areaData: {
      type: Array,
      required: true
    },
    modelValue: {
      type: Array,
      defalut: []
    }
  })

  const emits = defineEmits(['update:modelValue'])
  const selectedData = computed({
    get: () => {
      return props.modelValue || []
    },
    set: val => {
      emits('update:modelValue', val)
    }
  })
</script>

area-form.vue直接传入v-model

<template>
  ...
  <areaPopover
    v-if="currentAreaCode === province.code"
    :area-data="province.children"
    v-model="province.selectedChildren"
  >
  </areaPopover>
</template>

现在复选框的自身勾选是正常的了,但是父级勾选之后,子级展开的时候并没有全选

父级勾选,子级全选

根据我们上面类似于"懒加载"的方法,所以我的做法就是在子区域的界面显示的话,传入一个值告诉它目前父区域的一个状态,从而来判断是否要全选

在area-popover.vue这个组件中,新增一个props的字段,来代表父级目前是否是已选中的状态,并且在onMounted的生命周期里做初始化的判断

<template>
     <areaPopover
        :area-data="item.children"
        :parent-selected="currentIsCheck(item)"
        v-model="item.selectedChildren"
     >
    </areaPopover>
</template>

<script	setup>
    
  const props = defineProps({
  areaData: {
    type: Array,
    required: true
  },
  modelValue: {
    type: Array,
    defalut: []
  },
  parentSelected: {
    // 父区域是否被选择
    type: Boolean,
    default: false
  },
})


const currentIsCheck = computed(() => {
  // 判断当前的区域是否是选中的状态
  return function (item) {
    return selectedData.value.some(t => t.code == item.code)
  }
})
  
onMounted(() => {
  if (props.parentSelected) {
    // 父区域选中的话,就代表所有子级全选,需要把选中的状态重置
    selectedData.value = props.areaData.map(t => t)
  }
})
</script>

area-form.vue的组件中传入parentSelected这个值,这里使用一个计算属性来判断当前这个区域是否是选中的状态

<template>
      <areaPopover
       v-if="currentAreaCode === province.code"
       :area-data="province.children"
       v-model="province.selectedChildren"
       :parentSelected="currentIsCheck(group, province)"
       >
     </areaPopover>
</template>
<script setup>
const currentIsCheck = computed(() => {
  return function (groupData, item) {
    return groupData.selectedChildren && groupData.selectedChildren.some(t => t.code == item.code)
  }
})

</script>	

子级选择,父级区域半选状态的响应

其实这里有两种做法

  1. 通过watch的方式来监听selectedChildren数组的改变
  2. 通过子级的checkbox-group的change事件发生时,emit通知父级来改变选中状态

这里的话,我选择的是第一种的方式,虽然我没有去测试过,但是我觉的第2种的方式性能消耗会更大,因为需要深监测的方式进行watch监听


我们新增一个 isIndeterminate字段来驱动checkbox组件的半选状态,然后监听checkbox-group的change事件, 然后在回调的函数中去判断当前是否全选的状态,然后通过emit事件来通知父级改变isIndeterminate这个字段

<el-checkbox
    style="display: block"
    :indeterminate="item.isIndeterminate"
    :value="item"
    @change="
    value => {
        checkboxChangeHandle(value, item)
    }
    "
>
    ...
</el-checkbox>      

const changeHandle = formCheckbox => {
    /*
   * 每个checkbox有3个状态,选中,选中部分,不选中
   * */

    setTimeout(() => {
        let isIndeterminate = props.areaData.some(t => t.isIndeterminate)

        if (isIndeterminate) {
            // 如果当前数据有任何一个数据的indeterminate为true,往前的所有父级的indeterminate都为true
            emits('parentSelected', false)
            emits('indeterminate', true)
        } else if (selectedData.value.length === props.areaData.length) {
            //   如果所有数据的indeterminate都为false,就判断选中的数据是否跟渲染的数据相同,相同的话就是全选
            emits('parentSelected', true)
            emits('indeterminate', false)
        } else if (selectedData.value.length) {
            emits('parentSelected', false)
            emits('indeterminate', true)
        } else if (!selectedData.value.length) {
            //   如果一个选中项都没有,那勾选状态为空
            emits('parentSelected', false)
            emits('indeterminate', false)
        }

        if (!formCheckbox) return
        props.areaData.map(t => {
            // 需要手动判断数据是否被选中,被选中之后需要手动修改isIndeterminate的状态,否则显示的选中状态不正确
            let isExist = selectedData.value.some(t2 => {
                return t2.code == t.code
            })
            if (isExist) {
                t.isIndeterminate = false
            }
        })
    }, 100)
}

这里要添加一个定时器,来让通知事件在checkbox的自身事件发生之后再触发,不然会出现你界面显示不准确的问题

在组件上监听这两个事件

<areaPopover
             :area-data="item.children"
             :indeterminate="item.isIndeterminate"
             :parent-selected="currentIsCheck(item)"
             v-model="item.selectedChildren"
             @parent-selected="
                               value => {
                               changeParentSelected(value, item)
                               }
                               "
             @indeterminate="
                             value => {
                             changeIndeterminate(value, item)
                             }
                             "
             >
</areaPopover>

<script setup>
  const changeParentSelected = (value, item) => {
  let arr = selectedData.value
  let index = arr.findIndex(t => t.code == item.code)
  if (value && index == -1) {
    arr.push(item)
  } else if (!value && index != -1) {
    arr.splice(index, 1)
  }
  emits('update:modelValue', arr)
}
  
  const changeIndeterminate = (value, item) => {
  // 子级的区域选中状态发生变动的话,需要计算父区域的状态值
  item.isIndeterminate = value
  changeHandle(false)
}
  
</script>

area-form组件因为有华南这种地理大区比较特殊的存在,所以回调事件会稍微有点不一样,可以直接看源码,主要逻辑是相同,这里就不赘述了

到这里会发现el-popover的弹出的组件在点击非常边缘的时候,会触发到父级的checkbox选择事件,导致了选择结果不正确,通过控制台我们会发现是因为它自带了padding,由于我们之前阻止的是checkbox的本身的原生事件,所以点到padding这里的话,就会导致它的父盒子被触发了原生事件,也就是下面图中的位置

image.png

解决的方式,我们通过 事件代理的方式来禁止冒泡事件就可以了.在el-dialog的内容最外层的div上监听点击事件,然后通过类名判断是否是el-popover,是的话就禁用冒泡

<template>
	<el-dialog>
        <div @click="handleClick">
            ...
    	</div>
    </el-dialog>
</template>
<script setup>
const handleClick = event => {
  if (event.target.classList.contains('el-popover')) {
    // 防止点击el-popover的空白处导致父级区域的反选
    event.preventDefault()
  }
}
</script>

全选区域,显示已选择区域

​ 通过递归的方式获取已经选择到的区域,并且累计相加, filterSelectedFn这个方法里面的一些disabled等字段,后面会说到作用

<template>
		...
      <div class="flex items-center">
        <el-checkbox v-model="checkAll" @change="changeAllHandle">全选</el-checkbox>
        <div style="color:#ccc;margin-left: 12px">已选择{{ areaCount }}区域</div>
      </div>
</template>

<script setup>
const changeAllHandle = value => {
  areaData.value.map(t => {
    t.isIndeterminate = false
    if (value) {
      t.selected = true
      checkboxAllChildren(t, value)
    } else {
      t.selected = false
      checkboxAllChildren(t, value)
    }
  })
}

const filterSelectedFn = (isDeleteChldren = false) => {
  let treeData = areaData.value.filter(t => {
    // 遍历大区域的分组,只返回半选中或者有选中子级的数据
    return t.isIndeterminate || t.selectedChildren?.length
  })

  treeData = cloneDeep(treeData)

  const deleteExtraData = item => {
    item.isIndeterminate !== undefined && delete item.isIndeterminate
    item.selectedChildren !== undefined && delete item.selectedChildren
    item.disabled !== undefined && delete item.disabled
    // item.childrenExistDisabled !== undefined && delete item.childrenExistDisabled
  }

  const filterVoildFn = (items, parent) => {
    return items.filter(item => {
      // 如果半选中状态为false,而且父级的选中数据中没有该元素,则代表是没有选中该数据
      if (item.disabled) return false
      if (
        !item.isIndeterminate &&
        parent &&
        !parent.selectedChildren?.some(t => t.code == item.code)
      ) {
        return false
      }

      if (item.isIndeterminate || item.childrenExistDisabled) {
        // 半选中状态,就需要遍历子级
        item.children = filterVoildFn(item.children, item)

        if (isDeleteChldren) {
          deleteExtraData(item)
        }

        return true
      }

      if (
        !item.isIndeterminate &&
        !item.selectedChildren?.length &&
        parent.selectedChildren?.some(t => t.code == item.code)
      ) {
        // 这样也代表子级全选
        if (isDeleteChldren) {
          deleteExtraData(item)
          item.children !== undefined && delete item.children
        }
        return true
      }

      if (item.selectedChildren && item.selectedChildren.length === item.children.length) {
        // 子级全选
        if (isDeleteChldren) {
          deleteExtraData(item)
          item.children !== undefined && delete item.children
        }
        return true
      }
    })
  }

  let finalArr = []
  treeData.map(t => {
    finalArr = finalArr.concat(filterVoildFn(t.children, t))
  })

  return finalArr
}


const areaCount = computed(() => {
  let count = 0
  let arr = filterSelectedFn()
  const recursion = item => {
    // 递归到最小颗粒度
    if (item.children && item.children.length) {
      item.children.map(t => {
        recursion(t)
      })
    } else {
      count++
    }
  }
  arr.map(t => {
    recursion(t)
  })

  return count
})


</script>

保存方法

​ 通过上面的 filterSelectedFn获取选中的区域就可以了

const save = ()=>{
  if (!areaCount.value) {
    ElMessage.warning('请先选择区域')
    return
  }

  filterSelectedFn(true)

  emits('save', filterSelectedFn(true))
}

至此,新增的逻辑已经完成了,接下来就是编辑,还有再次新增时去重的逻辑了

编辑

  1. 编辑时需要回显已经选择的区域,所以props定义一个editItem
  2. 如果已经有数据的时候新增,需要去重掉已经被选择的区域.不能再选择 ,所以props需要定义一个existAreaList数据来进行去重的判断

编辑时回显方法

​ 利用递归的方式来进行两个数组的对比,从而改变数据中的 isIndeterminateselectedChildren

const initEditData = () => {
  const editData = cloneDeep(props.editItem.areaList)
  const recursiveUpdate = (areaList, editList, parent) => {
    areaList.forEach(area => {
      const editItem = editList.find(item => item.code === area.code)
      if (editItem) {
        const areaArr = area.children?.filter(t => !t.disabled) || []
        if (!parent.selectedChildren) {
          parent.selectedChildren = []
        }

        if (editItem.children && editItem.children.length && areaArr.length) {
          if (isAllChildrenSelected(area, editItem)) {
            parent.selectedChildren.push(area)
          } else {
            area.isIndeterminate = true
          }
          recursiveUpdate(area.children, editItem.children, area)
        } else {
          parent.selectedChildren.push(area)
        }
      }
    })
  }

  areaData.value.map(t => {
    recursiveUpdate(t.children, editData, t)
  })

  // 判断区域的状态
  areaData.value.map(t => {
    let isIndeterminate = t.children.some(t2 => t2.isIndeterminate)
    if (
      isIndeterminate ||
      (t.selectedChildren?.length && t.selectedChildren?.length < t.children.length)
    ) {
      t.isIndeterminate = true
      return
    }

    let isAll = t.selectedChildren?.length == t.children.length
    if (isAll) {
      t.selected = true
    }
  })
}

已选区域去重

​ 跟编辑的回显函数核心逻辑差不多,通过递归来判断数据中相同的数据,并且加上 disabledchildrenExistDisabled来记录上

const initDisabledData = () => {
  //   禁用已经被选择过的数据
  const existArr = cloneDeep(props.existAreaList)
  const recursiveUpdate = (areaList, existList) => {
    areaList.forEach(area => {
      let existItem = { children: [] }
      existList
        .filter(item => item.code == area.code)
        .map(t => {
          existItem.name = t.name
          existItem.code = t.code
          existItem.children = existItem.children.concat(t.children || [])
        })
      if (existItem.code && existItem.children && existItem.children.length) {
        // 记录一下子区域是否存在被去重的数据,在保存提交时需要用来判断
        area.childrenExistDisabled = true
        recursiveUpdate(area.children, existItem.children)
      } else if (existItem.code) {
        area.disabled = true
      }

      if (!area.disabled) {
        // 子级全选的话,父级也需要变成禁用
        area.disabled = area.children && area.children.every(t => t.disabled)
      }
    })
  }

  areaData.value.map(t => {
    recursiveUpdate(t.children, existArr)
    t.disabled = t.children.every(t => t.disabled)
  })
}

disabled目前我公司的业务逻辑是直接不显示的,所以我就直接在v-if中加上这个条件就可以了,这个大家可以根据自己的业务情况来进行调整

<el-checkbox
             style="display: block"
             :indeterminate="item.isIndeterminate"
             :value="item"
             v-if="!item.disabled"
             @change="
                      value => {
                      checkboxChangeHandle(value, item)
                      }
                      "
             >
</el-checkbox>

childrenExistDisabled这个字段主要用于保存的时候的判断逻辑,如果一个区域这个字段为true,就代表需要向下递归循环去判断子级,因为只要子级有数据被disabled,那就代表着你选择不可能是全选某个省,或者某个市.,比如广东省-广州市被去重了,那用户再次进来勾选广东省保存,这个时候要把广东省下所有市的数据返回出去,并不是只返回广东省就可以了,如果广东省没有区域被去重,就只返回广东省就可以了

组件的使用

<template>
  <div style="width: 100%;height:100%">

    <el-button type="primary" class="mb-[12px]" @click="addArea()">
      指定可配送区域
    </el-button>
    <el-table :data="tableData" style="width: 100%">
      <el-table-column prop="date" label="可配送区域">
        <template #default="{ row }">
          {{ areaListToString(row) }}
        </template>
      </el-table-column>
      <el-table-column
        :prop="item.prop"
        :label="item.label"
        v-for="item in currentConfig.columns"
        :key="item.prop"
      >
        <template #default="{ row }">
          <el-input-number
            v-model="row[item.prop]"
            :precision="currentConfig.precision"
            :min="0"
          />
        </template>
      </el-table-column>
      <el-table-column label="操作" >
        <template #default="{ row, $index }">
          <div >
            <el-button text type="text" @click="editHandle(row,$index)">
              编辑
            </el-button>
            <el-button text type="text" @click="deleteHandle($index)">删除</el-button>
          </div>
        </template>
      </el-table-column>
    </el-table>

    <div class="flex justify-end mt-[12px]">
      <el-button type="primary" @click="submitHandle" :loading="saveLoading">
        保存
      </el-button>
    </div>

    <!--    指定可配送区域-->
    <areaForm
      v-if="showAreaForm"
      @close="showAreaForm = false"
      @save="saveAreaForm"
      :existAreaList="existAreaList"
      :editItem="currentTableItem"
    ></areaForm>
  </div>

</template>

<script setup >
import areaForm from './components/selectAreaDialog/area-form.vue'
import {ref} from "vue"

const saveLoading = ref(false)
const showAreaForm = ref(false)
const existAreaList = ref([])
const tableData = ref([])
const currentTableItem = ref('')


const currentConfig = ref({
  category: 2,
  unit: '个',
  precision: 0,
  columns: [
    {
      prop: 'calculateStartValue',
      label: '首件(个)'
    },
    {
      prop: 'calculateStartPrice',
      label: '运费(元)'
    },
    {
      prop: 'calculateRenewalValue',
      label: '续件(个)'
    },
    {
      prop: 'calculateRenewalPrice',
      label: '续费(元)'
    }
  ]
})


const deleteHandle = (index) => {
  tableData.value.splice(index, 1)
}

const areaListToString = row => {
  let arr = []

  const recursionFn = (item, str = '') => {
    str = str ? `${str},${item.name}` : item.name
    if (item.children?.length) {
      item.children.map(t => {
        recursionFn(t, str)
      })
    } else {
      arr.push(str)
    }
  }

  row.areaList.map(item => {
    recursionFn(item)
  })

  arr.map((item, index) => {
    arr[index] = item.replaceAll(',', '-')
  })
  return arr.join(',')
}

const getExistArea = (index) => {
  //   index,就代表是编辑,需要得到的是除了自身之外的所有已选区域的数据
  existAreaList.value = []
  let arr = tableData.value.filter((t, i) => {
    return i !== index
  })

  existAreaList.value = arr.reduce((acc, t) => {
    return acc.concat(t.areaList)
  }, [])
}

const addArea = item => {
  getExistArea()
  currentTableItem.value = ''
  showAreaForm.value = true
}

const editHandle = (row,index) => {
  getExistArea(index)
  currentTableItem.value = row
  showAreaForm.value = true
}


const saveAreaForm = values => {
  showAreaForm.value = false
  if (currentTableItem.value) {
    // 编辑状态下
    currentTableItem.value.areaList = values
    return
  }

  tableData.value.push({
    areaList: values,
    calculateStartValue: '',
    calculateStartPrice: '',
    calculateRenewalValue: '',
    calculateRenewalPrice: ''
  })
}

const submitHandle = async () => {
  console.log(tableData.value);
}
</script>

<style lang="scss" scoped>
.form-item-width {
  width: 400px;
}
</style>

总结

​ 逻辑上比较复杂的其实是父子区域的联动,这一块的话我感觉我写的也不是特别的好,小伙伴有更好的思路也可以提出来,虚心请教~下面是源码,需要的小伙伴自取就好

地理大区选择区域源码