|
@@ -0,0 +1,411 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <div class="region-cascader">
|
|
|
|
|
+ <div class="region-selector">
|
|
|
|
|
+ <!-- 左侧:省份列表 -->
|
|
|
|
|
+ <div class="region-list province-list">
|
|
|
|
|
+ <div class="list-header">省份</div>
|
|
|
|
|
+ <div class="list-body">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="province in provinces"
|
|
|
|
|
+ :key="province.value"
|
|
|
|
|
+ class="region-item"
|
|
|
|
|
+ :class="{ 'is-active': isProvinceSelected(province.value), 'is-checked': isProvinceChecked(province.value) }"
|
|
|
|
|
+ @click="handleProvinceClick(province)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-checkbox
|
|
|
|
|
+ v-if="multiple"
|
|
|
|
|
+ :model-value="isProvinceChecked(province.value)"
|
|
|
|
|
+ @click.stop
|
|
|
|
|
+ @change="handleProvinceCheck(province, $event)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ province.label }}
|
|
|
|
|
+ </el-checkbox>
|
|
|
|
|
+ <span v-else>{{ province.label }}</span>
|
|
|
|
|
+ <el-icon v-if="province.children && province.children.length > 0" class="arrow-icon">
|
|
|
|
|
+ <ArrowRight />
|
|
|
|
|
+ </el-icon>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 中间:城市列表 -->
|
|
|
|
|
+ <div class="region-list city-list">
|
|
|
|
|
+ <div class="list-header">城市</div>
|
|
|
|
|
+ <div class="list-body">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="city in currentCities"
|
|
|
|
|
+ :key="city.value"
|
|
|
|
|
+ class="region-item"
|
|
|
|
|
+ :class="{ 'is-active': isCitySelected(city.value), 'is-checked': isCityChecked(city.value) }"
|
|
|
|
|
+ @click="handleCityClick(city)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-checkbox
|
|
|
|
|
+ v-if="multiple"
|
|
|
|
|
+ :model-value="isCityChecked(city.value)"
|
|
|
|
|
+ @click.stop
|
|
|
|
|
+ @change="handleCityCheck(city, $event)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ city.label }}
|
|
|
|
|
+ </el-checkbox>
|
|
|
|
|
+ <span v-else>{{ city.label }}</span>
|
|
|
|
|
+ <el-icon v-if="city.children && city.children.length > 0" class="arrow-icon">
|
|
|
|
|
+ <ArrowRight />
|
|
|
|
|
+ </el-icon>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="currentCities.length === 0" class="empty-text">
|
|
|
|
|
+ 请先选择省份
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <!-- 右侧:区县列表 -->
|
|
|
|
|
+ <div class="region-list district-list">
|
|
|
|
|
+ <div class="list-header">区县</div>
|
|
|
|
|
+ <div class="list-body">
|
|
|
|
|
+ <div
|
|
|
|
|
+ v-for="district in currentDistricts"
|
|
|
|
|
+ :key="district.value"
|
|
|
|
|
+ class="region-item"
|
|
|
|
|
+ :class="{ 'is-checked': isDistrictChecked(district.value) }"
|
|
|
|
|
+ @click="handleDistrictClick(district)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <el-checkbox
|
|
|
|
|
+ v-if="multiple"
|
|
|
|
|
+ :model-value="isDistrictChecked(district.value)"
|
|
|
|
|
+ @change="handleDistrictCheck(district, $event)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ district.label }}
|
|
|
|
|
+ </el-checkbox>
|
|
|
|
|
+ <span v-else>{{ district.label }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-if="currentDistricts.length === 0" class="empty-text">
|
|
|
|
|
+ 请先选择城市
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+import { ref, computed, watch } from 'vue';
|
|
|
|
|
+import { regionData } from 'element-china-area-data';
|
|
|
|
|
+import { ArrowRight } from '@element-plus/icons-vue';
|
|
|
|
|
+
|
|
|
|
|
+interface RegionData {
|
|
|
|
|
+ label: string;
|
|
|
|
|
+ value: string;
|
|
|
|
|
+ children?: RegionData[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface Props {
|
|
|
|
|
+ modelValue?: string[]; // 选中的区域代码数组
|
|
|
|
|
+ multiple?: boolean; // 是否多选模式,默认 true
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface Emits {
|
|
|
|
|
+ (e: 'update:modelValue', value: string[]): void;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
+ modelValue: () => [],
|
|
|
|
|
+ multiple: true
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+const emit = defineEmits<Emits>();
|
|
|
|
|
+
|
|
|
|
|
+// 所有省份
|
|
|
|
|
+const provinces = ref<RegionData[]>(regionData as RegionData[]);
|
|
|
|
|
+
|
|
|
|
|
+// 当前选中的省份
|
|
|
|
|
+const currentProvince = ref<RegionData | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+// 当前选中的城市
|
|
|
|
|
+const currentCity = ref<RegionData | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+// 当前省份下的城市列表
|
|
|
|
|
+const currentCities = computed(() => {
|
|
|
|
|
+ return currentProvince.value?.children || [];
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 当前城市下的区县列表
|
|
|
|
|
+const currentDistricts = computed(() => {
|
|
|
|
|
+ return currentCity.value?.children || [];
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+// 选中的区域代码
|
|
|
|
|
+const selectedRegions = ref<string[]>(props.modelValue || []);
|
|
|
|
|
+
|
|
|
|
|
+// 监听 props 变化
|
|
|
|
|
+watch(() => props.modelValue, (newVal) => {
|
|
|
|
|
+ selectedRegions.value = newVal || [];
|
|
|
|
|
+}, { immediate: true });
|
|
|
|
|
+
|
|
|
|
|
+/** 判断省份是否被选中(显示箭头高亮) */
|
|
|
|
|
+const isProvinceSelected = (provinceCode: string) => {
|
|
|
|
|
+ return currentProvince.value?.value === provinceCode;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 判断省份是否被勾选 */
|
|
|
|
|
+const isProvinceChecked = (provinceCode: string) => {
|
|
|
|
|
+ const province = provinces.value.find(p => p.value === provinceCode);
|
|
|
|
|
+ if (!province || !province.children) return false;
|
|
|
|
|
+
|
|
|
|
|
+ // 递归检查该省份下所有区县是否都被选中
|
|
|
|
|
+ const allDistrictCodes: string[] = [];
|
|
|
|
|
+ province.children.forEach(city => {
|
|
|
|
|
+ if (city.children) {
|
|
|
|
|
+ city.children.forEach(district => {
|
|
|
|
|
+ allDistrictCodes.push(district.value);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return allDistrictCodes.length > 0 && allDistrictCodes.every(code => selectedRegions.value.includes(code));
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 判断城市是否被选中(显示箭头高亮) */
|
|
|
|
|
+const isCitySelected = (cityCode: string) => {
|
|
|
|
|
+ return currentCity.value?.value === cityCode;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 判断城市是否被勾选 */
|
|
|
|
|
+const isCityChecked = (cityCode: string) => {
|
|
|
|
|
+ const city = currentCities.value.find(c => c.value === cityCode);
|
|
|
|
|
+ if (!city || !city.children) return false;
|
|
|
|
|
+
|
|
|
|
|
+ // 检查该城市下所有区县是否都被选中
|
|
|
|
|
+ return city.children.every(district => selectedRegions.value.includes(district.value));
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 判断区县是否被勾选 */
|
|
|
|
|
+const isDistrictChecked = (districtCode: string) => {
|
|
|
|
|
+ return selectedRegions.value.includes(districtCode);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 点击省份 */
|
|
|
|
|
+const handleProvinceClick = (province: RegionData) => {
|
|
|
|
|
+ currentProvince.value = province;
|
|
|
|
|
+ currentCity.value = null; // 重置城市选择
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 点击城市 */
|
|
|
|
|
+const handleCityClick = (city: RegionData) => {
|
|
|
|
|
+ currentCity.value = city;
|
|
|
|
|
+ // 单选模式下不做选中操作
|
|
|
|
|
+ if (!props.multiple) {
|
|
|
|
|
+ // 单选模式下只展开城市,不自动选择
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 点击区县(单选模式) */
|
|
|
|
|
+const handleDistrictClick = (district: RegionData) => {
|
|
|
|
|
+ if (!props.multiple) {
|
|
|
|
|
+ // 单选模式:清空其他选中,只选中当前区县
|
|
|
|
|
+ selectedRegions.value = [district.value];
|
|
|
|
|
+ emit('update:modelValue', selectedRegions.value);
|
|
|
|
|
+ }
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 勾选/取消省份 */
|
|
|
|
|
+const handleProvinceCheck = (province: RegionData, checked: boolean | string | number) => {
|
|
|
|
|
+ const isChecked = !!checked;
|
|
|
|
|
+ if (!province.children) return;
|
|
|
|
|
+
|
|
|
|
|
+ // 获取该省份下所有区县代码
|
|
|
|
|
+ const districtCodes: string[] = [];
|
|
|
|
|
+ province.children.forEach(city => {
|
|
|
|
|
+ if (city.children) {
|
|
|
|
|
+ city.children.forEach(district => {
|
|
|
|
|
+ districtCodes.push(district.value);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (isChecked) {
|
|
|
|
|
+ // 添加该省份下所有区县
|
|
|
|
|
+ const newRegions = [...selectedRegions.value];
|
|
|
|
|
+ districtCodes.forEach(code => {
|
|
|
|
|
+ if (!newRegions.includes(code)) {
|
|
|
|
|
+ newRegions.push(code);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ selectedRegions.value = newRegions;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 移除该省份下所有区县
|
|
|
|
|
+ selectedRegions.value = selectedRegions.value.filter(code => !districtCodes.includes(code));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ emit('update:modelValue', selectedRegions.value);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 勾选/取消城市 */
|
|
|
|
|
+const handleCityCheck = (city: RegionData, checked: boolean | string | number) => {
|
|
|
|
|
+ const isChecked = !!checked;
|
|
|
|
|
+ if (!city.children) return;
|
|
|
|
|
+
|
|
|
|
|
+ const districtCodes = city.children.map(district => district.value);
|
|
|
|
|
+
|
|
|
|
|
+ if (isChecked) {
|
|
|
|
|
+ // 添加该城市下所有区县
|
|
|
|
|
+ const newRegions = [...selectedRegions.value];
|
|
|
|
|
+ districtCodes.forEach(code => {
|
|
|
|
|
+ if (!newRegions.includes(code)) {
|
|
|
|
|
+ newRegions.push(code);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ selectedRegions.value = newRegions;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 移除该城市下所有区县
|
|
|
|
|
+ selectedRegions.value = selectedRegions.value.filter(code => !districtCodes.includes(code));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ emit('update:modelValue', selectedRegions.value);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 勾选/取消区县 */
|
|
|
|
|
+const handleDistrictCheck = (district: RegionData, checked: boolean | string | number) => {
|
|
|
|
|
+ const isChecked = !!checked;
|
|
|
|
|
+ if (isChecked) {
|
|
|
|
|
+ selectedRegions.value.push(district.value);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ selectedRegions.value = selectedRegions.value.filter(code => code !== district.value);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ emit('update:modelValue', selectedRegions.value);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+/** 获取选中的区域名称列表 */
|
|
|
|
|
+const getSelectedRegionNames = () => {
|
|
|
|
|
+ const names: string[] = [];
|
|
|
|
|
+
|
|
|
|
|
+ provinces.value.forEach(province => {
|
|
|
|
|
+ if (province.children) {
|
|
|
|
|
+ province.children.forEach(city => {
|
|
|
|
|
+ if (city.children) {
|
|
|
|
|
+ city.children.forEach(district => {
|
|
|
|
|
+ if (selectedRegions.value.includes(district.value)) {
|
|
|
|
|
+ names.push(`${province.label}-${city.label}-${district.label}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ return names;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// 暴露方法给父组件
|
|
|
|
|
+defineExpose({
|
|
|
|
|
+ getSelectedRegionNames
|
|
|
|
|
+});
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped>
|
|
|
|
|
+.region-cascader {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.region-selector {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ height: 400px;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.region-list {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ border-right: 1px solid #dcdfe6;
|
|
|
|
|
+ min-width: 200px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.region-list:last-child {
|
|
|
|
|
+ border-right: none;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.list-header {
|
|
|
|
|
+ padding: 12px 16px;
|
|
|
|
|
+ background: #f5f7fa;
|
|
|
|
|
+ border-bottom: 1px solid #dcdfe6;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ color: #303133;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.list-body {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ padding: 8px 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.region-item {
|
|
|
|
|
+ padding: 8px 16px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ transition: background-color 0.2s;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.region-item:hover {
|
|
|
|
|
+ background: #f5f7fa;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.region-item.is-active {
|
|
|
|
|
+ background: #ecf5ff;
|
|
|
|
|
+ color: #409eff;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.region-item.is-checked {
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.arrow-icon {
|
|
|
|
|
+ color: #909399;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ margin-left: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.region-item.is-active .arrow-icon {
|
|
|
|
|
+ color: #409eff;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.empty-text {
|
|
|
|
|
+ padding: 20px;
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ color: #909399;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 复选框样式调整 */
|
|
|
|
|
+.region-item :deep(.el-checkbox) {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.region-item :deep(.el-checkbox__label) {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ padding-left: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/* 滚动条样式 */
|
|
|
|
|
+.list-body::-webkit-scrollbar {
|
|
|
|
|
+ width: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.list-body::-webkit-scrollbar-thumb {
|
|
|
|
|
+ background: #dcdfe6;
|
|
|
|
|
+ border-radius: 3px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.list-body::-webkit-scrollbar-thumb:hover {
|
|
|
|
|
+ background: #c0c4cc;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.list-body::-webkit-scrollbar-track {
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|