AgePicker.vue 6.36 KB
<template>
  <div>
    <!-- 标签 -->
    <div v-if="label" class="text-sm text-gray-600 mb-2 flex items-center">
      <span v-if="required" class="text-red-500 mr-1">*</span>
      <span>{{ label }}</span>
    </div>

    <!-- 触发区域 -->
    <div
      class="flex justify-between items-center border border-gray-200 rounded-lg p-3 bg-gray-50"
      @tap="handleTap"
    >
      <span :class="displayValue ? 'text-gray-900' : 'text-gray-400'" class="text-sm">
        {{ displayValue || placeholder }}
      </span>
      <IconFont name="right" size="14" color="#9CA3AF" />
    </div>

    <!-- Picker 弹窗 -->
    <nut-popup
      position="bottom"
      v-model:visible="showPicker"
      :z-index="9999"
      :overlay="false"
      :close-on-click-overlay="false"
      :catch-move="true"
    >
      <nut-picker
        v-model="pickerValue"
        :columns="ageColumns"
        @confirm="onConfirm"
        @cancel="onCancel"
      />
    </nut-popup>
  </div>
</template>

<script setup>
/**
 * 年龄选择器组件
 *
 * @description 使用 NutUI Popup + Picker 实现年龄选择
 *              - 显示格式:3位数字(如 018 表示 18 岁)
 *              - 提交格式:数字(如 18)
 *              - 年龄范围:0-120 岁
 * @author Claude Code
 * @example
 * <AgePicker
 *   v-model="age"
 *   label="年龄"
 *   placeholder="请选择年龄"
 * />
 */
import { ref, computed, watch, inject } from 'vue'
import IconFont from '@/components/IconFont.vue'

// 注入父组件提供的弹窗控制函数
const popupControl = inject('popupControl', null)

/**
 * 组件属性
 */
const props = defineProps({
  /**
   * 标签文本
   * @type {string}
   */
  label: {
    type: String,
    default: ''
  },

  /**
   * 是否必填
   * @type {boolean}
   */
  required: {
    type: Boolean,
    default: false
  },

  /**
   * 占位符文本
   * @type {string}
   */
  placeholder: {
    type: String,
    default: '请选择年龄'
  },

  /**
   * 绑定的值(数字)
   * @type {number}
   */
  modelValue: {
    type: Number,
    default: null
  }
})

/**
 * 组件事件
 */
const emit = defineEmits([
  /**
   * 更新值事件
   * @event update:modelValue
   * @param {number} value - 选中的年龄(数字)
   */
  'update:modelValue',
  /**
   * 值变化事件
   * @event change
   * @param {number} value - 选中的年龄(数字)
   */
  'change',
  /**
   * 弹窗打开事件
   * @event open
   */
  'open',
  /**
   * 弹窗关闭事件
   * @event close
   */
  'close'
])

/**
 * 控制 Picker 显示
 * @type {Ref<boolean>}
 */
const showPicker = ref(false)

/**
 * Picker 选中的值
 * @type {Ref<Array<number>>}
 */
const pickerValue = ref([0, 1, 8]) // 默认 018

/**
 * 同步 Picker 值与 modelValue
 */
const syncPickerValue = () => {
  // 如果 modelValue 有值(包括 0),则使用 modelValue,否则默认为 18
  const age = (props.modelValue !== null && props.modelValue !== undefined)
    ? props.modelValue
    : 18

  // 确保 age 在 0-199 范围内
  const validAge = Math.min(Math.max(0, age), 199)

  const h = Math.floor(validAge / 100)
  const t = Math.floor((validAge % 100) / 10)
  const u = validAge % 10

  pickerValue.value = [h, t, u]
}

// 监听 modelValue 变化
watch(() => props.modelValue, syncPickerValue, { immediate: true })

// 监听弹窗打开,重新同步值(防止上次取消后保留了未确认的值)
watch(showPicker, (val) => {
  if (val) {
    syncPickerValue()
  }
})

/**
 * 处理点击事件
 */
const handleTap = () => {
  openPicker()
}

/**
 * 打开选择器
 */
const openPicker = () => {
  // 调用父组件提供的 open 函数
  if (popupControl && popupControl.open) {
    popupControl.open()
  }

  showPicker.value = true
}

/**
 * 年龄选项(3列数字格式)
 * @description 生成百位(0-1)、十位(0-9)、个位(0-9)的选项数组
 * @returns {Array<Array<{text: string, value: number}>>} Picker 列格式
 */
const ageColumns = computed(() => {
  // 百位: 0-1
  const hundreds = [
    { text: '0', value: 0 },
    { text: '1', value: 1 }
  ]

  // 十位: 0-9
  const tens = Array.from({ length: 10 }, (_, i) => ({
    text: i.toString(),
    value: i
  }))

  // 个位: 0-9 (为了支持 10, 20 等年龄,个位必须包含 0)
  // 用户需求提及第三列 1-9,但如果是 1-9 则无法选择 10, 20 等整数年龄
  // 因此此处使用 0-9 以确保完整性
  const units = Array.from({ length: 10 }, (_, i) => ({
    text: i.toString(),
    value: i
  }))

  return [hundreds, tens, units]
})

/**
 * 显示的值(数字格式)
 * @description 将数字转换为字符串显示
 * @returns {string} 显示文本
 */
const displayValue = computed(() => {
  return props.modelValue !== null && props.modelValue !== undefined
    ? props.modelValue.toString()
    : ''
})

/**
 * 确认选择
 * @param {Object} params - Picker 返回参数
 * @param {Array} params.selectedOptions - 选中的选项数组
 * @param {Array} params.selectedValue - 选中的值数组
 */
const onConfirm = ({ selectedValue, selectedOptions }) => {
  // 优先从 selectedOptions 获取值,因为它包含完整的选项对象
  // 某些情况下 selectedValue 可能不完整或类型不一致
  let h, t, u

  if (selectedOptions && selectedOptions.length >= 3) {
    h = selectedOptions[0]?.value
    t = selectedOptions[1]?.value
    u = selectedOptions[2]?.value
  } else if (Array.isArray(selectedValue) && selectedValue.length >= 3) {
    h = selectedValue[0]
    t = selectedValue[1]
    u = selectedValue[2]
  }

  // 确保所有位都有值(0 也是有效值)
  if (h !== undefined && t !== undefined && u !== undefined) {
    const age = parseInt(h) * 100 + parseInt(t) * 10 + parseInt(u)
    if (!Number.isNaN(age)) {
      emit('update:modelValue', age)
      emit('change', age) // 触发 change 事件,供父组件监听
    } else {
      console.error('[AgePicker] 计算结果为 NaN', { h, t, u })
    }
  } else {
    console.error('[AgePicker] 选中值无效', { selectedValue, selectedOptions })
  }

  // 调用父组件提供的 close 函数
  if (popupControl && popupControl.close) {
    popupControl.close()
  }

  showPicker.value = false
}

/**
 * 取消选择
 */
const onCancel = () => {
  // 调用父组件提供的 close 函数
  if (popupControl && popupControl.close) {
    popupControl.close()
  }

  showPicker.value = false
}
</script>

<style lang="less">
/* 组件样式 */
</style>