| <script setup lang="ts"> | 
| // https://github.com/view-design/ViewUIPlus/blob/master/src/components/input-number/input-number.vue | 
| // https://github.com/arco-design/arco-design-vue/blob/main/packages/web-vue/components/input-number/input-number.tsx | 
| import { usePointerSwipe, type MaybeRefOrGetter } from '@vueuse/core'; | 
| import { isNumber, isUndefined } from 'lodash-es'; | 
| import NP from 'number-precision'; | 
|   | 
| NP.enableBoundaryChecking(false); | 
|   | 
| type StepMethods = 'minus' | 'plus'; | 
|   | 
| const props = withDefaults( | 
|   defineProps<{ | 
|     autofocus?: boolean; | 
|     /** | 
|      * @zh 从 `formatter` 转换为数字,和 `formatter` 搭配使用 | 
|      * @en Convert from `formatter` to number, and use with `formatter` | 
|      */ | 
|     parser?: (val: string) => string; | 
|     /** | 
|      * @zh 定义输入框展示值 | 
|      * @en Define the display value of the input | 
|      */ | 
|     formatter?: (val: string) => string; | 
|     /** | 
|      * @zh 最小值 | 
|      * @en Min | 
|      */ | 
|     min?: number; | 
|     /** | 
|      * @zh 最大值 | 
|      * @en Max | 
|      */ | 
|     max?: number; | 
|     /** | 
|      * @zh 数字精度 | 
|      * @en Precision | 
|      */ | 
|     precision?: number; | 
|     /** | 
|      * @zh 数字变化步长 | 
|      * @en Number change step | 
|      */ | 
|     step?: number; | 
|     /** | 
|      * @zh 绑定值 | 
|      * @en Value | 
|      */ | 
|     modelValue?: number; | 
|     /** | 
|      * @zh 默认值(非受控模式) | 
|      * @en Default value (uncontrolled mode) | 
|      */ | 
|     defaultValue?: number; | 
|     /** | 
|      * @zh 触发 `v-model` 的事件 | 
|      * @en Trigger event for `v-model` | 
|      */ | 
|     modelEvent?: 'change' | 'input'; | 
|     /** | 
|      * @zh 模式(false:embed按钮内嵌模式,true:button左右按钮模式) | 
|      */ | 
|     controlsOutside?: boolean; | 
|     /** | 
|      * @zh 是否隐藏按钮(仅在`embed`模式可用) | 
|      * @en Whether to hide the button (only available in `embed` mode) | 
|      */ | 
|     hideButton?: boolean; | 
|     downDisabled?: boolean; | 
|     upDisabled?: boolean; | 
|     /** | 
|      * @zh 输入框大小 | 
|      * @en Input size | 
|      * @values 'small','large','default' | 
|      * @defaultValue 'default' | 
|      */ | 
|     size?: 'small' | 'large' | 'default'; | 
|     /** | 
|      * @zh 是否禁用 | 
|      * @en Whether to disable | 
|      */ | 
|     disabled?: boolean; | 
|     /** | 
|      * @zh 只读 | 
|      * @en Readonly | 
|      * @version 3.33.1 | 
|      */ | 
|     readonly?: boolean; | 
|     /** | 
|      * @zh 输入框提示文字 | 
|      * @en Input prompt text | 
|      */ | 
|     placeholder?: string; | 
|     append?: string; | 
|     prepend?: string; | 
|   }>(), | 
|   { | 
|     min: -Infinity, | 
|     max: Infinity, | 
|     step: 1, | 
|     modelEvent: 'change', | 
|     size: 'default', | 
|     precision: 2, | 
|   } | 
| ); | 
|   | 
| const emit = defineEmits<{ | 
|   (e: 'update:modelValue', value: number | undefined): void; | 
|   (e: 'on-change', value: number): void; | 
|   (e: 'on-focus', value: FocusEvent): void; | 
|   (e: 'on-blur', value: FocusEvent): void; | 
|   (e: 'on-input', value: number | undefined, inputValue: string, ev: Event): void; | 
|   (e: 'on-change', value: number | undefined, ev: Event): void; | 
| }>(); | 
|   | 
| const prefixCls = 'ivu-input-number'; | 
| const iconPrefixCls = 'ivu-icon'; | 
| const inputWrapClasses = ref(`${prefixCls}-input-wrap`); | 
| const inputClasses = ref(`${prefixCls}-input`); | 
| const handlerClasses = ref(`${prefixCls}-handler-wrap`); | 
| const innerUpClasses = ref( | 
|   `${prefixCls}-handler-up-inner ${iconPrefixCls} ${iconPrefixCls}-ios-arrow-up` | 
| ); | 
| const innerDownClasses = ref( | 
|   `${prefixCls}-handler-down-inner ${iconPrefixCls} ${iconPrefixCls}-ios-arrow-down` | 
| ); | 
| const upClasses = ref([ | 
|   `${prefixCls}-handler`, | 
|   `${prefixCls}-handler-up`, | 
|   { | 
|     [`${prefixCls}-handler-up-disabled`]: props.upDisabled, | 
|   }, | 
| ]); | 
| const downClasses = ref([ | 
|   `${prefixCls}-handler`, | 
|   `${prefixCls}-handler-down`, | 
|   { | 
|     [`${prefixCls}-handler-down-disabled`]: props.downDisabled, | 
|   }, | 
| ]); | 
|   | 
| const focused = ref(false); | 
|   | 
| const wrapClasses = computed(() => { | 
|   return [ | 
|     `${prefixCls}`, | 
|     { | 
|       [`${prefixCls}-${props.size}`]: !!props.size, | 
|       [`${prefixCls}-disabled`]: props.disabled, | 
|       [`${prefixCls}-focused`]: focused.value, | 
|       [`${prefixCls}-controls-outside`]: props.controlsOutside, | 
|     }, | 
|   ]; | 
| }); | 
|   | 
| const inputRef = ref<HTMLInputElement>(); | 
|   | 
| const mergedPrecision = computed(() => { | 
|   if (isNumber(props.precision)) { | 
|     const decimal = `${props.step}`.split('.')[1]; | 
|     const stepPrecision = (decimal && decimal.length) || 0; | 
|     return Math.max(stepPrecision, props.precision); | 
|   } | 
|   return undefined; | 
| }); | 
|   | 
| const getStringValue = (number: number | undefined) => { | 
|   if (!isNumber(number)) { | 
|     return ''; | 
|   } | 
|   | 
|   const numString = mergedPrecision.value | 
|     ? number.toFixed(mergedPrecision.value).replace(/\.?0+$/, '') | 
|     : String(number); | 
|   return props.formatter?.(numString) ?? numString; | 
| }; | 
|   | 
| const _value = ref(getStringValue(props.modelValue ?? props.defaultValue)); | 
|   | 
| const handleFocus = (event: FocusEvent) => { | 
|   focused.value = true; | 
|   emit('on-focus', event); | 
| }; | 
|   | 
| const handleBlur = (event: FocusEvent) => { | 
|   focused.value = false; | 
|   emit('on-blur', event); | 
| }; | 
|   | 
| const minus = (e: Event) => { | 
|   handleStepButton(e, 'minus', true); | 
| }; | 
| const plus = (e: Event) => { | 
|   handleStepButton(e, 'plus', true); | 
| }; | 
|   | 
| const handleKeyDown = (e: KeyboardEvent) => { | 
|   if (e.key === 'ArrowUp') { | 
|     e.preventDefault(); | 
|     !props.readonly && nextStep('plus', e); | 
|   } else if (e.key === 'ArrowDown') { | 
|     e.preventDefault(); | 
|     !props.readonly && nextStep('minus', e); | 
|   } | 
| }; | 
|   | 
| const nextStep = (method: StepMethods, event: Event) => { | 
|   if ( | 
|     (method === 'plus' && (isMax.value || props.upDisabled)) || | 
|     (method === 'minus' && (isMin.value || props.downDisabled)) | 
|   ) { | 
|     return; | 
|   } | 
|   let nextValue: number | undefined; | 
|   if (isNumber(valueNumber.value)) { | 
|     nextValue = getLegalValue(NP[method](valueNumber.value, props.step)); | 
|   } else { | 
|     nextValue = props.min === -Infinity ? 0 : props.min; | 
|   } | 
|   _value.value = getStringValue(nextValue); | 
|   updateNumberStatus(nextValue); | 
|   emit('update:modelValue', nextValue); | 
|   emit('on-change', nextValue, event); | 
| }; | 
|   | 
| // 步长重复定时器 | 
| let repeatTimer = 0; | 
| const SPEED = 150; | 
| const clearRepeatTimer = () => { | 
|   if (repeatTimer) { | 
|     window.clearTimeout(repeatTimer); | 
|     repeatTimer = 0; | 
|   } | 
| }; | 
| const clearRepeatTimerProps = { | 
|   onMouseup: clearRepeatTimer, | 
|   onMouseleave: clearRepeatTimer, | 
| }; | 
|   | 
| const handleStepButton = (event: Event, method: StepMethods, needRepeat = false) => { | 
|   event.preventDefault(); | 
|   inputRef.value?.focus(); | 
|   nextStep(method, event); | 
|   // 长按时持续触发 | 
|   if (needRepeat) { | 
|     repeatTimer = window.setTimeout( | 
|       () => (event.target as HTMLElement).dispatchEvent(event), | 
|       SPEED | 
|     ); | 
|   } | 
| }; | 
|   | 
| const valueNumber = computed(() => { | 
|   if (!_value.value) { | 
|     return undefined; | 
|   } | 
|   const number = Number(props.parser?.(_value.value) ?? _value.value); | 
|   return Number.isNaN(number) ? undefined : number; | 
| }); | 
|   | 
| const isMin = ref(isNumber(valueNumber.value) && valueNumber.value <= props.min); | 
| const isMax = ref(isNumber(valueNumber.value) && valueNumber.value >= props.max); | 
|   | 
| const updateNumberStatus = (number: number | undefined) => { | 
|   let _isMin = false; | 
|   let _isMax = false; | 
|   if (isNumber(number)) { | 
|     if (number <= props.min) { | 
|       _isMin = true; | 
|     } | 
|     if (number >= props.max) { | 
|       _isMax = true; | 
|     } | 
|   } | 
|   if (isMax.value !== _isMax) { | 
|     isMax.value = _isMax; | 
|   } | 
|   if (isMin.value !== _isMin) { | 
|     isMin.value = _isMin; | 
|   } | 
| }; | 
|   | 
| const handleInput = (e: Event) => { | 
|   let { value } = e.target as HTMLInputElement; | 
|   value = value.trim().replace(/。/g, '.'); | 
|   value = props.parser?.(value) ?? value; | 
|   if (isNumber(Number(value)) || /^(\.|-)$/.test(value)) { | 
|     _value.value = props.formatter?.(value) ?? value; | 
|     updateNumberStatus(valueNumber.value); | 
|     if (props.modelEvent === 'input') { | 
|       emit('update:modelValue', valueNumber.value); | 
|     } | 
|     emit('on-input', valueNumber.value, _value.value, e); | 
|   } | 
| }; | 
|   | 
| const getLegalValue = (value: number | undefined): number | undefined => { | 
|   if (isUndefined(value)) { | 
|     return undefined; | 
|   } | 
|   if (isNumber(props.min) && value < props.min) { | 
|     value = props.min; | 
|   } | 
|   if (isNumber(props.max) && value > props.max) { | 
|     value = props.max; | 
|   } | 
|   return isNumber(mergedPrecision.value) ? NP.round(value, mergedPrecision.value) : value; | 
| }; | 
|   | 
| const handleChange = (e: Event) => { | 
|   const finalValue = getLegalValue(valueNumber.value); | 
|   const stringValue = getStringValue(finalValue); | 
|   if (finalValue !== valueNumber.value || _value.value !== stringValue) { | 
|     _value.value = stringValue; | 
|     updateNumberStatus(finalValue); | 
|   } | 
|   nextTick(() => { | 
|     if (isNumber(props.modelValue) && props.modelValue !== finalValue) { | 
|       _value.value = getStringValue(props.modelValue); | 
|       updateNumberStatus(props.modelValue); | 
|     } | 
|   }); | 
|   emit('update:modelValue', finalValue); | 
|   emit('on-change', finalValue, e); | 
| }; | 
|   | 
| const handleExceedRange = () => { | 
|   const finalValue = getLegalValue(valueNumber.value); | 
|   const stringValue = getStringValue(finalValue); | 
|   if (finalValue !== valueNumber.value || _value.value !== stringValue) { | 
|     _value.value = stringValue; | 
|   } | 
|   emit('update:modelValue', finalValue); | 
| }; | 
|   | 
| // 滑动相关 | 
| const appendLabelRef = ref<HTMLElement>(); | 
| const prependLabelRef = ref<HTMLElement>(); | 
| const useSwipe = (target: MaybeRefOrGetter<HTMLElement | null | undefined>) => { | 
|   const startValue = ref<number>(); | 
|   const { posStart, posEnd } = usePointerSwipe(target, { | 
|     threshold: 0, | 
|     onSwipeStart: () => { | 
|       startValue.value = valueNumber.value; | 
|     }, | 
|     onSwipe: (e: PointerEvent) => { | 
|       if (!isNumber(startValue.value)) return; | 
|       const newValue = startValue.value + NP.round(posEnd.x - posStart.x, 0) * props.step; | 
|       _value.value = getStringValue(newValue); | 
|       props.modelEvent === 'input' ? handleInput(e) : handleChange(e); | 
|     }, | 
|   }); | 
| }; | 
|   | 
| // mounted | 
| onMounted(() => { | 
|   appendLabelRef.value && useSwipe(appendLabelRef); | 
|   prependLabelRef.value && useSwipe(prependLabelRef); | 
| }); | 
|   | 
| // watch | 
| watch( | 
|   () => props.modelValue, | 
|   (value: number | undefined) => { | 
|     if (value !== valueNumber.value) { | 
|       _value.value = getStringValue(value); | 
|       updateNumberStatus(value); | 
|     } | 
|   } | 
| ); | 
| watch( | 
|   () => props.min, | 
|   (newVal) => { | 
|     const _isMin = isNumber(valueNumber.value) && valueNumber.value <= newVal; | 
|     if (isMin.value !== _isMin) { | 
|       isMin.value = _isMin; | 
|     } | 
|   | 
|     const isExceedMinValue = isNumber(valueNumber.value) && valueNumber.value < newVal; | 
|     if (isExceedMinValue) { | 
|       handleExceedRange(); | 
|     } | 
|   } | 
| ); | 
| watch( | 
|   () => props.max, | 
|   (newVal) => { | 
|     const _isMax = isNumber(valueNumber.value) && valueNumber.value >= newVal; | 
|     if (isMax.value !== _isMax) { | 
|       isMax.value = _isMax; | 
|     } | 
|   | 
|     const isExceedMaxValue = isNumber(valueNumber.value) && valueNumber.value > newVal; | 
|     if (isExceedMaxValue) { | 
|       handleExceedRange(); | 
|     } | 
|   } | 
| ); | 
|   | 
| // 导出函数 | 
| defineExpose({ | 
|   minus, | 
|   plus, | 
|   input: inputRef.value, | 
|   /** | 
|    * @zh 使输入框获取焦点 | 
|    * @en Make the input box focus | 
|    * @public | 
|    */ | 
|   focus: () => { | 
|     inputRef.value?.focus(); | 
|   }, | 
|   /** | 
|    * @zh 使输入框失去焦点 | 
|    * @en Make the input box lose focus | 
|    * @public | 
|    */ | 
|   blur: () => { | 
|     inputRef.value?.blur(); | 
|   }, | 
| }); | 
| </script> | 
|   | 
| <template> | 
|   <div :class="wrapClasses"> | 
|     <template v-if="controlsOutside"> | 
|       <div | 
|         class="ivu-input-number-controls-outside-btn ivu-input-number-controls-outside-down" | 
|         :class="{ 'ivu-input-number-controls-outside-btn-disabled': downDisabled }" | 
|         @mousedown="minus" | 
|         v-bind="clearRepeatTimerProps" | 
|       > | 
|         <i class="ivu-icon ivu-icon-ios-remove"></i> | 
|       </div> | 
|       <div | 
|         class="ivu-input-number-controls-outside-btn ivu-input-number-controls-outside-up" | 
|         :class="{ 'ivu-input-number-controls-outside-btn-disabled': upDisabled }" | 
|         @mousedown="plus" | 
|         v-bind="clearRepeatTimerProps" | 
|       > | 
|         <i class="ivu-icon ivu-icon-ios-add"></i> | 
|       </div> | 
|     </template> | 
|     <div :class="handlerClasses" v-else-if="!hideButton"> | 
|       <a :class="upClasses" @mousedown="plus" v-bind="clearRepeatTimerProps"> | 
|         <span :class="innerUpClasses"></span> | 
|       </a> | 
|       <a :class="downClasses" @mousedown="minus" v-bind="clearRepeatTimerProps"> | 
|         <span :class="innerDownClasses"></span> | 
|       </a> | 
|     </div> | 
|     <div :class="inputWrapClasses"> | 
|       <template v-if="$slots.prefix"> | 
|         <slot name="prefix"></slot> | 
|       </template> | 
|       <label ref="appendLabelRef" :class="`${inputWrapClasses}__label`" v-else-if="append"> | 
|         {{ append }} | 
|       </label> | 
|       <input | 
|         ref="inputRef" | 
|         type="text" | 
|         autocomplete="off" | 
|         spellcheck="false" | 
|         role="spinbutton" | 
|         :aria-valuemax="max" | 
|         :aria-valuemin="min" | 
|         :aria-valuenow="_value" | 
|         :value="_value" | 
|         :class="inputClasses" | 
|         :disabled="disabled" | 
|         :autofocus="autofocus" | 
|         :readonly="readonly" | 
|         :placeholder="placeholder" | 
|         @focus="handleFocus" | 
|         @blur="handleBlur" | 
|         @input="handleInput" | 
|         @change="handleChange" | 
|         @keydown="handleKeyDown" | 
|       /> | 
|       <template v-if="$slots.suffix"> | 
|         <slot name="suffix"></slot> | 
|       </template> | 
|       <label ref="prependLabelRef" :class="`${inputWrapClasses}__label`" v-else-if="prepend"> | 
|         {{ prepend }} | 
|       </label> | 
|     </div> | 
|   </div> | 
| </template> | 
|   | 
| <style scoped lang="less"> | 
| @import 'view-ui-plus/src/styles/custom.less'; | 
|   | 
| @input-number-prefix-cls: ~'@{css-prefix}input-number'; | 
|   | 
| .@{input-number-prefix-cls} { | 
|   border: none; | 
|   background: @input-group-bg; | 
|   | 
|   &-input { | 
|     background: none; | 
|   } | 
|   | 
|   &-handler { | 
|     height: (@input-height-base / 2); | 
|   | 
|     &-wrap { | 
|       background: @input-group-bg; | 
|       border-left-color: transparent; | 
|     } | 
|   | 
|     &-down { | 
|       border-top: none; | 
|     } | 
|   | 
|     &-up-inner, | 
|     &-down-inner { | 
|       line-height: (@input-height-base / 2); | 
|     } | 
|   } | 
|   | 
|   &-input-wrap { | 
|     display: flex; | 
|     align-items: center; | 
|   | 
|     &__label { | 
|       flex-shrink: 0; | 
|       padding: 0 10px; | 
|       user-select: none; | 
|       cursor: ew-resize; | 
|     } | 
|   } | 
|   | 
|   &-small { | 
|     .@{input-number-prefix-cls}-handler { | 
|       height: (@input-height-small / 2); | 
|   | 
|       &-up-inner, | 
|       &-down-inner { | 
|         line-height: (@input-height-small / 2); | 
|       } | 
|     } | 
|   } | 
|   | 
|   &-large { | 
|     .@{input-number-prefix-cls}-handler { | 
|       height: (@input-height-large / 2); | 
|   | 
|       &-up-inner, | 
|       &-down-inner { | 
|         line-height: (@input-height-large / 2); | 
|       } | 
|     } | 
|   } | 
| } | 
| </style> |