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 = []
}
}
5269

被折叠的 条评论
为什么被折叠?



