This commit is contained in:
Zyronon
2025-11-11 11:47:59 +00:00
parent f502e2d713
commit bf589dce92
14 changed files with 662 additions and 732 deletions

View File

@@ -86,6 +86,7 @@ defineEmits(['click'])
padding: 0 1.3rem;
height: 2.4rem;
font-size: 0.9rem;
border-radius: .5rem;
}
& > span {

View File

@@ -1,13 +1,18 @@
<script setup lang="ts">
import { ref, useAttrs, watch } from 'vue';
import {defineComponent, ref, useAttrs, watch, computed} from 'vue';
import Close from "@/components/icon/Close.vue";
import { useDisableEventListener } from "@/hooks/event.ts";
import {useDisableEventListener} from "@/hooks/event.ts";
defineOptions({
name: "BaseInput",
})
const props = defineProps({
modelValue: [String, Number],
placeholder: String,
disabled: Boolean,
autofocus: Boolean,
error: Boolean,
type: {
type: String,
default: 'text',
@@ -32,34 +37,31 @@ const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur
const attrs = useAttrs();
const inputValue = ref(props.modelValue);
const errorMsg = ref('');
let focus = $ref(false)
let inputEl = $ref<HTMLDivElement>()
const passwordVisible = ref(false)
const inputType = computed(() => {
if (props.type === 'password') {
return passwordVisible.value ? 'text' : 'password'
}
return props.type
})
const togglePasswordVisibility = () => {
passwordVisible.value = !passwordVisible.value
}
watch(() => props.modelValue, (val) => {
inputValue.value = val;
validate(val);
});
const validate = (val: string | number | null | undefined) => {
let err = '';
const strVal = val == null ? '' : String(val);
if (props.required && !strVal.trim()) {
err = '不能为空';
} else if (props.maxLength && strVal.length > props.maxLength) {
err = `长度不能超过 ${props.maxLength} 个字符`;
}
errorMsg.value = err;
emit('validation', err === '', err);
return err === '';
};
const onInput = (e: Event) => {
const target = e.target as HTMLInputElement;
inputValue.value = target.value;
validate(target.value);
emit('update:modelValue', target.value);
emit('input', e);
emit('change', e);
};
const onChange = (e: Event) => {
@@ -73,14 +75,11 @@ const onFocus = (e: FocusEvent) => {
const onBlur = (e: FocusEvent) => {
focus = false
validate(inputValue.value);
emit('blur', e);
};
const clearInput = () => {
inputValue.value = '';
validate('');
emit('update:modelValue', '');
};
@@ -99,50 +98,63 @@ const vFocus = {
</script>
<template>
<div class="base-input2"
<div class="base-input"
ref="inputEl"
:class="{ 'is-disabled': disabled, 'has-error': errorMsg,focus, [`base-input2--${size}`]: true }">
:class="{ 'is-disabled': disabled, 'error': props.error, focus, [`base-input--${size}`]: true }">
<slot name="subfix"></slot>
<input
v-bind="attrs"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:value="inputValue"
@input="onInput"
@change="onChange"
@focus="onFocus"
@blur="onBlur"
class="inner"
v-focus="autofocus"
:maxlength="maxLength"
v-bind="attrs"
:type="inputType"
:placeholder="placeholder"
:disabled="disabled"
:value="inputValue"
@input="onInput"
@change="onChange"
@focus="onFocus"
@blur="onBlur"
class="inner"
v-focus="autofocus"
:maxlength="maxLength"
/>
<slot name="prefix"></slot>
<Close
v-if="clearable && inputValue && !disabled"
@click="clearInput"/>
<div v-if="errorMsg" class="base-input2__error">{{ errorMsg }}</div>
v-if="clearable && inputValue && !disabled"
@click="clearInput"/>
<!-- Password visibility toggle -->
<div
v-if="type === 'password' && !disabled"
class="password-toggle"
@click="togglePasswordVisibility"
:title="passwordVisible ? '隐藏密码' : '显示密码'">
<IconFluentEye16Regular v-if="!passwordVisible"/>
<IconFluentEyeOff16Regular v-else/>
</div>
</div>
</template>
<style scoped lang="scss">
.base-input2 {
.base-input {
position: relative;
display: inline-flex;
box-sizing: border-box;
width: 100%;
border: 1px solid var(--color-input-border);
border-radius: 4px;
border-radius: 6px;
overflow: hidden;
padding: .2rem .3rem;
transition: all .3s;
align-items: center;
background: var(--color-input-bg);
::placeholder {
font-size: 0.9rem;
color: darkgray;
}
// normal size (default)
&--normal {
padding: .2rem .3rem;
.inner {
height: 1.5rem;
font-size: 1rem;
@@ -151,8 +163,9 @@ const vFocus = {
// large size
&--large {
padding: .6rem .8rem;
padding: .4rem .6rem;
border-radius: .5rem;
.inner {
height: 2rem;
font-size: 1.125rem;
@@ -163,16 +176,9 @@ const vFocus = {
opacity: 0.6;
}
&.has-error {
.base-input2__inner {
border-color: #f56c6c;
}
.base-input2__error {
color: #f56c6c;
font-size: 0.85rem;
margin-top: 0.25rem;
}
&.error {
border-color: #f56c6c;
background: rgba(245, 108, 108, 0.07);
}
&.focus {
@@ -184,10 +190,6 @@ const vFocus = {
cursor: not-allowed;
}
&__error {
padding-left: 0.5rem;
}
.inner {
flex: 1;
font-size: 1rem;
@@ -200,5 +202,22 @@ const vFocus = {
background: transparent;
width: 100%;
}
.password-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
margin-left: 4px;
cursor: pointer;
color: var(--color-input-color);
opacity: 0.6;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
</style>

View File

@@ -5,7 +5,8 @@
</template>
<script setup lang="ts">
import {ref, provide, watch, toRef} from 'vue'
import {provide, ref, toRef} from 'vue'
import type {FormField, FormModel, FormRules} from './types'
interface Field {
prop: string
@@ -14,8 +15,8 @@ interface Field {
}
const props = defineProps({
model: Object,
rules: Object // { word: [{required:true,...}, ...], name: [...] }
model: Object as () => FormModel,
rules: Object as () => FormRules
})
const fields = ref<Field[]>([])
@@ -25,7 +26,7 @@ const registerField = (field: Field) => {
}
// 校验整个表单
const validate = (cb): boolean => {
function validate(cb) {
let valid = true
fields.value.forEach(f => {
const fieldRules = props.rules?.[f.prop] || []
@@ -35,10 +36,23 @@ const validate = (cb): boolean => {
cb(valid)
}
// 校验指定字段
function validateField(fieldName: string, cb?: (valid: boolean) => void): boolean {
const field = fields.value.find(f => f.prop === fieldName)
if (field) {
const fieldRules = props.rules?.[fieldName] || []
const valid = field.validate(fieldRules)
if (cb) cb(valid)
return valid
}
if (cb) cb(true)
return true
}
provide('registerField', registerField)
provide('formModel', toRef(props, 'model'))
provide('formValidate', validate)
provide('formRules', props.rules)
defineExpose({validate})
defineExpose({validate, validateField})
</script>

View File

@@ -11,7 +11,7 @@ let error = $ref('')
// 拿到 form 的 model 和注册函数
const formModel = inject<ref>('formModel')
const registerField = inject('registerField')
const registerField = inject<Function>('registerField')
const formRules = inject('formRules', {})
const myRules = $computed(() => {
@@ -31,43 +31,94 @@ const validate = (rules) => {
error = rule.message
return false
}
if (rule.min && val && val.toString().length < rule.min) {
error = rule.message
return false
}
if (rule.max && val && val.toString().length > rule.max) {
error = rule.message
return false
}
if (rule.validator) {
try {
rule.validator(rule, val)
return true
} catch (e) {
error = e.message
return false
}
}
}
return true
}
// 自动触发 blur 校验
const handleBlur = () => {
function handleBlur() {
const blurRules = myRules.filter((r) => r.trigger === 'blur')
if (blurRules.length) validate(blurRules)
}
function handChange() {
error = ''
}
// 注册到 Form
onMounted(() => {
registerField && registerField({prop: props.prop, modelValue: value, validate})
})
let slot = useSlots()
function patchVNode(vnode, patchFn) {
if (!vnode) return vnode
// 如果当前节点就是我们要找的 BaseInput
if (vnode.type && vnode.type.name) {
return patchFn(vnode)
}
// 如果有子节点,则递归修改
if (Array.isArray(vnode.children)) {
vnode.children = vnode.children.map(child => patchVNode(child, patchFn))
}
return vnode
}
defineRender(() => {
let DefaultNode = slot.default()[0]
return <div class="form-item mb-6 flex gap-space">
let DefaultNode: any = slot.default()[0]
// 对 DefaultNode 深度查找 BaseInput 并加上 onBlur / error
DefaultNode = patchVNode(DefaultNode, vnode => {
return {
...vnode,
props: {
...vnode.props,
error: !!error,
onBlur: handleBlur,
onChange: handChange
},
}
})
return <div class="form-item flex gap-space">
{props.label &&
<label class="w-20 flex items-start mt-1 justify-end">
{myRules.length ? <span class="form-error">*</span> : null} {props.label}
</label>}
<label class="w-20 flex items-start mt-1 justify-end">
{myRules.length ? <span class="form-error">*</span> : null} {props.label}
</label>}
<div class="flex-1 relative">
<DefaultNode onBlur={handleBlur}/>
<div class="form-error absolute top-[100%] anim" style={{opacity: error ? 1 : 0}}>{error}</div>
<DefaultNode/>
<div class="form-error my-0.5 anim" style={{opacity: error ? 1 : 0}}>{error} &nbsp;</div>
</div>
</div>
})
</script>
<style scoped lang="scss">
.form-item {
.form-error {
color: #f56c6c;
font-size: 0.8rem;
}
.form-error {
color: #f56c6c;
font-size: 0.8rem;
}
</style>

View File

@@ -0,0 +1,65 @@
// Form 组件的 TypeScript 类型定义
// 表单字段接口
export interface FormField {
prop: string
modelValue: any
validate: (rules: FormRule[]) => boolean
}
// 表单规则接口
export interface FormRule {
required?: boolean
message?: string
pattern?: RegExp
validator?: (rule: FormRule, value: any, callback: (error?: Error) => void) => void
min?: number
max?: number
len?: number
type?: string
}
// 表单规则对象类型
export type FormRules = Record<string, FormRule[]>
// 表单模型对象类型
export type FormModel = Record<string, any>
// Form 组件的 Props 接口
export interface FormProps {
model?: FormModel
rules?: FormRules
}
// Form 组件的实例接口
export interface FormInstance {
/**
* 校验整个表单
* @param callback 校验完成后的回调函数,接收校验结果
*/
validate: (callback: (valid: boolean) => void) => void
/**
* 校验指定字段
* @param fieldName 要校验的字段名称
* @param callback 可选的回调函数,接收校验结果
* @returns 校验是否通过
*/
validateField: (fieldName: string, callback?: (valid: boolean) => void) => boolean
}
// 注入的上下文类型
export interface FormContext {
registerField: (field: FormField) => void
formModel: FormModel
formValidate: (callback: (valid: boolean) => void) => void
formRules: FormRules
}
// 验证状态枚举
export enum ValidateStatus {
Success = 'success',
Error = 'error',
Validating = 'validating',
Pending = 'pending'
}