1 代码

<template>
  <div class="phone-input-container">
    <div class="phone-input-wrapper" :class="{ 'has-error': showError, 'disabled': disabled }">
      <!-- 国家/地区选择 -->
      <div v-if="showCountryCode" class="country-code-wrapper">
        <select 
          v-model="selectedCountryCode" 
          class="country-code-select"
          :disabled="disabled"
          @change="onCountryCodeChange"
        >
          <option v-for="country in countryCodes" :key="country.code" :value="country.code">
            {{ country.code }} ({{ country.name }})
          </option>
        </select>
        <span class="country-code-display" v-if="!showFullCountrySelect">
          {{ selectedCountryCode }}
        </span>
      </div>
      
      <!-- 输入框 -->
      <input
        ref="phoneInput"
        v-model="inputValue"
        :type="inputType"
        :placeholder="placeholder"
        :disabled="disabled"
        :readonly="readonly"
        :maxlength="maxLength"
        :required="required"
        class="phone-input"
        :style="inputStyles"
        @input="onInput"
        @blur="onBlur"
        @focus="onFocus"
      />
      
      <!-- 清空按钮 -->
      <button
        v-if="showClear && inputValue"
        type="button"
        class="clear-btn"
        @click="clearInput"
        :disabled="disabled"
      >
        <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M14 1.41L12.59 0L7 5.59L1.41 0L0 1.41L5.59 7L0 12.59L1.41 14L7 8.41L12.59 14L14 12.59L8.41 7L14 1.41Z" fill="#999"/>
        </svg>
      </button>
      
      <!-- 显示/隐藏手机号按钮 -->
      <button
        v-if="showToggleMask && inputValue"
        type="button"
        class="toggle-mask-btn"
        @click="toggleMask"
        :disabled="disabled"
      >
        <svg v-if="masked" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" fill="#666"/>
        </svg>
        <svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78l3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z" fill="#666"/>
        </svg>
      </button>
    </div>
    
    <!-- 验证信息显示 -->
    <div v-if="showError || helpText" class="validation-info">
      <div v-if="showError" class="error-text" :style="{ color: errorColor, fontSize: errorFontSize }">
        {{ errorMessage }}
      </div>
      <div v-if="helpText && !showError" class="help-text" :style="{ color: helpTextColor, fontSize: helpTextFontSize }">
        {{ helpText }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'PhoneInput',
  
  props: {
    // 双向绑定值
    value: {
      type: String,
      default: ''
    },
    
    // 样式相关属性
    width: {
      type: [String, Number],
      default: '100%'
    },
    height: {
      type: [String, Number],
      default: '40px'
    },
    inputBgColor: {
      type: String,
      default: '#ffffff'
    },
    borderColor: {
      type: String,
      default: '#dcdfe6'
    },
    focusBorderColor: {
      type: String,
      default: '#409eff'
    },
    errorBorderColor: {
      type: String,
      default: '#f56c6c'
    },
    textColor: {
      type: String,
      default: '#333333'
    },
    fontSize: {
      type: [String, Number],
      default: '14px'
    },
    borderRadius: {
      type: [String, Number],
      default: '4px'
    },
    
    // 功能属性
    placeholder: {
      type: String,
      default: '请输入手机号码'
    },
    required: {
      type: Boolean,
      default: false
    },
    disabled: {
      type: Boolean,
      default: false
    },
    readonly: {
      type: Boolean,
      default: false
    },
    
    // 验证相关
    validateOnBlur: {
      type: Boolean,
      default: true
    },
    validateOnChange: {
      type: Boolean,
      default: true
    },
    customValidator: {
      type: Function,
      default: null
    },
    
    // 显示选项
    showClear: {
      type: Boolean,
      default: true
    },
    showToggleMask: {
      type: Boolean,
      default: false
    },
    showCountryCode: {
      type: Boolean,
      default: false
    },
    showFullCountrySelect: {
      type: Boolean,
      default: false
    },
    
    // 辅助文本
    helpText: {
      type: String,
      default: ''
    },
    helpTextColor: {
      type: String,
      default: '#909399'
    },
    helpTextFontSize: {
      type: [String, Number],
      default: '12px'
    },
    errorColor: {
      type: String,
      default: '#f56c6c'
    },
    errorFontSize: {
      type: [String, Number],
      default: '12px'
    },
    
    // 新增:是否允许非数字字符
    allowNonNumeric: {
      type: Boolean,
      default: false
    }
  },
  
  data() {
    return {
      inputValue: '',
      isFocused: false,
      showError: false,
      errorMessage: '',
      masked: false,
      inputType: 'tel',
      selectedCountryCode: '+86',
      countryCodes: [
        { code: '+86', name: '中国' },
        { code: '+1', name: '美国' },
        { code: '+44', name: '英国' },
        { code: '+81', name: '日本' },
        { code: '+82', name: '韩国' },
        { code: '+65', name: '新加坡' },
        { code: '+852', name: '香港' },
        { code: '+853', name: '澳门' },
        { code: '+886', name: '台湾' }
      ]
    };
  },
  
  computed: {
    // 计算输入框样式
    inputStyles() {
      let borderColor = this.borderColor;
      
      if (this.showError) {
        borderColor = this.errorBorderColor;
      } else if (this.isFocused) {
        borderColor = this.focusBorderColor;
      }
      
      return {
        width: this.width,
        height: this.height,
        backgroundColor: this.inputBgColor,
        borderColor: borderColor,
        color: this.textColor,
        fontSize: typeof this.fontSize === 'number' ? `${this.fontSize}px` : this.fontSize,
        borderRadius: typeof this.borderRadius === 'number' ? `${this.borderRadius}px` : this.borderRadius,
        paddingLeft: this.showCountryCode && !this.showFullCountrySelect ? '70px' : '12px'
      };
    },
    
    // 计算最大输入长度
    maxLength() {
      if (this.showCountryCode) {
        const countryCode = this.selectedCountryCode.replace('+', '');
        return 1 + countryCode.length + (countryCode === '86' ? 11 : 15);
      }
      return 11;
    }
  },
  
  watch: {
    value: {
      immediate: true,
      handler(newVal) {
        this.inputValue = newVal || '';
      }
    },
    
    inputValue(newVal) {
      // 格式化显示(无空格版本)
      const formattedValue = this.formatPhoneNumber(newVal);
      if (formattedValue !== newVal) {
        this.inputValue = formattedValue;
        return;
      }
      
      // 触发双向绑定
      this.$emit('input', newVal);
      
      // 验证输入
      if (this.validateOnChange && newVal) {
        this.validateInput(newVal);
      }
    }
  },
  
  methods: {
    // 格式化手机号显示(无空格版本)
    formatPhoneNumber(phone) {
      if (!phone) return '';
      
      // 如果允许非数字字符,直接返回
      if (this.allowNonNumeric) {
        return phone;
      }
      
      let cleaned = phone;
      
      // 如果是国际号码,保留+号
      if (this.showCountryCode && phone.startsWith('+')) {
        const plusSign = phone.charAt(0);
        const afterPlus = phone.substring(1).replace(/\D/g, '');
        cleaned = plusSign + afterPlus;
      } else {
        // 国内号码,只保留数字
        cleaned = phone.replace(/\D/g, '');
      }
      
      // 限制最大长度
      return cleaned.substring(0, this.maxLength);
    },
    
    // 输入事件处理
    onInput(event) {
      // 直接使用原始输入值,不添加空格
      this.inputValue = event.target.value;
    },
    
    // 焦点事件处理
    onFocus() {
      this.isFocused = true;
      this.$emit('focus');
    },
    
    // 失去焦点事件处理
    onBlur() {
      this.isFocused = false;
      
      if (this.validateOnBlur) {
        this.validateInput(this.inputValue);
      }
      
      this.$emit('blur');
    },
    
    // 验证输入
    validateInput(value) {
      // 重置错误状态
      this.showError = false;
      this.errorMessage = '';
      
      // 如果是必填且为空
      if (this.required && !value.trim()) {
        this.showError = true;
        this.errorMessage = '手机号码不能为空';
        this.$emit('validate', { isValid: false, message: this.errorMessage });
        return false;
      }
      
      // 如果值为空,直接通过验证
      if (!value.trim()) {
        this.$emit('validate', { isValid: true, message: '' });
        return true;
      }
      
      // 使用自定义验证器
      if (this.customValidator && typeof this.customValidator === 'function') {
        const result = this.customValidator(value, this.selectedCountryCode);
        if (result && result.isValid === false) {
          this.showError = true;
          this.errorMessage = result.message || '手机号码格式不正确';
          this.$emit('validate', { isValid: false, message: this.errorMessage });
          return false;
        }
      }
      
      // 默认手机号验证(无空格版本)
      let isValid = false;
      let cleanValue = value;
      
      if (this.showCountryCode) {
        // 国际手机号验证
        const countryCode = this.selectedCountryCode.replace('+', '');
        let numberPart;
        
        if (cleanValue.startsWith('+')) {
          // 移除+号和国码
          const withoutPlus = cleanValue.substring(1);
          if (withoutPlus.startsWith(countryCode)) {
            numberPart = withoutPlus.substring(countryCode.length);
          } else {
            numberPart = withoutPlus;
          }
        } else {
          // 没有+号,直接使用整个值
          numberPart = cleanValue;
        }
        
        // 不同国家/地区的手机号验证规则
        switch(this.selectedCountryCode) {
          case '+86': // 中国
            isValid = /^1[3-9]\d{9}$/.test(numberPart);
            this.errorMessage = isValid ? '' : '请输入正确的中国手机号码 (11位,以1开头)';
            break;
          case '+1': // 美国/加拿大
            isValid = /^\d{10}$/.test(numberPart);
            this.errorMessage = isValid ? '' : '请输入正确的北美电话号码 (10位)';
            break;
          case '+44': // 英国
            isValid = /^7[1-9]\d{8}$/.test(numberPart);
            this.errorMessage = isValid ? '' : '请输入正确的英国手机号码 (10位,以7开头)';
            break;
          default:
            // 通用国际号码验证(至少6位数字)
            isValid = /^\d{6,}$/.test(numberPart);
            this.errorMessage = isValid ? '' : '请输入正确的国际手机号码';
        }
      } else {
        // 中国手机号验证(无空格)
        cleanValue = cleanValue.replace(/\D/g, '');
        isValid = /^1[3-9]\d{9}$/.test(cleanValue);
        this.errorMessage = isValid ? '' : '请输入正确的手机号码 (11位,以1开头)';
      }
      
      if (!isValid && value.trim()) {
        this.showError = true;
      }
      
      this.$emit('validate', { 
        isValid, 
        message: this.errorMessage,
        value,
        rawValue: this.getRawValue(),
        countryCode: this.selectedCountryCode
      });
      
      return isValid;
    },
    
    // 清空输入
    clearInput() {
      this.inputValue = '';
      this.showError = false;
      this.errorMessage = '';
      this.$emit('clear');
      this.$nextTick(() => {
        this.$refs.phoneInput.focus();
      });
    },
    
    // 切换掩码显示
    toggleMask() {
      this.masked = !this.masked;
      this.inputType = this.masked ? 'text' : 'tel';
    },
    
    // 国家代码变化处理
    onCountryCodeChange() {
      this.$emit('country-code-change', this.selectedCountryCode);
      // 重新验证
      if (this.inputValue) {
        this.validateInput(this.inputValue);
      }
    },
    
    // 手动验证方法(可从外部调用)
    validate() {
      return this.validateInput(this.inputValue);
    },
    
    // 手动聚焦方法
    focus() {
      this.$refs.phoneInput.focus();
    },
    
    // 手动失焦方法
    blur() {
      this.$refs.phoneInput.blur();
    },
    
    // 获取原始手机号(去除非数字字符,保留国际号码的+号)
    getRawValue() {
      if (!this.inputValue) return '';
      
      let rawValue = this.inputValue;
      
      // 如果允许非数字字符,直接返回
      if (this.allowNonNumeric) {
        return rawValue;
      }
      
      // 如果是国内号码,只保留数字
      if (!this.showCountryCode) {
        rawValue = rawValue.replace(/\D/g, '');
      } else if (rawValue.startsWith('+')) {
        // 国际号码,保留+号和数字
        const plusSign = rawValue.charAt(0);
        const afterPlus = rawValue.substring(1).replace(/\D/g, '');
        rawValue = plusSign + afterPlus;
      } else {
        // 没有+号的国际号码,只保留数字
        rawValue = rawValue.replace(/\D/g, '');
      }
      
      return rawValue;
    }
  }
};
</script>

<style scoped>
.phone-input-container {
  width: 100%;
  font-family: 'Helvetica Neue', Arial, sans-serif;
}

.phone-input-wrapper {
  position: relative;
  display: flex;
  align-items: center;
  width: 100%;
  border-radius: 4px;
  transition: all 0.3s ease;
}

.phone-input-wrapper.disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.phone-input-wrapper.has-error {
  animation: shake 0.5s;
}

@keyframes shake {
  0%, 100% { transform: translateX(0); }
  10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
  20%, 40%, 60%, 80% { transform: translateX(5px); }
}

.country-code-wrapper {
  position: absolute;
  left: 1px;
  top: 1px;
  bottom: 1px;
  display: flex;
  align-items: center;
  z-index: 1;
}

.country-code-select {
  height: calc(100% - 2px);
  padding: 0 8px;
  border: none;
  background-color: #f5f7fa;
  border-right: 1px solid #dcdfe6;
  border-radius: 4px 0 0 4px;
  font-size: 14px;
  color: #333;
  outline: none;
  cursor: pointer;
}

.country-code-select:disabled {
  background-color: #f5f5f5;
  cursor: not-allowed;
}

.country-code-display {
  padding: 0 12px;
  height: 100%;
  display: flex;
  align-items: center;
  background-color: #f5f7fa;
  border-right: 1px solid #dcdfe6;
  border-radius: 4px 0 0 4px;
  font-size: 14px;
  font-weight: 500;
  color: #333;
}

.phone-input {
  flex: 1;
  padding: 0 12px;
  border: 1px solid #dcdfe6;
  outline: none;
  transition: border-color 0.3s, box-shadow 0.3s;
  box-sizing: border-box;
  font-family: inherit;
  /* 确保文本不会因为空格而产生奇怪的对齐 */
  letter-spacing: normal;
}

.phone-input:focus {
  box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1);
}

.phone-input:disabled {
  background-color: #f5f5f5;
  cursor: not-allowed;
}

.phone-input::placeholder {
  color: #c0c4cc;
}

.clear-btn, .toggle-mask-btn {
  position: absolute;
  right: 40px;
  top: 50%;
  transform: translateY(-50%);
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  cursor: pointer;
  opacity: 0.7;
  transition: opacity 0.2s;
  padding: 0;
}

.clear-btn:hover, .toggle-mask-btn:hover {
  opacity: 1;
}

.clear-btn:disabled, .toggle-mask-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}

.toggle-mask-btn {
  right: 12px;
}

.validation-info {
  margin-top: 6px;
  min-height: 20px;
}

.error-text, .help-text {
  line-height: 1.5;
}

.error-text {
  animation: fadeIn 0.3s;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(-5px); }
  to { opacity: 1; transform: translateY(0); }
}
</style>

 

2 属性绑定

image

 

3 样式配置

image

 

4 功能配置

image

 

5 验证配置

image

自定义验证函数格式

// 参数:value - 输入值, countryCode - 当前国家代码(如果有)
// 返回值:{ isValid: Boolean, message?: String } 或 undefined
customValidator(value, countryCode) {
  if (value && !value.startsWith('138')) {
    return {
      isValid: false,
      message: '只允许138开头的手机号码'
    };
  }
  return { isValid: true };
}

 

6 显示选项

image

 

7 辅助文本

image

 

8 事件

image

 validate事件参数结构

{
  isValid: Boolean,      // 验证是否通过
  message: String,       // 验证消息(错误时显示)
  value: String,         // 当前输入值
  countryCode: String    // 当前国家代码(如果有)
}

 

9 方法

通过 ref 可以调用组件的方法

<template>
  <PhoneInput ref="phoneInput" v-model="phone" />
  <button @click="validatePhone">手动验证</button>
</template>

<script>
export default {
  methods: {
    validatePhone() {
      const isValid = this.$refs.phoneInput.validate();
      // 或者获取验证结果对象
      // const result = this.$refs.phoneInput.validate();
    }
  }
}
</script>

image

 

10 示例

10.1 基础示例

<template>
  <PhoneInput v-model="phone" />
</template>

 

10.2 样式自定义

<template>
  <PhoneInput
    v-model="phone"
    width="300px"
    height="50px"
    fontSize="16px"
    borderRadius="8px"
    inputBgColor="#f8f9fa"
    focusBorderColor="#007acc"
  />
</template>

 

10.3 验证配置

<template>
  <PhoneInput
    v-model="phone"
    :required="true"
    :customValidator="customValidator"
    placeholder="请输入11位手机号码"
    @validate="handleValidate"
  />
</template>

<script>
export default {
  methods: {
    customValidator(value) {
      if (value && !/^1[3-9]\d{9}$/.test(value.replace(/\s/g, ''))) {
        return {
          isValid: false,
          message: '请输入正确的手机号码格式'
        };
      }
      return { isValid: true };
    },
    
    handleValidate(result) {
      console.log('验证结果:', result);
      if (!result.isValid) {
        // 处理验证失败
      }
    }
  }
}
</script>