SearchBar.vue 6.19 KB
<template>
  <view class="search-bar" :class="containerClass">
    <view class="search-input-wrapper">
      <!-- 左侧搜索图标 -->
      <IconFont
        name="search"
        :size="iconSize"
        :color="iconColor"
        class="search-icon"
      />

      <!-- 原生输入框 -->
      <input
        :value="internalValue"
        :type="inputType"
        :placeholder="placeholder"
        :disabled="disabled"
        confirm-type="search"
        class="search-input"
        @input="handleInput"
        @focus="handleFocus"
        @blur="handleBlur"
        @confirm="handleSearch"
      />

      <!-- 清除按钮 -->
      <view
        v-if="showClear && internalValue"
        class="clear-icon"
        @tap.stop="handleClear"
        @click.stop="handleClear"
      >
        <IconFont
          name="close"
          :size="14"
          color="#9CA3AF"
        />
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref, watch, computed } from 'vue'
import IconFont from '@/components/icons/IconFont.vue'

/**
 * SearchBar 组件(基于 NutUI Input)
 *
 * @description 可复用的搜索栏组件,使用 NutUI Input 组件实现
 * @author Claude Code
 * @example
 * <SearchBar
 *   v-model="searchValue"
 *   placeholder="搜索资料..."
 *   variant="rounded"
 *   @search="handleSearch"
 * />
 */

const props = defineProps({
  /**
   * 绑定值
   * @type {string|number}
   */
  modelValue: {
    type: [String, Number],
    default: ''
  },
  /**
   * 占位文本
   * @type {string}
   * @default '搜索...'
   */
  placeholder: {
    type: String,
    default: '搜索...'
  },
  /**
   * 样式变体
   * @type {'normal' | 'rounded'}
   * @default 'normal'
   */
  variant: {
    type: String,
    default: 'normal',
    validator: (value) => ['normal', 'rounded'].includes(value)
  },
  /**
   * 是否显示边框
   * @type {boolean}
   * @default false
   */
  showBorder: {
    type: Boolean,
    default: false
  },
  /**
   * 是否显示清除按钮
   * @type {boolean}
   * @default false
   */
  showClear: {
    type: Boolean,
    default: false
  },
  /**
   * 是否禁用
   * @type {boolean}
   * @default false
   */
  disabled: {
    type: Boolean,
    default: false
  },
  /**
   * 输入框类型
   * @type {string}
   * @default 'text'
   */
  inputType: {
    type: String,
    default: 'text'
  },
  /**
   * 图标大小
   * @type {number|string}
   * @default 18
   */
  iconSize: {
    type: [Number, String],
    default: 18
  },
  /**
   * 图标颜色
   * @type {string}
   * @default '#9CA3AF'
   */
  iconColor: {
    type: String,
    default: '#9CA3AF'
  }
})

const emit = defineEmits({
  /**
   * 更新绑定值
   * @event update:modelValue
   * @param {string|number} value - 新值
   */
  'update:modelValue': (value) => true,
  /**
   * 搜索事件(按下回车)
   * @event search
   * @param {string|number} value - 当前值
   */
  search: (value) => true,
  /**
   * 输入事件
   * @event input
   * @param {string|number} value - 当前值
   */
  input: (value) => true,
  /**
   * 获得焦点
   * @event focus
   */
  focus: () => true,
  /**
   * 失去焦点
   * @event blur
   * @param {string} value - 当前输入值
   */
  blur: (value) => true,
  /**
   * 清除
   * @event clear
   */
  clear: () => true
})

// 内部值
const internalValue = ref(props.modelValue)

// 容器样式类
const containerClass = computed(() => {
  const classes = ['search-bar']

  if (props.variant === 'rounded') {
    classes.push('search-bar-rounded')
  }

  if (props.showBorder) {
    classes.push('search-bar-bordered')
  }

  return classes.join(' ')
})

// 监听 modelValue 变化
watch(() => props.modelValue, (newValue) => {
  internalValue.value = newValue
})

// 监听内部值变化,触发更新
watch(internalValue, (newValue) => {
  emit('update:modelValue', newValue)
  emit('input', newValue)
})

/**
 * 处理获得焦点
 */
function handleFocus() {
  console.log('[SearchBar Component] 获得焦点')
  emit('focus')
}

/**
 * 处理失去焦点
 */
function handleBlur() {
  console.log('[SearchBar Component] 失去焦点')
  emit('blur', internalValue.value)
}

/**
 * 处理搜索(回车)
 */
function handleSearch() {
  console.log('[SearchBar Component] handleSearch 被调用')
  console.log('[SearchBar Component] 当前输入值:', internalValue.value)
  emit('search', internalValue.value)
  console.log('[SearchBar Component] search 事件已发送')
}

/**
 * 清除输入
 */
function handleClear(event) {
  console.log('[SearchBar Component] 清除输入')
  // 阻止事件冒泡
  if (event) {
    event.stopPropagation()
  }
  // 清空内部值
  internalValue.value = ''
  // 触发 clear 事件(通知父组件)
  emit('clear')
  // 触发 v-model 更新
  emit('update:modelValue', '')
  // 触发 input 事件(保持一致性)
  emit('input', '')
}

/**
 * 处理输入事件(原生 input)
 */
function handleInput(e) {
  const value = e.detail.value
  internalValue.value = value
  emit('update:modelValue', value)
  emit('input', value)
}
</script>

<style lang="less">
.search-bar {
  padding: 0;
  background: transparent;
  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
  border-radius: 44rpx;
  border: 1px solid #e5e7eb;

  .search-input-wrapper {
    display: flex;
    align-items: center;
    padding: 24rpx 32rpx;
    background: transparent;
    border-radius: 44rpx;

    .search-icon {
      flex-shrink: 0;
      margin-right: 16rpx;
    }

    .search-input {
      flex: 1;
      height: 100%;
      font-size: 28rpx;
      color: #333;
      background: transparent;
      border: none;
      outline: none;
      padding: 0;
      margin: 0;

      &::placeholder {
        color: #9CA3AF;
      }

      &:disabled {
        color: #ccc;
      }
    }

    .clear-icon {
      flex-shrink: 0;
      margin-left: 16rpx;
      padding: 8rpx;
      cursor: pointer;

      &:active {
        opacity: 0.6;
      }
    }
  }

  &.search-bar-rounded {
    border-radius: 44rpx;
    overflow: hidden;

    .search-input-wrapper {
      border-radius: 44rpx;
    }
  }

  &.search-bar-bordered {
    border: 1px solid #e5e7eb;
  }
}

.search-bar-rounded {
  height: 88rpx;

  .search-input-wrapper {
    height: 100%;
  }
}
</style>