feat: 金额编辑器交互优化 — 支持光标定位、插入删除、长按删除
- 新增 AmountEditor 组件,页面只接一个组件即可获得完整金额编辑体验 - Numpad 增强:支持 cursorIndex 光标输入 + 长按连续删除 - 金额编辑纯函数 amount-edit.ts 集中管理插入/删除/校验规则 - 记一笔、预算页、周期账单弹窗统一接入 AmountEditor - 默认聚焦金额、点击备注/日期/分类隐藏 Numpad、再次点击金额恢复 - 空值输入小数点规范为 0.
This commit is contained in:
263
client/src/components/AmountEditor/AmountEditor.vue
Normal file
263
client/src/components/AmountEditor/AmountEditor.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<view class="amount-editor" :class="[`amount-editor--${size}`, { 'amount-editor--focused': innerFocused }]">
|
||||
<view class="amount-display" @tap="focusAtEnd">
|
||||
<text class="currency">¥</text>
|
||||
<view class="amount-value">
|
||||
<template v-if="displayChars.length > 0">
|
||||
<template v-for="(char, index) in displayChars" :key="`${char}-${index}`">
|
||||
<text v-if="innerFocused && cursorIndex === index" class="cursor"></text>
|
||||
<text class="amount-char" @tap.stop="focusAt(index + 1)">{{ char }}</text>
|
||||
</template>
|
||||
<text v-if="innerFocused && cursorIndex === displayChars.length" class="cursor"></text>
|
||||
</template>
|
||||
<template v-else>
|
||||
<text class="placeholder" @tap.stop="focusAtEnd">{{ placeholder }}</text>
|
||||
<text v-if="innerFocused" class="cursor"></text>
|
||||
</template>
|
||||
</view>
|
||||
<text v-if="unit" class="unit">{{ unit }}</text>
|
||||
</view>
|
||||
|
||||
<Numpad
|
||||
v-model="innerValue"
|
||||
v-model:cursorIndex="cursorIndex"
|
||||
:max="max"
|
||||
:fixed="fixed"
|
||||
:visible="innerFocused && visible"
|
||||
@confirm="emit('confirm')"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import Numpad from '@/components/Numpad/Numpad.vue'
|
||||
import { clampCursorIndex } from '@/components/AmountEditor/amount-edit'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string
|
||||
max?: number
|
||||
fixed?: boolean
|
||||
visible?: boolean
|
||||
autoFocus?: boolean
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
placeholder?: string
|
||||
unit?: string
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
max: 9999999.99,
|
||||
fixed: true,
|
||||
visible: true,
|
||||
autoFocus: true,
|
||||
size: 'large',
|
||||
placeholder: '0',
|
||||
unit: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'focus'): void
|
||||
(e: 'blur'): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
const innerFocused = ref(false)
|
||||
const cursorIndex = ref(0)
|
||||
|
||||
const innerValue = computed({
|
||||
get: () => props.modelValue || '',
|
||||
set: (value: string) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const displayChars = computed(() => innerValue.value.split(''))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
cursorIndex.value = clampCursorIndex(value || '', cursorIndex.value)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible && props.autoFocus) {
|
||||
nextTick(focus)
|
||||
} else if (!visible) {
|
||||
blur()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function focus() {
|
||||
cursorIndex.value = innerValue.value.length
|
||||
if (!innerFocused.value) {
|
||||
innerFocused.value = true
|
||||
emit('focus')
|
||||
}
|
||||
}
|
||||
|
||||
function blur() {
|
||||
if (innerFocused.value) {
|
||||
innerFocused.value = false
|
||||
emit('blur')
|
||||
}
|
||||
}
|
||||
|
||||
function focusAt(index: number) {
|
||||
if (!props.visible) return
|
||||
cursorIndex.value = clampCursorIndex(innerValue.value, index)
|
||||
if (!innerFocused.value) {
|
||||
innerFocused.value = true
|
||||
emit('focus')
|
||||
}
|
||||
}
|
||||
|
||||
function focusAtEnd() {
|
||||
focusAt(innerValue.value.length)
|
||||
}
|
||||
|
||||
defineExpose({ focus, blur })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins.scss';
|
||||
|
||||
.amount-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.amount-display {
|
||||
min-height: 156rpx;
|
||||
padding: $space-lg $space-xl;
|
||||
background: $surface;
|
||||
border: 2rpx solid $border;
|
||||
border-radius: $radius-2xl;
|
||||
box-shadow: $shadow-clay;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $space-xs;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
transition: border-color $transition-normal, box-shadow $transition-normal, transform $transition-fast;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
.amount-editor--focused .amount-display {
|
||||
border-color: $primary-light;
|
||||
box-shadow: $shadow-clay, 0 8rpx 24rpx rgba(255, 140, 105, 0.16);
|
||||
}
|
||||
|
||||
.currency,
|
||||
.unit {
|
||||
flex-shrink: 0;
|
||||
color: $text-sec;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.currency {
|
||||
align-self: flex-start;
|
||||
margin-top: 12rpx;
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 40rpx;
|
||||
}
|
||||
|
||||
.unit {
|
||||
align-self: flex-end;
|
||||
margin-bottom: 14rpx;
|
||||
font-size: $font-md;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-weight: 700;
|
||||
color: $text;
|
||||
line-height: 1.1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.amount-char,
|
||||
.placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.1em;
|
||||
}
|
||||
|
||||
.amount-char {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
width: 4rpx;
|
||||
height: 1em;
|
||||
margin: 0 2rpx;
|
||||
border-radius: 999rpx;
|
||||
background: $primary;
|
||||
animation: cursor-blink 1s steps(2, start) infinite;
|
||||
}
|
||||
|
||||
.amount-editor--small {
|
||||
.amount-display {
|
||||
min-height: 112rpx;
|
||||
padding: $space-md $space-lg;
|
||||
border-radius: $radius-xl;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 52rpx;
|
||||
}
|
||||
|
||||
.currency {
|
||||
margin-top: 6rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-editor--medium {
|
||||
.amount-display {
|
||||
min-height: 132rpx;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 68rpx;
|
||||
}
|
||||
|
||||
.currency {
|
||||
font-size: 34rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.amount-editor--large {
|
||||
.amount-value {
|
||||
font-size: 88rpx;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cursor-blink {
|
||||
0%, 45% { opacity: 1; }
|
||||
46%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.amount-display {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
108
client/src/components/AmountEditor/amount-edit.ts
Normal file
108
client/src/components/AmountEditor/amount-edit.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
export type AmountKey = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '00' | '.'
|
||||
export type AmountActionKey = AmountKey | 'delete'
|
||||
|
||||
export interface AmountEditOptions {
|
||||
max?: number
|
||||
maxLength?: number
|
||||
}
|
||||
|
||||
export interface AmountEditResult {
|
||||
value: string
|
||||
cursorIndex: number
|
||||
accepted: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_MAX = 9999999.99
|
||||
// 对应最大金额字符串 '9999999.99' 的长度。
|
||||
const DEFAULT_MAX_LENGTH = 10
|
||||
const MAX_DECIMAL_PLACES = 2
|
||||
|
||||
export function clampCursorIndex(value: string, cursorIndex: number): number {
|
||||
if (cursorIndex < 0) return 0
|
||||
if (cursorIndex > value.length) return value.length
|
||||
return cursorIndex
|
||||
}
|
||||
|
||||
export function insertAmountKey(
|
||||
value: string,
|
||||
cursorIndex: number,
|
||||
key: AmountKey,
|
||||
options: AmountEditOptions = {}
|
||||
): AmountEditResult {
|
||||
const safeCursor = clampCursorIndex(value, cursorIndex)
|
||||
|
||||
// 替换模式:当当前值只有一个 '0' 且光标在末尾时,非 '.' 输入应替换 '0'
|
||||
if (value === '0' && safeCursor === value.length && key !== '.') {
|
||||
// key 可能是 '00',需要规范化
|
||||
const insert = normalizeInsert('', 0, key)
|
||||
if (insert === '') return { value, cursorIndex: safeCursor, accepted: true }
|
||||
const next = insert
|
||||
if (!isValidAmountInput(next, options)) {
|
||||
return { value, cursorIndex: safeCursor, accepted: false }
|
||||
}
|
||||
return {
|
||||
value: next,
|
||||
cursorIndex: insert.length,
|
||||
accepted: true,
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedInsert = normalizeInsert(value, safeCursor, key)
|
||||
const next = value.slice(0, safeCursor) + normalizedInsert + value.slice(safeCursor)
|
||||
|
||||
if (!isValidAmountInput(next, options)) {
|
||||
return { value, cursorIndex: safeCursor, accepted: false }
|
||||
}
|
||||
|
||||
return {
|
||||
value: next,
|
||||
cursorIndex: safeCursor + normalizedInsert.length,
|
||||
accepted: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteAmountChar(value: string, cursorIndex: number): AmountEditResult {
|
||||
const safeCursor = clampCursorIndex(value, cursorIndex)
|
||||
if (safeCursor === 0) {
|
||||
return { value, cursorIndex: safeCursor, accepted: false }
|
||||
}
|
||||
|
||||
return {
|
||||
value: value.slice(0, safeCursor - 1) + value.slice(safeCursor),
|
||||
cursorIndex: safeCursor - 1,
|
||||
accepted: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function applyAmountKey(
|
||||
value: string,
|
||||
cursorIndex: number,
|
||||
key: AmountActionKey,
|
||||
options: AmountEditOptions = {}
|
||||
): AmountEditResult {
|
||||
if (key === 'delete') return deleteAmountChar(value, cursorIndex)
|
||||
return insertAmountKey(value, cursorIndex, key, options)
|
||||
}
|
||||
|
||||
export function isValidAmountInput(value: string, options: AmountEditOptions = {}): boolean {
|
||||
if (value === '') return true
|
||||
if (value.length > (options.maxLength ?? DEFAULT_MAX_LENGTH)) return false
|
||||
if (!/^\d*(\.\d*)?$/.test(value)) return false
|
||||
|
||||
const dotIndex = value.indexOf('.')
|
||||
if (dotIndex >= 0 && value.length - dotIndex - 1 > MAX_DECIMAL_PLACES) return false
|
||||
|
||||
const num = parseFloat(value)
|
||||
if (!Number.isNaN(num) && num > (options.max ?? DEFAULT_MAX)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function normalizeInsert(value: string, cursorIndex: number, key: AmountKey): string {
|
||||
if ((key === '0' || key === '00') && value === '') return '0'
|
||||
if ((key === '0' || key === '00') && value === '0' && cursorIndex === value.length) return ''
|
||||
if (value === '0' && cursorIndex === value.length && key !== '.') return key
|
||||
// 空值时输入小数点,补前导零,保证合法格式
|
||||
if (value === '' && key === '.') return '0.'
|
||||
return key
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<view class="numpad" :class="{ 'numpad-fixed': fixed }">
|
||||
<view class="numpad" :class="{ 'numpad-fixed': fixed, 'numpad-hidden': !visible }">
|
||||
<view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)">
|
||||
<text class="key-text">{{ key }}</text>
|
||||
</view>
|
||||
<view class="key fn" @tap="onDelete">
|
||||
<view
|
||||
class="key fn"
|
||||
@tap="onDeleteTap"
|
||||
@touchstart="onDeleteTouchStart"
|
||||
@touchend="onDeleteTouchEnd"
|
||||
@touchcancel="onDeleteTouchEnd"
|
||||
>
|
||||
<view class="backspace-icon">
|
||||
<view class="backspace-arrow"></view>
|
||||
<view class="backspace-line"></view>
|
||||
@@ -16,76 +22,118 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount } from 'vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import {
|
||||
applyAmountKey,
|
||||
clampCursorIndex,
|
||||
type AmountKey
|
||||
} from '@/components/AmountEditor/amount-edit'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 金额字符串(v-model) */
|
||||
modelValue?: string
|
||||
/** 光标位置 */
|
||||
cursorIndex?: number
|
||||
/** 最大金额(元) */
|
||||
max?: number
|
||||
/** 是否固定在底部(弹窗中使用时设为 false) */
|
||||
fixed?: boolean
|
||||
/** 是否显示(fixed 模式下控制滑入/滑出) */
|
||||
visible?: boolean
|
||||
}>(), {
|
||||
modelValue: '',
|
||||
max: 9999999.99,
|
||||
fixed: true
|
||||
fixed: true,
|
||||
visible: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'update:cursorIndex', index: number): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
|
||||
const keys: AmountKey[] = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
|
||||
|
||||
const amountStr = computed(() => props.modelValue)
|
||||
|
||||
const effectiveCursorIndex = computed(() =>
|
||||
clampCursorIndex(amountStr.value, props.cursorIndex ?? amountStr.value.length)
|
||||
)
|
||||
|
||||
let deleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let deleteLongPressed = false
|
||||
|
||||
function vibrate() {
|
||||
try { uni.vibrateShort({ type: 'light' }) } catch {}
|
||||
}
|
||||
|
||||
function emitValue(val: string) {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
function onKeyTap(key: string) {
|
||||
function applyKey(key: AmountKey | 'delete') {
|
||||
vibrate()
|
||||
const current = amountStr.value
|
||||
|
||||
if (key === '.') {
|
||||
if (current.includes('.')) return
|
||||
if (!current || current === '0') { emitValue('0.'); return }
|
||||
}
|
||||
|
||||
let next: string
|
||||
if (!current || current === '0') {
|
||||
next = (key === '0' || key === '00') ? '0' : key
|
||||
} else {
|
||||
next = current + key
|
||||
}
|
||||
|
||||
if (next.includes('.')) {
|
||||
const decimals = next.split('.')[1]
|
||||
if (decimals && decimals.length > 2) return
|
||||
}
|
||||
if (next.length > 10) return
|
||||
const num = parseFloat(next)
|
||||
if (!isNaN(num) && num > props.max) return
|
||||
|
||||
emitValue(next)
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
vibrate()
|
||||
const current = amountStr.value
|
||||
if (current.length > 1) {
|
||||
emitValue(current.slice(0, -1))
|
||||
} else {
|
||||
emitValue('')
|
||||
const result = applyAmountKey(
|
||||
amountStr.value,
|
||||
effectiveCursorIndex.value,
|
||||
key,
|
||||
{ max: props.max }
|
||||
)
|
||||
if (result.accepted) {
|
||||
emit('update:modelValue', result.value)
|
||||
emit('update:cursorIndex', result.cursorIndex)
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyTap(key: AmountKey) {
|
||||
applyKey(key)
|
||||
}
|
||||
|
||||
function onDeleteTap() {
|
||||
if (deleteLongPressed) {
|
||||
deleteLongPressed = false
|
||||
return
|
||||
}
|
||||
applyKey('delete')
|
||||
}
|
||||
|
||||
function doDeleteRepeat() {
|
||||
deleteLongPressed = true
|
||||
const result = applyAmountKey(
|
||||
amountStr.value,
|
||||
effectiveCursorIndex.value,
|
||||
'delete',
|
||||
{ max: props.max }
|
||||
)
|
||||
if (result.accepted) {
|
||||
vibrate()
|
||||
emit('update:modelValue', result.value)
|
||||
emit('update:cursorIndex', result.cursorIndex)
|
||||
deleteTimer = setTimeout(doDeleteRepeat, 120)
|
||||
} else {
|
||||
deleteLongPressed = false
|
||||
deleteTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDeleteTouchStart() {
|
||||
if (deleteTimer !== null) clearTimeout(deleteTimer)
|
||||
deleteLongPressed = false
|
||||
deleteTimer = setTimeout(doDeleteRepeat, 400)
|
||||
}
|
||||
|
||||
function onDeleteTouchEnd() {
|
||||
if (deleteTimer !== null) {
|
||||
clearTimeout(deleteTimer)
|
||||
deleteTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (deleteTimer !== null) {
|
||||
clearTimeout(deleteTimer)
|
||||
deleteTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
function onConfirm() {
|
||||
vibrate()
|
||||
emit('confirm')
|
||||
@@ -101,24 +149,53 @@ function onConfirm() {
|
||||
gap: $space-sm;
|
||||
padding: $space-sm $space-xl;
|
||||
|
||||
// 非 fixed 模式的显示/隐藏
|
||||
&:not(.numpad-fixed) {
|
||||
transition: max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s ease;
|
||||
overflow: hidden;
|
||||
max-height: 600rpx;
|
||||
|
||||
&.numpad-hidden {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// fixed 模式的滑入/滑出
|
||||
&.numpad-fixed {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: $surface;
|
||||
background: $bg;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
z-index: 100;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.numpad-hidden {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.key {
|
||||
height: 96rpx;
|
||||
border-radius: $radius-xl;
|
||||
background: $bg;
|
||||
background: $surface;
|
||||
border: 2rpx solid $border;
|
||||
box-shadow: $shadow-clay;
|
||||
@include flex-center;
|
||||
transition: all $transition-fast;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
|
||||
&:active { background: $border; }
|
||||
&:active {
|
||||
background: $surface-warm;
|
||||
box-shadow: $shadow-clay-pressed;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.key-text {
|
||||
@@ -130,13 +207,25 @@ function onConfirm() {
|
||||
|
||||
.key.fn {
|
||||
background: $primary-light;
|
||||
&:active { background: #FFD0C0; }
|
||||
border-color: #FFD0C0;
|
||||
|
||||
&:active {
|
||||
background: #FFD0C0;
|
||||
box-shadow: $shadow-clay-pressed;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.key.eq {
|
||||
background: linear-gradient(135deg, $primary, $primary-dark);
|
||||
box-shadow: 0 4rpx 16rpx rgba(255, 140, 105, 0.3);
|
||||
&:active { background: $primary-dark; }
|
||||
border: none;
|
||||
box-shadow: $shadow-md, 0 4rpx 12rpx rgba(255, 140, 105, 0.3);
|
||||
|
||||
&:active {
|
||||
background: $primary-dark;
|
||||
box-shadow: $shadow-clay-pressed;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
// 自绘 backspace 图标
|
||||
|
||||
Reference in New Issue
Block a user