区域选择组件(PC)

从零到一:开发高性能的PC端多选区域选择组件

在招聘需求发布场景中,常常需要选择多个地址,但组件库自带的级联选择往往存在局限性:只能选择到具体城市、无法多选父级地区、展示不够直观等。今天我将带大家从零开始,开发一个支持平铺多级展示、多选、搜索功能的PC端区域选择组件,并分享一些优化思路。

一、需求分析与组件设计

1. 为什么需要自定义区域选择组件?

在招聘系统中,HR发布职位时经常需要选择多个工作地点,比如"北京-海淀区"、"上海-浦东新区"等组合。现有组件存在以下问题:

  • 原生级联选择器只能单选,无法满足多选需求
  • 无法选择父级地区(如只选择"北京市"而不选择具体区)
  • 展示方式为树形结构,不够直观,操作效率低
  • 缺少已选地区的集中管理

原生级联选择组件功能展示:
image

优化后的组件:

image

2. 核心功能需求梳理

基于实际业务场景,我们需要实现以下功能:

  • 支持多选地区,包括父级和子级地区
  • 采用平铺方式展示地区数据,提高选择效率
  • 通过面包屑导航实现层级切换
  • 右侧面板展示已选地区,支持单独删除和一键清空
  • 支持国内/国外地区切换
  • 提供搜索功能快速定位地区

二、基础版本实现

如何实现地区数据的获取与处理?

首先,我们需要从接口获取地区数据并进行扁平化处理,以便后续操作:

async getTreeData() {
  try {
    const res = await request.get(
      `${service.atsService}/base/atsArea/queryAreaTree`,
      {
        homeAbroad: '1', // 1:国内,2:国外,这是接口约定的参数值
      }
    )

    const { flatArr, idMap } = this.getFlattenTree(res.result)
    this.treeData = flatArr
    this.treeIdObj = idMap
    // 100000是根节点ID,表示全国,这里获取所有省份数据
    this.showCity = this.treeData.filter(
      (item) => item.parentId === '100000'
    )
    if (this.value) {
      this.getCurrent()
    }
  } catch (e) {
    console.log('获取地区数据失败->', e)
  }
}

// 树结构平铺,将嵌套的树形结构转换为一维数组,便于后续查找和操作
getFlattenTree(tree) {
  const flatArr = []
  const idMap = {} // 创建ID到节点的映射,实现O(1)复杂度查找
  tree.forEach((itemNode) => {
    flattenTree(itemNode)
  })
  function flattenTree(node) {
    const newNode = {
      title: node.title, // 地区名称
      id: node.id, // 地区ID
      key: node.key, // 用于React/Vue列表渲染的唯一key
      checked: false, // 选中状态
      parentId: node.parentId, // 父级ID
      leaf: node.leaf, // 是否为叶子节点(是否还有子级)
    }
    flatArr.push(newNode)
    idMap[node.id] = newNode
    // 递归处理子节点
    if (node.children && node.children.length > 0) {
      node.children.forEach((child) => flattenTree(child))
    }
  }
  return { flatArr, idMap }
}

这段代码的关键在于将树形结构的数据扁平化为一维数组,并创建一个以ID为键的映射表,大大提高了后续查找的效率。

如何实现地区多选功能?

多选是本组件的核心功能之一,实现方式如下:

// 点击了省市区的checkbox
onCheckboxChange(checked, checkItem) {
  if (checked) {
    // 将完整的地区路径保存(从省份到当前选择项)
    this.alreadyCheckedCity.push([...this.stackProvince, checkItem])
  }
}

// 处理保存操作
handleSave() {
  this.selectCityCode = []
  this.selectOption = this.alreadyCheckedCity.map((item) => {
    if (Array.isArray(item) && item.length > 0) {
      // 拼接地区名称(如:北京/海淀区)
      const label = item.map((v) => v.title).join('/')
      // 拼接地区编码(如:110000,110108)
      const value = item.map((v) => v.key).join(',')
      this.selectCityCode.push(value)
      return { label, value }
    }
    return
  })
  // 使用'/'分隔多个地区选择(如:110000,110108/310000,310115)
  this.$emit('input', this.selectCityCode.join('/'))
  this.$emit('change', this.selectCityCode.join('/'))
  this.dropOpen = false
}

我们使用一个二维数组 alreadyCheckedCity 来存储已选地区,每条记录包含从省份到选择项的完整路径,这样既可以保存完整的地区层级信息,又能方便地拼接展示文本。

如何实现层级导航和展示?

为了实现直观的层级导航,我们使用面包屑组件并结合动态渲染:

// 点击了省市区的文字,进入下一级
childCheck(child) {
  if (child.leaf) return // 如果是叶子节点(如:北京市的区),则不继续深入
  this.stackProvince.push(child) // 将当前地区添加到路径栈中
  // 过滤出当前地区的所有子地区
  this.showCity = this.treeData.filter((item) => item.parentId === child.id)
}

// 面包屑点击事件,返回上一级
onBreadClick(item, index) {
  // 显示对应层级的子地区
  this.showCity = this.treeData.filter(child => child.parentId === item.id)
  // 截断路径栈,保留到点击的层级
  this.stackProvince = this.stackProvince.slice(0, index)
}

// 渲染地区列表
renderContent() {
  return (
    <div class="city-box">
      <div class="provice-title">
        <a-breadcrumb separator=">">
          <a-breadcrumb-item>
            <span style="cursor:pointer" onClick={() => {
              // 点击"全部省份"返回根层级
              this.showCity = this.treeData.filter(item => item.parentId === '100000')
              this.stackProvince = []
            }}>
              全部省份
            </span>
          </a-breadcrumb-item>
          {this.stackProvince.map((item, index) => {
            return (
              !item.leaf && (
                <a-breadcrumb-item href="javascript:void(0);">
                  <span onClick={() => this.onBreadClick(item, index)}>
                    {item.title}
                  </span>
                </a-breadcrumb-item>
              )
            )
          })}
        </a-breadcrumb>
      </div>
      {this.showCity.length > 0 &&
        this.showCity.map((item) => (
          <div class="city-item">
            <span class="check-content">
              <a-checkbox
                v-model={item.checked}
                onChange={(e) => this.onCheckboxChange(e.target.checked, item)}
              ></a-checkbox>
            </span>
            <div style="display:flex;align-items:center;" onClick={() => this.childCheck(item)}>
              <a-tooltip title={item.title.length > 4 ? item.title : null}>
                <span class={item.leaf ? 'city-name' : 'city-name city-name--hover'}>
                  {item.title}
                </span>
              </a-tooltip>
              {!item.leaf && <span class="arrow">{this.rightArrowSvg()}</span>}
            </div>
          </div>
        ))}
    </div>
  )
}

这种实现方式让用户可以像浏览文件夹一样逐层深入选择地区,同时保持了清晰的层级关系。

[展示层级导航效果图]

如何实现已选地区的管理?

为了方便用户管理已选地区,我们在右侧面板展示所有已选项,并提供删除功能:

// 清空已选或删除单个选项
onClear(index) {
  if (index === undefined) {
    // 清空所有选中项
    this.alreadyCheckedCity.forEach(item => {
      if (Array.isArray(item) && item.length > 0) {
        const lastChild = item[item.length - 1]
        lastChild.checked = false
      }
    })
    this.alreadyCheckedCity = []
  } else {
    // 删除单个选中项
    const item = this.alreadyCheckedCity[index]
    if (Array.isArray(item) && item.length > 0) {
      const lastChild = item[item.length - 1]
      lastChild.checked = false
    }
    this.alreadyCheckedCity.splice(index, 1)
  }
}

// 渲染右侧已选面板
renderRightShow() {
  return (
    <div class="right-show" ref="rightShowRef">
      <div>
        <div class="check-header">
          <div>
            <span class="show__title">已选地区</span>
            <span class="show__count">
              <span class="cur__count">{this.alreadyCheckedCity.length}</span>
              /99
            </span>
          </div>
          <span class="clear_check" onClick={() => this.onClear()}>清空已选</span>
        </div>
        <ul class="selected-box">
          {this.alreadyCheckedCity.map((item, index) => (
            <li class="show-item">
              <span class="show__name">
                {Array.isArray(item) && item.map((item) => item.title).join('/')}
              </span>
              <span class="show__close" onClick={() => this.onClear(index)}>
                <a-icon type="close-circle" style="color:#bfc3c7;width: 8px;" />
              </span>
            </li>
          ))}
        </ul>
      </div>
    </div>
  )
}

三、组件优化方案

1. 性能优化:如何避免重复渲染和无效计算?

问题分析
当前实现中,每次数据变化都可能导致组件的重新渲染,影响性能。

优化方案

// 优化前:直接在模板中使用方法
renderContent() {
  // ... 渲染逻辑
}

// 优化后:使用计算属性缓存结果
computed: {
  displayCityList() {
    // 只在依赖数据变化时重新计算
    return this.treeData.filter(item => item.parentId === this.currentParentId)
  }
}

// 优化前:频繁操作DOM
mounted() {
  document.addEventListener('mousedown', this.handleOutsideClick)
}

beforeDestroy() {
  document.removeEventListener('mousedown', this.handleOutsideClick)
}

// 优化后:使用Vue指令或ref
// 在组件内部使用自定义指令或ref来处理点击外部关闭的逻辑

2. 用户体验优化:如何实现搜索功能?

问题分析
当前组件缺少搜索功能,当地区数量较多时,用户查找困难。

优化方案

// 添加搜索功能
data() {
  return {
    // ... 原有数据
    searchKeyword: ''
  }
},

computed: {
  filteredCities() {
    if (!this.searchKeyword) {
      return this.showCity
    }
    return this.showCity.filter(item => 
      item.title.includes(this.searchKeyword)
    )
  }
},

methods: {
  // 搜索方法
  handleSearch(e) {
    this.searchKeyword = e.target.value
  }
}

// 在模板中添加搜索框
// <input type="text" v-model="searchKeyword" placeholder="搜索地区" />

[展示搜索功能效果图]

3. 代码健壮性:如何处理异步加载和错误状态?

问题分析
当前组件在数据加载过程中没有状态提示,错误处理也比较简单。

优化方案

data() {
  return {
    // ... 原有数据
    loading: false,
    error: null
  }
},

async getTreeData() {
  this.loading = true
  this.error = null
  try {
    // ... 原有数据获取逻辑
  } catch (e) {
    console.error('获取地区数据失败:', e)
    this.error = '获取地区数据失败,请稍后重试'
  } finally {
    this.loading = false
  }
}

// 在模板中添加加载和错误状态展示
// {this.loading && <div class="loading">加载中...</div>}
// {this.error && <div class="error">{this.error}</div>}

4. 代码结构优化:如何分离关注点?

问题分析
当前组件既负责数据获取又负责展示,不符合单一职责原则。

优化方案

// 1. 将数据获取逻辑抽离为单独的服务
// src/services/areaService.js
import request from '@/utils/http'
import service from '@/api/service'

export const getAreaTree = async (type = '1') => {
  try {
    // 调用地区树接口,参数type=1表示国内,type=2表示国外
    const res = await request.get(`${service.atsService}/base/atsArea/queryAreaTree`, {
      homeAbroad: type
    })
    return res.result
  } catch (error) {
    console.error('获取地区树失败:', error)
    throw error
  }
}

export const flattenAreaTree = (tree) => {
  // 扁平化处理逻辑...
}

// 2. 在组件中使用
import { getAreaTree, flattenAreaTree } from '@/services/areaService'

// 组件内使用
async getTreeData() {
  this.loading = true
  try {
    const treeData = await getAreaTree('1') // 获取国内地区数据
    const { flatArr, idMap } = flattenAreaTree(treeData)
    // ... 后续处理
  } catch (error) {
    // 错误处理
  } finally {
    this.loading = false
  }
}

5. 功能扩展:如何支持更多自定义配置?

问题分析
当前组件的配置项较少,难以适应不同的业务场景。

优化方案

props: {
  // ... 原有属性
  maxSelectCount: {
    type: Number,
    default: 99 // 默认最大选择数量
  },
  showSearch: {
    type: Boolean,
    default: true // 是否显示搜索框
  },
  showForeign: {
    type: Boolean,
    default: true // 是否显示国外选项
  },
  placeholder: {
    type: String,
    default: '请选择地区'
  },
  // 支持自定义地区数据,适用于没有接口的情况
  customAreaData: {
    type: Array,
    default: () => []
  }
},

// 在数据获取时优先使用自定义数据
async getTreeData() {
  if (this.customAreaData && this.customAreaData.length > 0) {
    const { flatArr, idMap } = this.getFlattenTree(this.customAreaData)
    this.treeData = flatArr
    this.treeIdObj = idMap
    this.showCity = this.treeData.filter(item => item.parentId === '100000')
    if (this.value) {
      this.getCurrent()
    }
    return
  }
  
  // 原有接口获取逻辑...
}

四、最佳实践与总结

  1. 性能优先:合理使用计算属性、缓存数据、避免不必要的DOM操作
  2. 用户体验:提供搜索、加载状态、错误提示等增强功能
  3. 代码健壮性:完善错误处理、边界情况检查
  4. 可维护性:遵循单一职责原则,分离数据获取和展示逻辑
  5. 扩展性:提供足够的配置项,支持不同业务场景

通过以上优化,我们的地区选择组件不仅功能完善,而且性能优良,能够为用户提供流畅直观的操作体验。在实际项目中,你还可以根据具体需求进一步定制和扩展。

五、完整优化版本代码

下面是整合了以上优化点的完整组件代码:

import request from '@/utils/http'
import service from '@/api/service'

export default {
  name: 'AreaSelect',
  props: {
    value: {
      type: String,
      default: '',
      // 父组件传递的已选地区值,格式为:110000,110108/310000,310115
    },
    disabled: {
      type: Boolean,
      default: false
    },
    placeholder: {
      type: String,
      default: '请选择'
    },
    maxSelectCount: {
      type: Number,
      default: 99 // 最大选择数量限制
    },
    showSearch: {
      type: Boolean,
      default: true // 是否显示搜索框
    },
    showForeign: {
      type: Boolean,
      default: true // 是否显示国外选项
    },
    customAreaData: {
      type: Array,
      default: () => [] // 自定义地区数据,优先级高于接口数据
    }
  },
  data() {
    return {
      treeData: [], // 扁平化后的完整地区数据
      treeIdObj: {}, // 以ID为键的地区数据映射,用于快速查找
      showCity: [], // 当前页面展示的地区数据
      dropOpen: false, // 下拉面板是否展开
      tabKey: '1', // 当前选中的标签页(1:国内,2:国外)
      stackProvince: [], // 当前面包屑路径栈
      alreadyCheckedCity: [], // 已选地区列表,二维数组保存完整路径
      selectOption: [], // 已选地区的选项格式,用于select组件显示
      selectCityCode: [], // 已选地区的编码列表
      searchKeyword: '', // 搜索关键词
      loading: false, // 数据加载状态
      error: null // 错误信息
    }
  },
  computed: {
    // 过滤后的地区列表,支持搜索功能
    filteredCities() {
      if (!this.searchKeyword) {
        return this.showCity
      }
      return this.showCity.filter(item => 
        item.title.toLowerCase().includes(this.searchKeyword.toLowerCase())
      )
    },
    // 是否可以继续添加地区
    canAddMore() {
      return this.alreadyCheckedCity.length < this.maxSelectCount
    },
    // 当前父级ID,用于计算属性依赖追踪
    currentParentId() {
      return this.stackProvince.length > 0 
        ? this.stackProvince[this.stackProvince.length - 1].id 
        : '100000'
    }
  },
  watch: {
    // 监听value变化,同步选中状态
    value: {
      immediate: true,
      handler(val) {
        if (val && this.treeData.length > 0) {
          this.getCurrent()
        }
      }
    }
  },
  created() {
    this.getTreeData()
  },
  mounted() {
    // 添加点击外部关闭下拉面板的事件监听
    document.addEventListener('mousedown', this.handleOutsideClick)
  },
  beforeDestroy() {
    // 组件销毁前移除事件监听
    document.removeEventListener('mousedown', this.handleOutsideClick)
  },
  methods: {
    // 点击外部关闭下拉面板
    handleOutsideClick(e) {
      const dropRenderBox = document.querySelector('.drop-render-box')
      if (dropRenderBox && !dropRenderBox.contains(e.target)) {
        this.dropOpen = false
      }
    },
    
    // 获取地区树数据
    async getTreeData() {
      this.loading = true
      this.error = null
      try {
        // 优先使用自定义数据
        if (this.customAreaData && this.customAreaData.length > 0) {
          const { flatArr, idMap } = this.getFlattenTree(this.customAreaData)
          this.treeData = flatArr
          this.treeIdObj = idMap
        } else {
          // 调用接口获取数据,1表示国内地区
          const res = await request.get(`${service.atsService}/base/atsArea/queryAreaTree`, {
            homeAbroad: '1',
          })
          const { flatArr, idMap } = this.getFlattenTree(res.result)
          this.treeData = flatArr
          this.treeIdObj = idMap
        }
        // 默认显示全国所有省份(parentId='100000')
        this.showCity = this.treeData.filter(item => item.parentId === '100000')
        // 如果有初始值,同步选中状态
        if (this.value) {
          this.getCurrent()
        }
      } catch (e) {
        console.error('获取地区数据失败:', e)
        this.error = '获取地区数据失败,请稍后重试'
      } finally {
        this.loading = false
      }
    },
    
    // 根据传入的value同步选中状态
    getCurrent() {
      const val = this.value
      // 按'/'分割多个地区选择
      const selectedAllCode = val.split('/')
      this.selectCityCode = [...selectedAllCode]
      
      // 处理每个地区选择
      this.selectCityCode.forEach((item) => {
        const codes = item.split(',') // 按','分割地区编码路径
        // 查找每个编码对应的地区信息
        const arr = codes.reduce((pre, cur) => {
          pre.push(this.treeIdObj[cur])
          return pre
        }, [])
        this.alreadyCheckedCity.push(arr)
      })
      
      // 合并所有选中的地区编码并去重
      const selectedCode = [
        ...new Set(
          selectedAllCode.reduce((pre, cur) => {
            pre.push(...cur.split(','))
            return pre
          }, [])
        ),
      ]
      
      // 生成select组件的选项格式
      this.selectOption = selectedAllCode.map((value) => {
        const label = value
          .split(',')
          .map((c) => this.treeIdObj[c].title)
          .join('/')
        return { label, value }
      })
      
      // 更新选中状态
      selectedCode.forEach((code) => {
        const item = this.treeIdObj[code]
        if (item) {
          item.checked = true
        }
      })
    },
    
    // 树结构平铺处理
    getFlattenTree(tree) {
      const flatArr = []
      const idMap = {}
      
      // 递归处理树节点
      function flattenTree(node) {
        const newNode = {
          title: node.title,
          id: node.id,
          key: node.key,
          checked: false,
          parentId: node.parentId,
          leaf: node.leaf,
        }
        flatArr.push(newNode)
        idMap[node.id] = newNode
        
        // 递归处理子节点
        if (node.children && node.children.length > 0) {
          node.children.forEach((child) => flattenTree(child))
        }
      }
      
      // 处理顶层节点
      tree.forEach((itemNode) => {
        flattenTree(itemNode)
      })
      
      return { flatArr, idMap }
    },
    
    // 渲染右侧箭头图标
    rightArrowSvg() {
      return (
        <svg
          width="10px"
          height="10px"
          viewBox="0 0 18 18"
          color="#146aff"
          class="area-icon-right"
        >
          <g fill="none" fill-rule="evenodd">
            <path d="M0 0h18v18H0z"></path>
            <path
              d="M10.5 9L5.304 3.04a1.32 1.32 0 0 1 .003-1.691.956.956 0 0 1 1.473-.004l5.914 6.786c.21.24.31.555.305.869.006.314-.096.63-.305.87l-5.914 6.785a.956.956 0 0 1-1.473-.004 1.32 1.32 0 0 1-.003-1.69L10.499 9z"
              fill="currentColor"
            ></path>
          </g>
        </svg>
      )
    },
    
    // 处理checkbox选择事件
    onCheckboxChange(checked, checkItem) {
      if (checked) {
        // 添加到已选列表,保存完整路径
        this.alreadyCheckedCity.push([...this.stackProvince, checkItem])
      } else {
        // 取消选择时,从已选列表中移除
        const index = this.alreadyCheckedCity.findIndex(item => 
          item.length > 0 && item[item.length - 1].id === checkItem.id
        )
        if (index > -1) {
          this.alreadyCheckedCity.splice(index, 1)
        }
      }
    },
    
    // 点击地区名称进入下一级
    childCheck(child) {
      if (child.leaf) return // 叶子节点不能继续深入
      this.stackProvince.push(child)
      // 过滤出当前地区的子地区
      this.showCity = this.treeData.filter((item) => item.parentId === child.id)
    },
    
    // 面包屑点击事件
    onBreadClick(item, index) {
      // 显示对应层级的子地区
      this.showCity = this.treeData.filter(
        (child) => child.parentId === item.id
      )
      // 截断路径栈
      this.stackProvince = this.stackProvince.slice(0, index)
    },
    
    // 搜索处理
    handleSearch(e) {
      this.searchKeyword = e.target.value
    },
    
    // 清空已选或删除单个选项
    onClear(index) {
      if (index === undefined) {
        // 清空所有选中项
        this.alreadyCheckedCity.forEach(item => {
          if (Array.isArray(item) && item.length > 0) {
            const lastChild = item[item.length - 1]
            lastChild.checked = false
          }
        })
        this.alreadyCheckedCity = []
      } else {
        // 删除单个选中项
        const item = this.alreadyCheckedCity[index]
        if (Array.isArray(item) && item.length > 0) {
          const lastChild = item[item.length - 1]
          lastChild.checked = false
        }
        this.alreadyCheckedCity.splice(index, 1)
      }
    },
    
    // 处理取消操作
    handleCancel() {
      this.dropOpen = false
    },
    
    // 处理保存操作
    handleSave() {
      this.selectCityCode = []
      this.selectOption = this.alreadyCheckedCity.map((item) => {
        if (Array.isArray(item) && item.length > 0) {
          const label = item.map((v) => v.title).join('/')
          const value = item.map((v) => v.key).join(',')
          this.selectCityCode.push(value)
          return { label, value }
        }
        return
      }).filter(Boolean)
      
      // 触发input和change事件,将选中的地区编码通过'/'分隔的字符串形式传递给父组件
      this.$emit('input', this.selectCityCode.join('/'))
      this.$emit('change', this.selectCityCode.join('/'))
      this.dropOpen = false
    },
    
    // 渲染下拉内容
    dropdownRender() {
      const tabOptions = [
        { title: '国内', key: '1' },
        { title: '国外', key: '2' }
      ]
      
      // 如果不显示国外选项,过滤掉国外标签
      const displayTabs = this.showForeign ? tabOptions : tabOptions.filter(tab => tab.key === '1')
      
      return (
        <div class="drop-render-box">
          <div class="content-box">
            <div class="left-check">
              <a-tabs animated={false} v-model={this.tabKey}>
                {displayTabs.map((item) => (
                  <a-tab-pane key={item.key} tab={item.title}></a-tab-pane>
                ))}
              </a-tabs>
              
              {/* 加载状态 */}
              {this.loading && <div class="loading">加载中...</div>}
              
              {/* 错误状态 */}
              {this.error && <div class="error">{this.error}</div>}
              
              {/* 国内地区内容 */}
              {!this.loading && !this.error && this.tabKey === '1' && this.renderContent()}
              
              {/* 国外地区内容 */}
              {!this.loading && !this.error && this.tabKey === '2' && this.renderForeignContent()}
            </div>
            
            {/* 右侧已选地区面板 */}
            {this.renderRightShow()}
          </div>
          
          {/* 底部按钮 */}
          <div class="drop-footer-btn">
            <a-button
              size="small"
              style="margin-right: 12px;"
              onClick={this.handleCancel}
            >
              取消
            </a-button>
            <a-button
              size="small"
              type="primary"
              onClick={() => this.handleSave()}
            >
              确定
            </a-button>
          </div>
        </div>
      )
    },
    
    // 渲染国内地区内容
    renderContent() {
      return (
        <div class="city-box">
          {/* 面包屑导航 */}
          <div class="provice-title">
            <a-breadcrumb separator=">">
              <a-breadcrumb-item>
                <span
                  style="cursor:pointer"
                  onClick={() => {
                    this.showCity = this.treeData.filter(
                      (item) => item.parentId === '100000'
                    )
                    this.stackProvince = []
                  }}
                >
                  全部省份
                </span>
              </a-breadcrumb-item>
              {this.stackProvince.map((item, index) => {
                return (
                  !item.leaf && (
                    <a-breadcrumb-item href="javascript:void(0);">
                      <span
                        onClick={() => {
                          this.onBreadClick(item, index)
                        }}
                      >
                        {item.title}
                      </span>
                    </a-breadcrumb-item>
                  )
                )
              })}
            </a-breadcrumb>
          </div>
          
          {/* 搜索框 */}
          {this.showSearch && (
            <div class="search-container">
              <input
                type="text"
                placeholder="搜索地区"
                value={this.searchKeyword}
                onChange={this.handleSearch}
              />
            </div>
          )}
          
          {/* 地区列表 */}
          {this.filteredCities.length > 0 &&
            this.filteredCities.map((item) => (
              <div class="city-item">
                <span class="check-content">
                  <a-checkbox
                    v-model={item.checked}
                    disabled={!this.canAddMore && !item.checked}
                    onChange={(e) => {
                      this.onCheckboxChange(e.target.checked, item)
                    }}
                  ></a-checkbox>
                </span>
                <div
                  style="display:flex;align-items:center;"
                  onClick={() => this.childCheck(item)}
                >
                  <a-tooltip title={item.title.length > 4 ? item.title : null}>
                    <span
                      class={
                        item.leaf ? 'city-name' : 'city-name city-name--hover'
                      }
                    >
                      {item.title}
                    </span>
                  </a-tooltip>
                  {!item.leaf && (
                    <span class="arrow">{this.rightArrowSvg()}</span>
                  )}
                </div>
              </div>
            ))}
            
          {/* 无搜索结果提示 */}
          {this.searchKeyword && this.filteredCities.length === 0 && (
            <div class="no-result">暂无匹配结果</div>
          )}
        </div>
      )
    },
    
    // 渲染国外地区内容
    renderForeignContent() {
      return <div class="city-box">暂无数据</div>
    },
    
    // 渲染右侧已选面板
    renderRightShow() {
      return (
        <div class="right-show" ref="rightShowRef">
          <div>
            <div class="check-header">
              <div>
                <span class="show__title">已选地区</span>
                <span class="show__count">
                  <span class="cur__count">
                    {this.alreadyCheckedCity.length}
                  </span>
                  /{this.maxSelectCount}
                </span>
              </div>
              <span 
                class="clear_check" 
                onClick={() => this.onClear()} 
                style={{cursor: this.alreadyCheckedCity.length > 0 ? 'pointer' : 'not-allowed'}}
              >
                清空已选
              </span>
            </div>
            <ul class="selected-box">
              {this.alreadyCheckedCity.map((item, index) => {
                return (
                  <li class="show-item">
                    <span class="show__name">
                      {Array.isArray(item) &&
                        item.map((item) => item.title).join('/')}
                    </span>
                    <span
                      class="show__close"
                      onClick={() => {
                        this.onClear(index)
                      }}
                    >
                      <a-icon
                        type="close-circle"
                        style="color:#bfc3c7;width: 8px;"
                      />
                    </span>
                  </li>
                )
              })}
            </ul>
          </div>
        </div>
      )
    }
  },
  
  // 组件渲染
  render() {
    return (
      <div class="area-select">
        <a-select
          style="width:100%;max-width:300px"
          placeholder={this.placeholder}
          open={this.dropOpen}
          maxTagCount={1}
          autoClearSearchValue={true}
          mode="multiple"
          disabled={this.disabled}
          v-model={this.selectCityCode}
          dropdownRender={this.dropdownRender}
          onFocus={() => {
            this.dropOpen = true
          }}
          options={this.selectOption}
        ></a-select>
      </div>
    )
  },
}
</script>
<style lang="less" scoped>
.drop-render-box {
  position: relative;
  width: 580px;
  height: 350px;
  background: #fff;
  padding: 10px 16px 16px;
  border-radius: 8px;
  border: 1px solid #ccc;
  font-size: 12px;
  color: #0e1114;
  
  .search-container {
    padding: 0px 10px 10px;
    /deep/ .ant-input {
      border-radius: 8px;
      height: 30px;
    }
  }
  
  .content-box {
    width: 100%;
    display: flex;
    border-bottom: 1px solid #f0f2f5;
    
    .left-check {
      width: 80%;
      box-sizing: border-box;
      border-right: 1px solid #f0f2f5;
    }
    
    .right-show {
      width: 40%;
      
      .check-header {
        height: 40px;
        line-height: 40px;
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding-left: 8px;
        
        .show__title {
          margin-right: 6px;
        }
        
        .show__count {
          color: #bfc3c7;
          
          .cur__count {
            color: #565e66;
          }
        }
        
        .clear_check {
          cursor: pointer;
        }
      }
      
      .selected-box {
        overflow-y: auto;
        max-height: 250px;
        
        &::-webkit-scrollbar {
          width: 4px;
        }
        
        &::-webkit-scrollbar-thumb {
          background: #888;
          border-radius: 4px;
        }
      }
      
      .show-item {
        display: inline-block;
        margin-left: 10px;
        padding: 4px 0;
        
        .show__name {
          margin-right: 4px;
        }
        
        .show__close {
          cursor: pointer;
        }
      }
    }
    
    .city-box {
      height: 230px;
      overflow-y: auto;
      display: flex;
      align-items: center;
      align-content: flex-start;
      flex-wrap: wrap;
      
      /deep/ .ant-checkbox-inner {
        width: 12px;
        height: 12px;
        
        &::after {
          top: 4px;
          left: 1px;
          width: 5px;
          height: 10px;
        }
      }
      
      row-gap: 8px;
      
      &::-webkit-scrollbar {
        width: 4px;
      }
      
      &::-webkit-scrollbar-track {
        background: #f1f1f1;
      }
      
      &::-webkit-scrollbar-thumb {
        background: #888;
        border-radius: 4px;
      }
    }
    
    .provice-title {
      width: 100%;
      padding-bottom: 2px;
      background: #fff;
      z-index: 99;
      position: sticky;
      top: 0;
    }
    
    .city-item {
      width: 110px;
      margin-left: 4px;
      display: flex;
      align-items: center;
      
      .check-content {
        margin-right: 6px;
      }
      
      .city-name {
        display: inline-block;
        max-width: 60px;
        font-size: 12px;
        cursor: pointer;
        text-overflow: ellipsis;
        overflow: hidden;
        white-space: nowrap;
      }
      
      .city-name--hover {
        &:hover {
          color: #1890ff;
          
          + .arrow {
            display: inline;
          }
        }
      }
      
      .arrow {
        display: none;
      }
    }
    
    .loading,
    .error,
    .no-result {
      width: 100%;
      height: 230px;
      display: flex;
      align-items: center;
      justify-content: center;
      color: #999;
    }
    
    .error {
      color: #ff4d4f;
    }
  }
  
  .drop-footer-btn {
    position: absolute;
    bottom: 0;
    right: 0;
    display: flex;
    justify-content: flex-end;
    padding: 8px 10px;
    
    /deep/ .ant-btn {
      display: inline-block;
      width: 60px;
      height: 28px;
      text-align: center;
      line-height: 28px;
    }
  }
}
</style>

posted @ 2025-12-01 12:26  含若飞  阅读(10)  评论(0)    收藏  举报