级联框组件实现-版本2

1.子组件

<template>
  <div class="cascader-select">
    <!-- 输入框 -->
    <div 
      class="input-wrapper"
      @click="toggleDropdown"
    >
      <input
        v-model="displayValue"
        :placeholder="placeholder"
        class="cascader-input"
        readonly
      />
      <div class="input-icons">
        <span 
          v-if="selectedPath.length > 0"
          class="clear-icon"
          @click.stop="handleClear"
          title="清空选择"
        >
          ×
        </span>
        <span 
          class="arrow-icon"
          :class="{ 'arrow-up': showDropdown }"
        ></span>
      </div>
    </div>

    <!-- 级联下拉框 -->
    <div 
      v-show="showDropdown" 
      class="cascader-dropdown"
    >
      <div class="cascader-content">
        <!-- 级联头部标注 -->
        <div class="cascader-header">
          <div 
            v-for="(panel, level) in cascaderPanels"
            :key="`header-${level}`"
            class="cascader-header-item"
          >
            {{ getHeaderLabel(level) }}
          </div>
        </div>
        
        <!-- 级联面板 -->
        <div class="cascader-panels">
          <div
            v-for="(panel, level) in cascaderPanels"
            :key="level"
            class="cascader-panel"
          >
            <div
              v-for="option in panel"
              :key="option.name"
              class="cascader-option"
              :class="{
                'is-active': isOptionActive(option, level),
                'is-selected': isOptionSelected(option, level),
                'has-children': option.children && option.children.length > 0
              }"
              @click="handleOptionClick(option, level)"
            >
              <span class="option-label">{{ option.nameCn }}</span>
              <span 
                v-if="option.children && option.children.length > 0"
                class="option-arrow"
              ></span>
            </div>
          </div>
        </div>
      </div>

      <!-- 操作按钮 -->
      <div class="cascader-footer">
        <button 
          class="btn btn-cancel"
          @click="handleCancel"
        >
          取消
        </button>
        <button 
          class="btn btn-confirm"
          @click="handleConfirm"
          :disabled="!tempSelectedPath.length"
        >
          确定
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch } from 'vue'

// 类型定义
export interface CascaderOption {
  name: string
  nameCn: string
  nameEn: string
  children?: CascaderOption[]
}

export interface CascaderProps {
  options: CascaderOption[]
  modelValue?: string[]
  placeholder?: string
  headerLabels?: string[]
}

export interface CascaderEmits {
  (event: 'update:modelValue', value: string[]): void
  (event: 'change', value: string[], selectedOptions: CascaderOption[]): void
}

// 定义props,带默认值
const props = withDefaults(defineProps<CascaderProps>(), {
  modelValue: () => [],
  placeholder: '请选择',
  headerLabels: () => ['分类', '子分类', '组件', '详细', '子项']
})

// 定义emits
const emit = defineEmits<CascaderEmits>()

// 响应式数据
const showDropdown = ref<boolean>(false)
const tempSelectedPath = ref<string[]>([...props.modelValue])
const selectedPath = ref<string[]>([...props.modelValue])

// 计算属性 - 级联面板数据
const cascaderPanels = computed<CascaderOption[][]>(() => {
  const panels: CascaderOption[][] = [props.options]
  
  let currentOptions: CascaderOption[] = props.options
  for (let i = 0; i < tempSelectedPath.value.length; i++) {
    const selectedValue = tempSelectedPath.value[i]
    const selectedOption = currentOptions.find((option: CascaderOption) => option.name === selectedValue)
    
    if (selectedOption && selectedOption.children && selectedOption.children.length > 0) {
      panels.push(selectedOption.children)
      currentOptions = selectedOption.children
    } else {
      break
    }
  }
  
  return panels
})

// 计算属性 - 显示值(只显示最后一级的label)
const displayValue = computed<string>(() => {
  if (!selectedPath.value.length) return ''
  
  let currentOptions: CascaderOption[] = props.options
  let finalLabel = ''
  
  for (const value of selectedPath.value) {
    const option = currentOptions.find((opt: CascaderOption) => opt.name === value)
    if (option) {
      finalLabel = option.nameCn
      currentOptions = option.children || []
    }
  }
  
  return finalLabel
})

// 方法 - 切换下拉框显示状态
const toggleDropdown = (): void => {
  showDropdown.value = !showDropdown.value
  if (showDropdown.value) {
    // 打开时重置临时选中路径
    tempSelectedPath.value = [...selectedPath.value]
  }
}

// 方法 - 检查选项是否处于激活状态
const isOptionActive = (option: CascaderOption, level: number): boolean => {
  return tempSelectedPath.value[level] === option.name
}

// 方法 - 检查选项是否已选中
const isOptionSelected = (option: CascaderOption, level: number): boolean => {
  return selectedPath.value[level] === option.name
}

// 方法 - 处理选项点击
const handleOptionClick = (option: CascaderOption, level: number): void => {
  const newPath = tempSelectedPath.value.slice(0, level)
  newPath.push(option.name)
  tempSelectedPath.value = newPath
}

// 方法 - 确认选择
const handleConfirm = (): void => {
  selectedPath.value = [...tempSelectedPath.value]
  
  // 获取选中的完整选项对象
  const selectedOptions: CascaderOption[] = []
  let currentOptions: CascaderOption[] = props.options
  
  for (const value of selectedPath.value) {
    const option = currentOptions.find((opt: CascaderOption) => opt.name === value)
    if (option) {
      selectedOptions.push(option)
      currentOptions = option.children || []
    }
  }
  
  // 触发事件
  emit('update:modelValue', selectedPath.value)
  emit('change', selectedPath.value, selectedOptions)
  
  // 关闭下拉框
  showDropdown.value = false
}

// 方法 - 取消选择
const handleCancel = (): void => {
  tempSelectedPath.value = [...selectedPath.value]
  showDropdown.value = false
}

// 方法 - 清空选择
const handleClear = (): void => {
  selectedPath.value = []
  tempSelectedPath.value = []
  emit('update:modelValue', [])
  emit('change', [], [])
  showDropdown.value = false
}

// 方法 - 获取头部标签
const getHeaderLabel = (level: number): string => {
  return props.headerLabels[level] || `级别 ${level + 1}`
}

// 监听外部值变化
watch(() => props.modelValue, (newValue: string[]) => {
  selectedPath.value = [...newValue]
  tempSelectedPath.value = [...newValue]
}, { deep: true })

// 点击外部关闭下拉框
const handleClickOutside = (event: Event): void => {
  const target = event.target as HTMLElement
  const cascaderElement = document.querySelector('.cascader-select') as HTMLElement
  
  if (cascaderElement && !cascaderElement.contains(target)) {
    showDropdown.value = false
  }
}

// 添加/移除全局点击监听
watch(showDropdown, (isShow: boolean) => {
  if (isShow) {
    document.addEventListener('click', handleClickOutside)
  } else {
    document.removeEventListener('click', handleClickOutside)
  }
})

// 组件卸载时清理事件监听
import { onUnmounted } from 'vue'
onUnmounted(() => {
  document.removeEventListener('click', handleClickOutside)
})
</script>

<style scoped>
.cascader-select {
  position: relative;
  display: inline-block;
  width: 100%;
}

.input-wrapper {
  position: relative;
  cursor: pointer;
}

.cascader-input {
  width: 100%;
  padding: 12px 60px 12px 16px;
  border: 2px solid #e9ecef;
  border-radius: 8px;
  background-color: #fff;
  cursor: pointer;
  transition: all 0.3s ease;
  font-size: 14px;
  line-height: 1.5;
  box-sizing: border-box;
}

.cascader-input:hover {
  border-color: #667eea;
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}

.cascader-input:focus {
  outline: none;
  border-color: #667eea;
  box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}

.input-icons {
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  gap: 4px;
}

.clear-icon {
  color: #9ca3af;
  font-size: 14px;
  cursor: pointer;
  padding: 2px;
  border-radius: 3px;
  transition: all 0.2s ease;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
}

.clear-icon:hover {
  color: #6b7280;
  background-color: #f3f4f6;
}

.arrow-icon {
  color: #adb5bd;
  font-size: 12px;
  transition: transform 0.3s ease;
  pointer-events: none;
  margin-left: 4px;
}

.arrow-icon.arrow-up {
  transform: rotate(180deg);
}

.cascader-dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  background: white;
  border: 2px solid #e9ecef;
  border-radius: 12px;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
  z-index: 2000;
  margin-top: 8px;
  max-height: 450px;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.cascader-content {
  flex: 1;
  overflow: hidden;
}

.cascader-header {
  display: flex;
  border-bottom: 2px solid #e9ecef;
  background: #fff;
}

.cascader-header-item {
  flex: 1;
  min-width: 100px;
  width: 100px;
  padding: 8px 12px;
  font-weight: 600;
  font-size: 13px;
  color: #667eea;
  text-align: left;
  border-right: 1px solid #dee2e6;
  background: #fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}

.cascader-header-item:last-child {
  border-right: none;
}

.cascader-panels {
  display: flex;
  max-height: 350px;
  overflow: auto;
}

.cascader-panel {
  flex: 1;
  min-width: 100px;
  width: 100px;
  border-right: 1px solid #e9ecef;
  background: #fff;
}

.cascader-panel:last-child {
  border-right: none;
}

.cascader-option {
  padding: 8px 12px;
  cursor: pointer;
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-size: 13px;
  color: #495057;
  transition: all 0.2s ease;
  position: relative;
}

.cascader-option:hover {
  background-color: #f8f9fa;
  color: #667eea;
}

.cascader-option.is-active {
  color: #667eea;
  background-color: #e7f3ff;
  font-weight: 500;
}

.cascader-option.is-selected {
  color: #667eea;
  font-weight: 600;
}

.cascader-option.has-children {
  padding-right: 40px;
}

.option-label {
  flex: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.option-arrow {
  color: #adb5bd;
  font-size: 12px;
  margin-left: 8px;
  font-weight: bold;
}

.cascader-footer {
  border-top: 1px solid #e9ecef;
  padding: 16px 20px;
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  background: #fff;
}

.btn {
  padding: 8px 20px;
  border-radius: 6px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  border: 2px solid;
  transition: all 0.3s ease;
}

.btn-cancel {
  background: #fff;
  color: #6c757d;
  border-color: #dee2e6;
}

.btn-cancel:hover {
  color: #495057;
  border-color: #adb5bd;
  background-color: #f8f9fa;
}

.btn-confirm {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: #fff;
  border-color: transparent;
}

.btn-confirm:hover:not(:disabled) {
  transform: translateY(-1px);
  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}

.btn-confirm:disabled {
  background: #adb5bd;
  border-color: #adb5bd;
  cursor: not-allowed;
  transform: none;
}

@media (max-width: 768px) {
  .cascader-panel {
    min-width: 100px;
    width: 100px;
  }
  
  .cascader-header-item {
    min-width: 100px;
    width: 100px;
    font-size: 12px;
    padding: 6px 8px;
  }
  
  .cascader-option {
    padding: 6px 8px;
    font-size: 12px;
  }
}
</style>

2.父组件

 <cascader-select
    :model-value="selectedValue"
   :options="fourLevelOptions"
      placeholder="请选择组件分类..."
      :header-labels="headerLabels"
      @update:model-value="updateSelectedValue"
      @change="handleCascaderChange"
     ></cascader-select>

 // 方法
const updateSelectedValue = (value) => {
   selectedValue.value = value
 }
const handleCascaderChange = (value, selectedOptions) => {
    selectedLabels.value = selectedOptions.map(opt => opt.nameCn)
     
     console.log(`级联选择器变更: ${selectedLabels.value.join(' → ')}`)
     console.log(`选择层级: 第 ${value.length} 层,提取请求参数: ${requestParam.value}`)
     
     if (requestParam.value) {
         loadTableData(requestParam.value)
     } else {
         tableData.value = []
     }
 }
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序媛夏天

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值