wd-input-number.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. <template>
  2. <view :class="`wd-input-number ${customClass} ${disabled ? 'is-disabled' : ''} ${withoutInput ? 'is-without-input' : ''}`" :style="customStyle">
  3. <!-- 减号按钮 -->
  4. <view
  5. :class="`wd-input-number__action ${minDisabled || disableMinus ? 'is-disabled' : ''}`"
  6. @click="handleClick('sub')"
  7. @touchstart="handleTouchStart('sub')"
  8. @touchend.stop="handleTouchEnd"
  9. >
  10. <wd-icon name="decrease" custom-class="wd-input-number__action-icon"></wd-icon>
  11. </view>
  12. <!-- 输入框 -->
  13. <view v-if="!withoutInput" class="wd-input-number__inner" @click.stop="">
  14. <input
  15. class="wd-input-number__input"
  16. :style="`${inputWidth ? 'width: ' + inputWidth : ''}`"
  17. :type="inputType"
  18. :input-mode="precision ? 'decimal' : 'numeric'"
  19. :disabled="disabled || disableInput"
  20. :value="String(inputValue)"
  21. :placeholder="placeholder"
  22. :adjust-position="adjustPosition"
  23. @input="handleInput"
  24. @focus="handleFocus"
  25. @blur="handleBlur"
  26. />
  27. <view class="wd-input-number__input-border"></view>
  28. </view>
  29. <!-- 加号按钮 -->
  30. <view
  31. :class="`wd-input-number__action ${maxDisabled || disablePlus ? 'is-disabled' : ''}`"
  32. @click="handleClick('add')"
  33. @touchstart="handleTouchStart('add')"
  34. @touchend.stop="handleTouchEnd"
  35. >
  36. <wd-icon name="add" custom-class="wd-input-number__action-icon"></wd-icon>
  37. </view>
  38. </view>
  39. </template>
  40. <script lang="ts">
  41. export default {
  42. name: 'wd-input-number',
  43. options: {
  44. virtualHost: true,
  45. addGlobalClass: true,
  46. styleIsolation: 'shared'
  47. }
  48. }
  49. </script>
  50. <script lang="ts" setup>
  51. import wdIcon from '../wd-icon/wd-icon.vue'
  52. import { computed, nextTick, ref, watch } from 'vue'
  53. import { isDef, isEqual } from '../common/util'
  54. import { inputNumberProps, type OperationType } from './types'
  55. import { callInterceptor } from '../common/interceptor'
  56. const props = defineProps(inputNumberProps)
  57. const emit = defineEmits<{
  58. /**
  59. * 数值变化事件
  60. */
  61. (e: 'change', value: { value: number | string }): void
  62. /**
  63. * 输入框聚焦事件
  64. */
  65. (e: 'focus', detail: any): void
  66. /**
  67. * 输入框失焦事件
  68. */
  69. (e: 'blur', value: { value: string | number }): void
  70. /**
  71. * v-model 更新事件
  72. */
  73. (e: 'update:modelValue', value: number | string): void
  74. }>()
  75. const inputValue = ref<string | number>(getInitValue())
  76. let longPressTimer: ReturnType<typeof setTimeout> | null = null
  77. /**
  78. * 判断数字是否达到最小值限制
  79. */
  80. const minDisabled = computed(() => {
  81. const val = toNumber(inputValue.value)
  82. return props.disabled || val <= props.min || addStep(val, -props.step) < props.min
  83. })
  84. /**
  85. * 判断数字是否达到最大值限制
  86. */
  87. const maxDisabled = computed(() => {
  88. const val = toNumber(inputValue.value)
  89. return props.disabled || val >= props.max || addStep(val, props.step) > props.max
  90. })
  91. // 监听 modelValue 变化
  92. watch(
  93. () => props.modelValue,
  94. (val) => {
  95. inputValue.value = formatValue(val)
  96. }
  97. )
  98. // 监听 max, min, precision 变化时重新格式化当前值
  99. watch([() => props.max, () => props.min, () => props.precision], () => {
  100. const val = toNumber(inputValue.value)
  101. inputValue.value = formatValue(val)
  102. })
  103. /**
  104. * 获取初始值
  105. */
  106. function getInitValue() {
  107. if (!props.updateOnInit) {
  108. // 不自动修正时,仅做显示格式化,不修正值
  109. return formatDisplay(props.modelValue)
  110. }
  111. const formatted = formatValue(props.modelValue)
  112. // 如果格式化后的值与原始值不同,同步到外部
  113. if (!isEqual(String(formatted), String(props.modelValue))) {
  114. emit('update:modelValue', formatted)
  115. }
  116. return formatted
  117. }
  118. /**
  119. * 获取数字的小数位数
  120. */
  121. function getPrecision(val?: number) {
  122. if (!isDef(val)) return 0
  123. const str = val.toString()
  124. const dotIndex = str.indexOf('.')
  125. return dotIndex === -1 ? 0 : str.length - dotIndex - 1
  126. }
  127. /**
  128. * 按指定精度处理数值
  129. */
  130. function toPrecision(val: number) {
  131. const precision = Number(props.precision)
  132. return Math.round(val * Math.pow(10, precision)) / Math.pow(10, precision)
  133. }
  134. /**
  135. * 将字符串或数字转换为标准数值
  136. */
  137. function toNumber(val: string | number): number {
  138. // 空值处理
  139. if (props.allowNull && (!isDef(val) || val === '')) {
  140. return NaN
  141. }
  142. if (!isDef(val) || val === '') {
  143. return props.min
  144. }
  145. let str = String(val)
  146. // 处理中间输入状态
  147. if (str.endsWith('.')) str = str.slice(0, -1)
  148. if (str.startsWith('.')) str = '0' + str
  149. if (str.startsWith('-.')) str = '-0' + str.substring(1)
  150. if (str === '-' || str === '') return props.min
  151. let num = Number(str)
  152. if (isNaN(num)) num = props.min
  153. return normalizeValue(num)
  154. }
  155. /**
  156. * 标准化数值(应用步进、边界、精度规则)
  157. */
  158. function normalizeValue(val: number): number {
  159. let result = val
  160. // 严格步进
  161. if (props.stepStrictly) {
  162. const stepPrecision = getPrecision(props.step)
  163. const factor = Math.pow(10, stepPrecision)
  164. result = (Math.round(result / props.step) * factor * props.step) / factor
  165. }
  166. // 边界限制
  167. if (props.stepStrictly) {
  168. result = applyStrictBounds(result, props.min, props.max)
  169. } else {
  170. result = Math.min(Math.max(result, props.min), props.max)
  171. }
  172. // 精度处理
  173. if (isDef(props.precision)) {
  174. result = toPrecision(result)
  175. }
  176. return result
  177. }
  178. /**
  179. * 严格步进模式下的边界处理
  180. */
  181. function applyStrictBounds(val: number, min: number, max: number): number {
  182. if (val >= min && val <= max) return val
  183. const stepPrecision = getPrecision(props.step)
  184. const factor = Math.pow(10, stepPrecision)
  185. if (val < min) {
  186. const minSteps = Math.ceil((min * factor) / (props.step * factor))
  187. const candidate = toPrecision((minSteps * props.step * factor) / factor)
  188. if (candidate > max) {
  189. const maxSteps = Math.floor((max * factor) / (props.step * factor))
  190. return toPrecision((maxSteps * props.step * factor) / factor)
  191. }
  192. return candidate
  193. }
  194. if (val > max) {
  195. const maxSteps = Math.floor((max * factor) / (props.step * factor))
  196. const candidate = toPrecision((maxSteps * props.step * factor) / factor)
  197. if (candidate < min) {
  198. const minSteps = Math.ceil((min * factor) / (props.step * factor))
  199. return toPrecision((minSteps * props.step * factor) / factor)
  200. }
  201. return candidate
  202. }
  203. return val
  204. }
  205. /**
  206. * 格式化值用于显示(包含修正逻辑)
  207. */
  208. function formatValue(val: string | number): string | number {
  209. if (props.allowNull && (!isDef(val) || val === '')) {
  210. return ''
  211. }
  212. const num = toNumber(val)
  213. const precision = Number(props.precision)
  214. if (!isDef(props.precision)) {
  215. return num
  216. }
  217. return precision === 0 ? Number(num.toFixed(0)) : num.toFixed(precision)
  218. }
  219. /**
  220. * 仅做显示格式化,不包含值修正逻辑
  221. */
  222. function formatDisplay(val: string | number): string | number {
  223. if (props.allowNull && (!isDef(val) || val === '')) {
  224. return ''
  225. }
  226. if (!isDef(val) || val === '') {
  227. return props.min
  228. }
  229. let num = Number(val)
  230. if (isNaN(num)) {
  231. return props.min
  232. }
  233. const precision = Number(props.precision)
  234. if (!isDef(props.precision)) {
  235. return num
  236. }
  237. return precision === 0 ? Number(num.toFixed(0)) : num.toFixed(precision)
  238. }
  239. /**
  240. * 检查是否为中间输入状态
  241. */
  242. function isIntermediate(val: string): boolean {
  243. if (!val) return false
  244. const str = String(val)
  245. return str.endsWith('.') || str.startsWith('.') || str.startsWith('-.') || str === '-' || (Number(props.precision) > 0 && str.indexOf('.') === -1)
  246. }
  247. /**
  248. * 清理输入值
  249. */
  250. function cleanInput(val: string): string {
  251. if (!val) return ''
  252. // 清理非数字、小数点、负号
  253. let cleaned = val.replace(/[^\d.-]/g, '')
  254. // 处理负号,保证负号只出现在最前面
  255. const hasNegative = cleaned.startsWith('-')
  256. cleaned = cleaned.replace(/-/g, '')
  257. if (hasNegative) cleaned = '-' + cleaned
  258. // 处理小数点
  259. const precision = Number(props.precision)
  260. if (precision > 0) {
  261. const parts = cleaned.split('.')
  262. if (parts.length > 2) {
  263. cleaned = parts[0] + '.' + parts.slice(1).join('')
  264. }
  265. } else {
  266. cleaned = cleaned.split('.')[0]
  267. }
  268. // 处理以点开头的情况
  269. if (cleaned.startsWith('.')) return '0' + cleaned
  270. if (cleaned.startsWith('-.')) return '-0' + cleaned.substring(1)
  271. return cleaned
  272. }
  273. /**
  274. * 更新值并触发事件
  275. */
  276. function updateValue(val: string | number) {
  277. // 空值处理
  278. if (props.allowNull && (!isDef(val) || val === '')) {
  279. if (isEqual('', String(props.modelValue))) {
  280. inputValue.value = ''
  281. return
  282. }
  283. const doUpdate = () => {
  284. inputValue.value = ''
  285. emit('update:modelValue', '')
  286. emit('change', { value: '' })
  287. }
  288. callInterceptor(props.beforeChange, { args: [''], done: doUpdate })
  289. return
  290. }
  291. const num = toNumber(val)
  292. const display = formatValue(val)
  293. if (isEqual(String(num), String(props.modelValue))) {
  294. inputValue.value = display
  295. return
  296. }
  297. const doUpdate = () => {
  298. inputValue.value = display
  299. emit('update:modelValue', num)
  300. emit('change', { value: num })
  301. }
  302. callInterceptor(props.beforeChange, { args: [num], done: doUpdate })
  303. }
  304. /**
  305. * 按步进值增减
  306. */
  307. function addStep(val: string | number, step: number) {
  308. const num = Number(val)
  309. if (isNaN(num)) return normalizeValue(props.min)
  310. const precision = Math.max(getPrecision(num), getPrecision(step))
  311. const factor = Math.pow(10, precision)
  312. const result = (num * factor + step * factor) / factor
  313. return normalizeValue(result)
  314. }
  315. /**
  316. * 处理按钮点击
  317. */
  318. function handleClick(type: OperationType) {
  319. const step = type === 'add' ? props.step : -props.step
  320. if ((step < 0 && (minDisabled.value || props.disableMinus)) || (step > 0 && (maxDisabled.value || props.disablePlus))) return
  321. const newVal = addStep(inputValue.value, step)
  322. updateValue(newVal)
  323. }
  324. /**
  325. * 处理输入事件
  326. */
  327. function handleInput(event: any) {
  328. const rawVal = event.detail.value || ''
  329. // 立即更新显示
  330. inputValue.value = rawVal
  331. nextTick(() => {
  332. // 空值处理
  333. if (rawVal === '') {
  334. inputValue.value = ''
  335. if (props.immediateChange && props.allowNull) {
  336. updateValue('')
  337. }
  338. return
  339. }
  340. // 清理输入
  341. const cleaned = cleanInput(rawVal)
  342. // 中间状态处理
  343. if (Number(props.precision) > 0 && isIntermediate(cleaned)) {
  344. inputValue.value = cleaned
  345. return
  346. }
  347. // 正常输入处理
  348. inputValue.value = cleaned
  349. if (props.immediateChange) {
  350. updateValue(cleaned)
  351. }
  352. })
  353. }
  354. /**
  355. * 处理失焦事件
  356. */
  357. function handleBlur(event: any) {
  358. const val = event.detail.value || ''
  359. updateValue(val)
  360. emit('blur', { value: val })
  361. }
  362. /**
  363. * 处理聚焦事件
  364. */
  365. function handleFocus(event: any) {
  366. emit('focus', event.detail)
  367. }
  368. /**
  369. * 长按逻辑
  370. */
  371. function longPressStep(type: OperationType) {
  372. clearLongPressTimer()
  373. longPressTimer = setTimeout(() => {
  374. handleClick(type)
  375. longPressStep(type)
  376. }, 250)
  377. }
  378. function handleTouchStart(type: OperationType) {
  379. if (!props.longPress) return
  380. clearLongPressTimer()
  381. longPressTimer = setTimeout(() => {
  382. handleClick(type)
  383. longPressStep(type)
  384. }, 600)
  385. }
  386. function handleTouchEnd() {
  387. if (!props.longPress) return
  388. clearLongPressTimer()
  389. }
  390. function clearLongPressTimer() {
  391. if (longPressTimer) {
  392. clearTimeout(longPressTimer)
  393. longPressTimer = null
  394. }
  395. }
  396. </script>
  397. <style lang="scss" scoped>
  398. @import './index.scss';
  399. </style>