442 lines
9.0 KiB
Vue
442 lines
9.0 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="secure-input" :class="{ 'has-error': error }">
|
|||
|
|
<label v-if="label" class="secure-input__label" :for="inputId">
|
|||
|
|
{{ label }}
|
|||
|
|
<span v-if="required" class="secure-input__required">*</span>
|
|||
|
|
</label>
|
|||
|
|
|
|||
|
|
<div class="secure-input__wrapper">
|
|||
|
|
<input
|
|||
|
|
:id="inputId"
|
|||
|
|
ref="inputRef"
|
|||
|
|
v-model="inputValue"
|
|||
|
|
:type="showPassword ? 'text' : type"
|
|||
|
|
:placeholder="placeholder"
|
|||
|
|
:disabled="disabled"
|
|||
|
|
:readonly="readonly"
|
|||
|
|
:maxlength="maxLength"
|
|||
|
|
:autocomplete="autocomplete"
|
|||
|
|
:class="inputClasses"
|
|||
|
|
@input="handleInput"
|
|||
|
|
@blur="handleBlur"
|
|||
|
|
@focus="handleFocus"
|
|||
|
|
@keyup.enter="handleEnter"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<button
|
|||
|
|
v-if="type === 'password' && showToggle"
|
|||
|
|
type="button"
|
|||
|
|
class="secure-input__toggle"
|
|||
|
|
@click="togglePasswordVisibility"
|
|||
|
|
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
|
|||
|
|
>
|
|||
|
|
<el-icon v-if="showPassword"><Hide /></el-icon>
|
|||
|
|
<el-icon v-else><View /></el-icon>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<button
|
|||
|
|
v-if="clearable && inputValue && !disabled && !readonly"
|
|||
|
|
type="button"
|
|||
|
|
class="secure-input__clear"
|
|||
|
|
@click="clearInput"
|
|||
|
|
aria-label="清除输入"
|
|||
|
|
>
|
|||
|
|
<el-icon><Close /></el-icon>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
<div v-if="strengthIndicator && type === 'password'" class="secure-input__strength">
|
|||
|
|
<div class="secure-input__strength-bar">
|
|||
|
|
<div
|
|||
|
|
class="secure-input__strength-fill"
|
|||
|
|
:class="passwordStrengthClass"
|
|||
|
|
:style="{ width: `${passwordStrengthPercentage}%` }"
|
|||
|
|
></div>
|
|||
|
|
</div>
|
|||
|
|
<span class="secure-input__strength-text">{{ passwordStrengthText }}</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="error" class="secure-input__error">{{ error }}</div>
|
|||
|
|
<div v-else-if="hint" class="secure-input__hint">{{ hint }}</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup>
|
|||
|
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
|||
|
|
import { View, Hide, Close } from '@element-plus/icons-vue'
|
|||
|
|
import { validatePassword, sanitizeInput } from '@/utils/security'
|
|||
|
|
import { ElMessage } from 'element-plus'
|
|||
|
|
|
|||
|
|
const props = defineProps({
|
|||
|
|
modelValue: {
|
|||
|
|
type: [String, Number],
|
|||
|
|
default: ''
|
|||
|
|
},
|
|||
|
|
type: {
|
|||
|
|
type: String,
|
|||
|
|
default: 'text',
|
|||
|
|
validator: (value) => ['text', 'password', 'email', 'tel', 'url', 'number'].includes(value)
|
|||
|
|
},
|
|||
|
|
label: {
|
|||
|
|
type: String,
|
|||
|
|
default: ''
|
|||
|
|
},
|
|||
|
|
placeholder: {
|
|||
|
|
type: String,
|
|||
|
|
default: ''
|
|||
|
|
},
|
|||
|
|
required: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: false
|
|||
|
|
},
|
|||
|
|
disabled: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: false
|
|||
|
|
},
|
|||
|
|
readonly: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: false
|
|||
|
|
},
|
|||
|
|
clearable: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: false
|
|||
|
|
},
|
|||
|
|
showToggle: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: true
|
|||
|
|
},
|
|||
|
|
strengthIndicator: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: false
|
|||
|
|
},
|
|||
|
|
maxLength: {
|
|||
|
|
type: Number,
|
|||
|
|
default: 255
|
|||
|
|
},
|
|||
|
|
autocomplete: {
|
|||
|
|
type: String,
|
|||
|
|
default: 'off'
|
|||
|
|
},
|
|||
|
|
validateOnBlur: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: true
|
|||
|
|
},
|
|||
|
|
sanitize: {
|
|||
|
|
type: Boolean,
|
|||
|
|
default: true
|
|||
|
|
},
|
|||
|
|
error: {
|
|||
|
|
type: String,
|
|||
|
|
default: ''
|
|||
|
|
},
|
|||
|
|
hint: {
|
|||
|
|
type: String,
|
|||
|
|
default: ''
|
|||
|
|
},
|
|||
|
|
size: {
|
|||
|
|
type: String,
|
|||
|
|
default: 'default',
|
|||
|
|
validator: (value) => ['small', 'default', 'large'].includes(value)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const emit = defineEmits(['update:modelValue', 'input', 'blur', 'focus', 'enter', 'clear', 'strength-change'])
|
|||
|
|
|
|||
|
|
const inputRef = ref(null)
|
|||
|
|
const inputValue = ref(props.modelValue)
|
|||
|
|
const showPassword = ref(false)
|
|||
|
|
const isFocused = ref(false)
|
|||
|
|
const passwordStrength = ref(0)
|
|||
|
|
|
|||
|
|
// 生成唯一ID
|
|||
|
|
const inputId = computed(() => `secure-input-${Math.random().toString(36).substring(2, 9)}`)
|
|||
|
|
|
|||
|
|
// 计算输入框样式类
|
|||
|
|
const inputClasses = computed(() => {
|
|||
|
|
return [
|
|||
|
|
'secure-input__field',
|
|||
|
|
`secure-input__field--${props.size}`,
|
|||
|
|
{
|
|||
|
|
'secure-input__field--focused': isFocused.value,
|
|||
|
|
'secure-input__field--disabled': props.disabled,
|
|||
|
|
'secure-input__field--readonly': props.readonly,
|
|||
|
|
'secure-input__field--has-error': props.error,
|
|||
|
|
'secure-input__field--password': props.type === 'password'
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 计算密码强度类
|
|||
|
|
const passwordStrengthClass = computed(() => {
|
|||
|
|
if (passwordStrength.value <= 2) return 'weak'
|
|||
|
|
if (passwordStrength.value <= 3) return 'medium'
|
|||
|
|
return 'strong'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 计算密码强度百分比
|
|||
|
|
const passwordStrengthPercentage = computed(() => {
|
|||
|
|
return Math.min(passwordStrength.value * 20, 100)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 计算密码强度文本
|
|||
|
|
const passwordStrengthText = computed(() => {
|
|||
|
|
if (passwordStrength.value <= 2) return '弱'
|
|||
|
|
if (passwordStrength.value <= 3) return '中等'
|
|||
|
|
return '强'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 监听modelValue变化
|
|||
|
|
watch(() => props.modelValue, (newVal) => {
|
|||
|
|
inputValue.value = newVal
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 监听输入值变化
|
|||
|
|
watch(inputValue, (newVal) => {
|
|||
|
|
// 如果启用了输入清理,则清理输入
|
|||
|
|
const processedValue = props.sanitize ? sanitizeInput(newVal) : newVal
|
|||
|
|
|
|||
|
|
// 更新密码强度(仅当类型为password时)
|
|||
|
|
if (props.type === 'password' && props.strengthIndicator) {
|
|||
|
|
updatePasswordStrength(processedValue)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 触发更新事件
|
|||
|
|
emit('update:modelValue', processedValue)
|
|||
|
|
emit('input', processedValue)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 更新密码强度
|
|||
|
|
const updatePasswordStrength = (password) => {
|
|||
|
|
if (!password) {
|
|||
|
|
passwordStrength.value = 0
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const result = validatePassword(password)
|
|||
|
|
passwordStrength.value = result.score
|
|||
|
|
emit('strength-change', result)
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('密码强度验证失败:', error)
|
|||
|
|
passwordStrength.value = 0
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理输入事件
|
|||
|
|
const handleInput = (event) => {
|
|||
|
|
// 输入事件已在watch中处理
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理焦点事件
|
|||
|
|
const handleFocus = (event) => {
|
|||
|
|
isFocused.value = true
|
|||
|
|
emit('focus', event)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理失焦事件
|
|||
|
|
const handleBlur = (event) => {
|
|||
|
|
isFocused.value = false
|
|||
|
|
emit('blur', event)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理回车事件
|
|||
|
|
const handleEnter = (event) => {
|
|||
|
|
emit('enter', event)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 切换密码可见性
|
|||
|
|
const togglePasswordVisibility = () => {
|
|||
|
|
showPassword.value = !showPassword.value
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 清除输入
|
|||
|
|
const clearInput = () => {
|
|||
|
|
inputValue.value = ''
|
|||
|
|
emit('clear')
|
|||
|
|
nextTick(() => {
|
|||
|
|
inputRef.value?.focus()
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 聚焦输入框
|
|||
|
|
const focus = () => {
|
|||
|
|
inputRef.value?.focus()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 失焦输入框
|
|||
|
|
const blur = () => {
|
|||
|
|
inputRef.value?.blur()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 选择文本
|
|||
|
|
const select = () => {
|
|||
|
|
inputRef.value?.select()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 暴露方法
|
|||
|
|
defineExpose({
|
|||
|
|
focus,
|
|||
|
|
blur,
|
|||
|
|
select,
|
|||
|
|
inputRef
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
// 初始化密码强度
|
|||
|
|
if (props.type === 'password' && props.strengthIndicator && inputValue.value) {
|
|||
|
|
updatePasswordStrength(inputValue.value)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.secure-input {
|
|||
|
|
width: 100%;
|
|||
|
|
margin-bottom: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__label {
|
|||
|
|
display: block;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
font-weight: 500;
|
|||
|
|
color: #333;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__required {
|
|||
|
|
color: #f56c6c;
|
|||
|
|
margin-left: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__wrapper {
|
|||
|
|
position: relative;
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__field {
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 12px 16px;
|
|||
|
|
border: 1px solid #dcdfe6;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: #606266;
|
|||
|
|
background-color: #fff;
|
|||
|
|
transition: border-color 0.2s;
|
|||
|
|
outline: none;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__field:focus {
|
|||
|
|
border-color: #409eff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__field--small {
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__field--large {
|
|||
|
|
padding: 16px 20px;
|
|||
|
|
font-size: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__field--disabled {
|
|||
|
|
background-color: #f5f7fa;
|
|||
|
|
color: #c0c4cc;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__field--readonly {
|
|||
|
|
background-color: #f5f7fa;
|
|||
|
|
color: #606266;
|
|||
|
|
cursor: default;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__field--has-error {
|
|||
|
|
border-color: #f56c6c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__field--password {
|
|||
|
|
padding-right: 80px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__toggle,
|
|||
|
|
.secure-input__clear {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 50%;
|
|||
|
|
transform: translateY(-50%);
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
color: #909399;
|
|||
|
|
font-size: 16px;
|
|||
|
|
padding: 4px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
transition: color 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__toggle:hover,
|
|||
|
|
.secure-input__clear:hover {
|
|||
|
|
color: #409eff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__toggle {
|
|||
|
|
right: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__clear {
|
|||
|
|
right: 40px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__strength {
|
|||
|
|
margin-top: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__strength-bar {
|
|||
|
|
height: 4px;
|
|||
|
|
background-color: #e4e7ed;
|
|||
|
|
border-radius: 2px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__strength-fill {
|
|||
|
|
height: 100%;
|
|||
|
|
transition: width 0.3s, background-color 0.3s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__strength-fill.weak {
|
|||
|
|
background-color: #f56c6c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__strength-fill.medium {
|
|||
|
|
background-color: #e6a23c;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__strength-fill.strong {
|
|||
|
|
background-color: #67c23a;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__strength-text {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #909399;
|
|||
|
|
margin-top: 4px;
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__error {
|
|||
|
|
color: #f56c6c;
|
|||
|
|
font-size: 12px;
|
|||
|
|
margin-top: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input__hint {
|
|||
|
|
color: #909399;
|
|||
|
|
font-size: 12px;
|
|||
|
|
margin-top: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.secure-input.has-error .secure-input__field {
|
|||
|
|
border-color: #f56c6c;
|
|||
|
|
}
|
|||
|
|
</style>
|