|
|
@@ -0,0 +1,354 @@
|
|
|
+<template>
|
|
|
+ <div class="smart-select" ref="selectWrapper">
|
|
|
+ <!-- 合并的输入框 -->
|
|
|
+ <input
|
|
|
+ ref="inputRef"
|
|
|
+ v-model="inputValue"
|
|
|
+ :placeholder="placeholder"
|
|
|
+ class="smart-input"
|
|
|
+ :class="{ 'is-focused': showOptions }"
|
|
|
+ @focus="handleFocus"
|
|
|
+ @blur="handleBlur"
|
|
|
+ @input="handleInput"
|
|
|
+ @keydown="handleKeydown"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- 下拉选项 -->
|
|
|
+ <div v-if="showOptions && filteredOptions.length > 0" class="smart-dropdown">
|
|
|
+ <div class="options-list">
|
|
|
+ <div
|
|
|
+ v-for="(option, index) in filteredOptions"
|
|
|
+ :key="getOptionValue(option)"
|
|
|
+ @mousedown="handleOptionClick(option)"
|
|
|
+ class="option-item"
|
|
|
+ :class="{
|
|
|
+ 'is-selected': isSelected(option),
|
|
|
+ 'is-hovered': hoverIndex === index
|
|
|
+ }"
|
|
|
+ >
|
|
|
+ {{ getOptionLabel(option) }}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 无匹配数据提示 -->
|
|
|
+ <div v-if="showOptions && filteredOptions.length === 0" class="no-match">
|
|
|
+ 无匹配数据
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
|
|
+
|
|
|
+ const props = defineProps({
|
|
|
+ modelValue: {
|
|
|
+ type: [String, Number],
|
|
|
+ default: ''
|
|
|
+ },
|
|
|
+ options: {
|
|
|
+ type: Array,
|
|
|
+ default: () => []
|
|
|
+ },
|
|
|
+ placeholder: {
|
|
|
+ type: String,
|
|
|
+ default: '请选择或输入'
|
|
|
+ },
|
|
|
+ valueKey: {
|
|
|
+ type: String,
|
|
|
+ default: 'alarmType'
|
|
|
+ },
|
|
|
+ labelKey: {
|
|
|
+ type: String,
|
|
|
+ default: 'alarmTypeName'
|
|
|
+ },
|
|
|
+ // 是否允许输入自定义值
|
|
|
+ allowCustom: {
|
|
|
+ type: Boolean,
|
|
|
+ default: false
|
|
|
+ },
|
|
|
+ // 防抖延迟(毫秒)
|
|
|
+ debounce: {
|
|
|
+ type: Number,
|
|
|
+ default: 300
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const emit = defineEmits(['update:modelValue', 'change', 'search'])
|
|
|
+
|
|
|
+ // 响应式数据
|
|
|
+ const showOptions = ref(false)
|
|
|
+ const inputValue = ref('')
|
|
|
+ const hoverIndex = ref(-1)
|
|
|
+ const inputRef = ref(null)
|
|
|
+ const selectWrapper = ref(null)
|
|
|
+ const debounceTimer = ref(null)
|
|
|
+
|
|
|
+ // 获取选项值
|
|
|
+ const getOptionValue = (option) => {
|
|
|
+ return typeof option === 'object' ? option[props.valueKey] : option
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取选项标签
|
|
|
+ const getOptionLabel = (option) => {
|
|
|
+ if (typeof option === 'object') {
|
|
|
+ return option[props.labelKey] || option[props.valueKey]
|
|
|
+ }
|
|
|
+ return option
|
|
|
+ }
|
|
|
+
|
|
|
+ // 查找当前值对应的选项
|
|
|
+ const findCurrentOption = (value) => {
|
|
|
+ return props.options.find(option => getOptionValue(option) === value)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 过滤选项
|
|
|
+ const filteredOptions = computed(() => {
|
|
|
+ if (!inputValue.value) {
|
|
|
+ return props.options
|
|
|
+ }
|
|
|
+
|
|
|
+ const searchText = inputValue.value.toLowerCase()
|
|
|
+ return props.options.filter(option => {
|
|
|
+ const label = getOptionLabel(option).toLowerCase()
|
|
|
+ return label.includes(searchText)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ // 检查是否选中
|
|
|
+ const isSelected = (option) => {
|
|
|
+ return getOptionValue(option) === props.modelValue
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理焦点事件
|
|
|
+ const handleFocus = () => {
|
|
|
+ showOptions.value = true
|
|
|
+ hoverIndex.value = -1
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理失去焦点事件
|
|
|
+ const handleBlur = () => {
|
|
|
+ setTimeout(() => {
|
|
|
+ showOptions.value = false
|
|
|
+ hoverIndex.value = -1
|
|
|
+
|
|
|
+ // 如果允许自定义值,且当前输入值不在选项中,则使用输入值
|
|
|
+ if (props.allowCustom && inputValue.value && !findCurrentOption(props.modelValue)) {
|
|
|
+ emit('update:modelValue', inputValue.value)
|
|
|
+ emit('change', inputValue.value)
|
|
|
+ }
|
|
|
+ }, 200)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理输入事件(带防抖)
|
|
|
+ const handleInput = () => {
|
|
|
+ showOptions.value = true
|
|
|
+ hoverIndex.value = -1
|
|
|
+
|
|
|
+ // 清除之前的定时器
|
|
|
+ if (debounceTimer.value) {
|
|
|
+ clearTimeout(debounceTimer.value)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 设置防抖
|
|
|
+ debounceTimer.value = setTimeout(() => {
|
|
|
+ emit('search', inputValue.value)
|
|
|
+ }, props.debounce)
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理选项点击
|
|
|
+ const handleOptionClick = (option) => {
|
|
|
+ const value = getOptionValue(option)
|
|
|
+ const label = getOptionLabel(option)
|
|
|
+
|
|
|
+ inputValue.value = label
|
|
|
+ emit('update:modelValue', value)
|
|
|
+ emit('change', value)
|
|
|
+ showOptions.value = false
|
|
|
+ hoverIndex.value = -1
|
|
|
+ }
|
|
|
+
|
|
|
+ // 键盘导航
|
|
|
+ const handleKeydown = (event) => {
|
|
|
+ switch (event.key) {
|
|
|
+ case 'ArrowDown':
|
|
|
+ event.preventDefault()
|
|
|
+ if (showOptions.value && filteredOptions.value.length > 0) {
|
|
|
+ hoverIndex.value = (hoverIndex.value + 1) % filteredOptions.value.length
|
|
|
+ scrollToHoveredOption()
|
|
|
+ } else {
|
|
|
+ showOptions.value = true
|
|
|
+ }
|
|
|
+ break
|
|
|
+
|
|
|
+ case 'ArrowUp':
|
|
|
+ event.preventDefault()
|
|
|
+ if (showOptions.value && filteredOptions.value.length > 0) {
|
|
|
+ hoverIndex.value = hoverIndex.value <= 0
|
|
|
+ ? filteredOptions.value.length - 1
|
|
|
+ : hoverIndex.value - 1
|
|
|
+ scrollToHoveredOption()
|
|
|
+ }
|
|
|
+ break
|
|
|
+
|
|
|
+ case 'Enter':
|
|
|
+ event.preventDefault()
|
|
|
+ if (showOptions.value && hoverIndex.value >= 0 && filteredOptions.value[hoverIndex.value]) {
|
|
|
+ // 选择当前悬停的选项
|
|
|
+ handleOptionClick(filteredOptions.value[hoverIndex.value])
|
|
|
+ } else if (props.allowCustom && inputValue.value) {
|
|
|
+ // 使用自定义输入值
|
|
|
+ emit('update:modelValue', inputValue.value)
|
|
|
+ emit('change', inputValue.value)
|
|
|
+ showOptions.value = false
|
|
|
+ }
|
|
|
+ break
|
|
|
+
|
|
|
+ case 'Escape':
|
|
|
+ showOptions.value = false
|
|
|
+ hoverIndex.value = -1
|
|
|
+ break
|
|
|
+
|
|
|
+ case 'Tab':
|
|
|
+ showOptions.value = false
|
|
|
+ hoverIndex.value = -1
|
|
|
+ break
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 滚动到悬停的选项
|
|
|
+ const scrollToHoveredOption = () => {
|
|
|
+ nextTick(() => {
|
|
|
+ const optionElements = selectWrapper.value?.querySelectorAll('.option-item')
|
|
|
+ if (optionElements && optionElements[hoverIndex.value]) {
|
|
|
+ optionElements[hoverIndex.value].scrollIntoView({
|
|
|
+ block: 'nearest',
|
|
|
+ behavior: 'smooth'
|
|
|
+ })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ // 点击外部关闭
|
|
|
+ const handleClickOutside = (event) => {
|
|
|
+ if (selectWrapper.value && !selectWrapper.value.contains(event.target)) {
|
|
|
+ showOptions.value = false
|
|
|
+ hoverIndex.value = -1
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 监听外部值变化
|
|
|
+ watch(() => props.modelValue, (newValue) => {
|
|
|
+ const option = findCurrentOption(newValue)
|
|
|
+ if (option) {
|
|
|
+ inputValue.value = getOptionLabel(option)
|
|
|
+ } else if (props.allowCustom) {
|
|
|
+ inputValue.value = newValue
|
|
|
+ } else {
|
|
|
+ inputValue.value = ''
|
|
|
+ }
|
|
|
+ }, { immediate: true })
|
|
|
+
|
|
|
+ // 监听选项列表变化
|
|
|
+ watch(() => props.options, () => {
|
|
|
+ // 当选项列表更新时,重新设置当前值的显示
|
|
|
+ const option = findCurrentOption(props.modelValue)
|
|
|
+ if (option) {
|
|
|
+ inputValue.value = getOptionLabel(option)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ // 生命周期
|
|
|
+ onMounted(() => {
|
|
|
+ document.addEventListener('click', handleClickOutside)
|
|
|
+ })
|
|
|
+
|
|
|
+ onUnmounted(() => {
|
|
|
+ document.removeEventListener('click', handleClickOutside)
|
|
|
+ if (debounceTimer.value) {
|
|
|
+ clearTimeout(debounceTimer.value)
|
|
|
+ }
|
|
|
+ })
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+ .smart-select {
|
|
|
+ position: relative;
|
|
|
+ width: 370px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .smart-input {
|
|
|
+ width: 100%;
|
|
|
+ padding: 8px 12px;
|
|
|
+ border: 1px solid #dcdfe6;
|
|
|
+ border-radius: 4px;
|
|
|
+ outline: none;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #606266;
|
|
|
+ transition: border-color 0.2s;
|
|
|
+ }
|
|
|
+
|
|
|
+ .smart-input:focus {
|
|
|
+ border-color: #409eff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .smart-input.is-focused {
|
|
|
+ border-color: #409eff;
|
|
|
+ }
|
|
|
+
|
|
|
+ .smart-dropdown {
|
|
|
+ position: absolute;
|
|
|
+ top: 100%;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ background: white;
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ border-radius: 4px;
|
|
|
+ box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
|
|
+ z-index: 1000;
|
|
|
+ margin-top: 4px;
|
|
|
+ max-height: 200px;
|
|
|
+ overflow: hidden;
|
|
|
+ }
|
|
|
+
|
|
|
+ .options-list {
|
|
|
+ max-height: 200px;
|
|
|
+ overflow-y: auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ .option-item {
|
|
|
+ padding: 8px 12px;
|
|
|
+ cursor: pointer;
|
|
|
+ color: #606266;
|
|
|
+ transition: all 0.2s;
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .option-item:hover,
|
|
|
+ .option-item.is-hovered {
|
|
|
+ background: #f5f7fa;
|
|
|
+ }
|
|
|
+
|
|
|
+ .option-item.is-selected {
|
|
|
+ color: #409eff;
|
|
|
+ background: #f0f9ff;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+
|
|
|
+ .no-match {
|
|
|
+ position: absolute;
|
|
|
+ top: 100%;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ padding: 16px;
|
|
|
+ text-align: center;
|
|
|
+ color: #909399;
|
|
|
+ font-size: 14px;
|
|
|
+ background: white;
|
|
|
+ border: 1px solid #e4e7ed;
|
|
|
+ border-radius: 4px;
|
|
|
+ box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
|
|
+ z-index: 1000;
|
|
|
+ margin-top: 4px;
|
|
|
+ }
|
|
|
+</style>
|