feat: 金额编辑器交互优化 — 支持光标定位、插入删除、长按删除
- 新增 AmountEditor 组件,页面只接一个组件即可获得完整金额编辑体验 - Numpad 增强:支持 cursorIndex 光标输入 + 长按连续删除 - 金额编辑纯函数 amount-edit.ts 集中管理插入/删除/校验规则 - 记一笔、预算页、周期账单弹窗统一接入 AmountEditor - 默认聚焦金额、点击备注/日期/分类隐藏 Numpad、再次点击金额恢复 - 空值输入小数点规范为 0.
This commit is contained in:
@@ -19,13 +19,16 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="amount-display">
|
||||
<text class="currency">¥</text>
|
||||
<text class="digits">{{ displayAmount }}</text>
|
||||
<text class="cursor">|</text>
|
||||
</view>
|
||||
<AmountEditor
|
||||
ref="amountEditorRef"
|
||||
v-model="amountStr"
|
||||
:fixed="true"
|
||||
size="large"
|
||||
:auto-focus="true"
|
||||
@confirm="save"
|
||||
/>
|
||||
|
||||
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat">
|
||||
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat" @tap="blurAmountEditor">
|
||||
<view class="category-grid">
|
||||
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id }" @tap="selectedCat = cat.id">
|
||||
<view class="cat-icon-wrap">
|
||||
@@ -41,18 +44,18 @@
|
||||
|
||||
<view class="extra-fields">
|
||||
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
|
||||
<view class="extra-row">
|
||||
<view class="extra-row" @tap="blurAmountEditor">
|
||||
<Icon name="calendar" :size="28" color="#8B7E7E" />
|
||||
<text class="extra-text">{{ dateLabel }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="extra-row">
|
||||
<Icon name="edit" :size="28" color="#8B7E7E" />
|
||||
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" />
|
||||
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="200" @focus="blurAmountEditor" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<Numpad v-model="amountStr" @confirm="save" />
|
||||
|
||||
|
||||
<SaveSuccess
|
||||
v-model:visible="showSuccess"
|
||||
@@ -71,7 +74,7 @@ import { useCategoryStore } from '@/stores/category'
|
||||
import { useTransactionStore } from '@/stores/transaction'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { suggestCategory } from '@/api/stats'
|
||||
import Numpad from '@/components/Numpad/Numpad.vue'
|
||||
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
|
||||
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
|
||||
@@ -95,6 +98,7 @@ const editId = ref<number | null>(null)
|
||||
const showSuccess = ref(false)
|
||||
const scrollToCat = ref('')
|
||||
const todayStr = localToday
|
||||
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
|
||||
// Remember last selected category per type
|
||||
const lastCatByType: Record<string, number | null> = { expense: null, income: null }
|
||||
|
||||
@@ -103,10 +107,6 @@ const dateLabel = computed(() => {
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
})
|
||||
|
||||
const displayAmount = computed(() => {
|
||||
if (!amountStr.value) return ''
|
||||
return amountStr.value
|
||||
})
|
||||
|
||||
const currentCategories = computed(() => catStore.getByType(type.value))
|
||||
|
||||
@@ -217,6 +217,10 @@ function onDateChange(e: any) {
|
||||
selectedDate.value = e.detail.value
|
||||
}
|
||||
|
||||
function blurAmountEditor() {
|
||||
amountEditorRef.value?.blur()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
|
||||
@@ -261,6 +265,7 @@ function onContinue() {
|
||||
amountStr.value = ''
|
||||
note.value = ''
|
||||
showSuccess.value = false
|
||||
nextTick(() => amountEditorRef.value?.focus())
|
||||
}
|
||||
|
||||
/** 返回上一页 */
|
||||
@@ -290,10 +295,14 @@ function goBack() {
|
||||
@import '@/styles/mixins.scss';
|
||||
|
||||
.page {
|
||||
@include page-base;
|
||||
min-height: 100vh;
|
||||
background: $bg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 0;
|
||||
// AmountEditor fixed Numpad 占位: 4行×96rpx + 3间隙×16rpx + padding×2×16rpx = 464rpx
|
||||
$numpad-h: 464rpx;
|
||||
padding-bottom: calc(#{$numpad-h} + constant(safe-area-inset-bottom));
|
||||
padding-bottom: calc(#{$numpad-h} + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.header-fixed {
|
||||
@@ -358,38 +367,6 @@ function goBack() {
|
||||
|
||||
.income .slider { transform: translateX(160rpx); }
|
||||
|
||||
.amount-display {
|
||||
text-align: center;
|
||||
padding: $space-lg $space-xl $space-md;
|
||||
}
|
||||
|
||||
.currency {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: $space-2xl;
|
||||
font-weight: 500;
|
||||
color: $text-muted;
|
||||
margin-right: $space-xs;
|
||||
}
|
||||
|
||||
.digits {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 88rpx;
|
||||
font-weight: 700;
|
||||
color: $text;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
font-family: 'Fredoka', monospace;
|
||||
font-size: 88rpx;
|
||||
font-weight: 300;
|
||||
color: $primary;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.category-scroll {
|
||||
max-height: 320rpx;
|
||||
@@ -472,10 +449,6 @@ function goBack() {
|
||||
|
||||
.placeholder { color: $text-muted; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cursor {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
@@ -60,15 +60,15 @@
|
||||
|
||||
<!-- 自定义金额 -->
|
||||
<view class="section-title">自定义金额</view>
|
||||
<view class="custom-input">
|
||||
<text class="input-prefix">¥</text>
|
||||
<text class="input-value" :class="{ placeholder: !amountStr }">{{ amountStr }}</text>
|
||||
<text class="input-cursor">|</text>
|
||||
<text class="input-unit">元</text>
|
||||
</view>
|
||||
|
||||
<!-- Numpad -->
|
||||
<Numpad v-model="amountStr" @confirm="onConfirm" />
|
||||
<AmountEditor
|
||||
ref="amountEditorRef"
|
||||
v-model="amountStr"
|
||||
:fixed="true"
|
||||
size="large"
|
||||
unit="元"
|
||||
:auto-focus="true"
|
||||
@confirm="onConfirm"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -84,7 +84,7 @@ import { formatAmount, getCurrentMonth } from '@/utils/format'
|
||||
import type { Budget } from '@/api/budget'
|
||||
import { statusBarHeight } from '@/utils/system'
|
||||
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
|
||||
import Numpad from '@/components/Numpad/Numpad.vue'
|
||||
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
|
||||
const budgetStore = useBudgetStore()
|
||||
@@ -97,6 +97,7 @@ const monthExpense = ref(0)
|
||||
const loading = ref(true)
|
||||
const initialLoaded = ref(false)
|
||||
const amountStr = ref('')
|
||||
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
|
||||
|
||||
const presets = [1000, 2000, 3000, 5000, 10000]
|
||||
|
||||
@@ -162,6 +163,7 @@ onPullDownRefresh(async () => {
|
||||
|
||||
function selectPreset(val: number) {
|
||||
amountStr.value = String(val)
|
||||
amountEditorRef.value?.focus()
|
||||
}
|
||||
|
||||
async function onConfirm() {
|
||||
@@ -367,60 +369,11 @@ function goBack() {
|
||||
color: $text;
|
||||
}
|
||||
|
||||
.custom-input {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: $space-xs;
|
||||
margin-bottom: $space-lg;
|
||||
padding: $space-md $space-lg;
|
||||
background: $surface;
|
||||
border-radius: $radius-xl;
|
||||
border: 2rpx solid $border;
|
||||
}
|
||||
|
||||
.input-prefix {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: $font-3xl;
|
||||
font-weight: 600;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.input-value {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: $font-4xl;
|
||||
font-weight: 600;
|
||||
color: $text;
|
||||
flex: 1;
|
||||
|
||||
&.placeholder {
|
||||
color: #D0C4C4;
|
||||
}
|
||||
}
|
||||
|
||||
.input-unit {
|
||||
font-size: $font-lg;
|
||||
color: $text-sec;
|
||||
}
|
||||
|
||||
.input-cursor {
|
||||
font-family: 'Fredoka', monospace;
|
||||
font-size: $font-4xl;
|
||||
font-weight: 300;
|
||||
color: $primary;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.preview-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
.input-cursor {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -65,11 +65,11 @@
|
||||
</scroll-view>
|
||||
|
||||
<!-- 添加/编辑弹窗 -->
|
||||
<view class="modal-mask" v-if="showModal" @tap="showModal = false">
|
||||
<view class="modal-mask" v-if="showModal" @tap="closeModal">
|
||||
<view class="form-modal" @tap.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{ editingId ? '编辑周期账单' : '添加周期账单' }}</text>
|
||||
<view class="modal-close" @tap="showModal = false">
|
||||
<view class="modal-close" @tap="closeModal">
|
||||
<text class="modal-close-text">×</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -84,13 +84,17 @@
|
||||
</view>
|
||||
|
||||
<!-- 金额 -->
|
||||
<view class="amount-row">
|
||||
<text class="currency">¥</text>
|
||||
<text class="digits" :class="{ placeholder: !amountStr }">{{ amountStr || '0' }}</text>
|
||||
</view>
|
||||
<AmountEditor
|
||||
ref="amountEditorRef"
|
||||
v-model="amountStr"
|
||||
:fixed="false"
|
||||
size="medium"
|
||||
:auto-focus="showModal"
|
||||
@confirm="handleSubmit"
|
||||
/>
|
||||
|
||||
<!-- 分类 -->
|
||||
<scroll-view class="category-scroll" scroll-x>
|
||||
<scroll-view class="category-scroll" scroll-x @tap="blurAmountEditor">
|
||||
<view class="category-row">
|
||||
<view v-for="cat in currentCategories" :key="cat.id" class="cat-item" @tap="form.category_id = cat.id">
|
||||
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" :selected="form.category_id === cat.id" />
|
||||
@@ -102,14 +106,14 @@
|
||||
<!-- 备注 -->
|
||||
<view class="form-item">
|
||||
<Icon name="edit" :size="28" color="#8B7E7E" />
|
||||
<input class="form-input" v-model="form.note" placeholder="备注(可选)" maxlength="50" />
|
||||
<input class="form-input" v-model="form.note" placeholder="备注(可选)" maxlength="50" @focus="blurAmountEditor" />
|
||||
</view>
|
||||
|
||||
<!-- 周期 -->
|
||||
<view class="form-item">
|
||||
<Icon name="calendar" :size="28" color="#8B7E7E" />
|
||||
<view class="freq-options">
|
||||
<view v-for="f in frequencies" :key="f.value" class="freq-tab" :class="{ active: form.frequency === f.value }" @tap="form.frequency = f.value">
|
||||
<view v-for="f in frequencies" :key="f.value" class="freq-tab" :class="{ active: form.frequency === f.value }" @tap="selectFrequency(f.value)">
|
||||
<text class="freq-text">{{ f.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -137,8 +141,7 @@
|
||||
|
||||
</scroll-view>
|
||||
|
||||
<!-- 数字键盘:在滚动区外,始终可见,避免 scroll-view 吞事件 -->
|
||||
<Numpad v-model="amountStr" :fixed="false" @confirm="handleSubmit" />
|
||||
|
||||
|
||||
<view class="modal-footer" @tap="handleSubmit">
|
||||
<text class="submit-text">{{ editingId ? '保存修改' : '添加' }}</text>
|
||||
@@ -150,7 +153,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { waitForReady } from '@/utils/app-ready'
|
||||
import { statusBarHeight, capsuleRight } from '@/utils/system'
|
||||
import { formatAmount } from '@/utils/format'
|
||||
@@ -158,9 +161,8 @@ import { useCategoryStore } from '@/stores/category'
|
||||
import { getRecurringTemplates, createRecurringTemplate, updateRecurringTemplate, deleteRecurringTemplate, syncRecurring } from '@/api/recurring'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||||
import Numpad from '@/components/Numpad/Numpad.vue'
|
||||
import AmountEditor from '@/components/AmountEditor/AmountEditor.vue'
|
||||
import type { RecurringTemplate } from '@/api/recurring'
|
||||
import type { Category } from '@/stores/category'
|
||||
|
||||
const catStore = useCategoryStore()
|
||||
|
||||
@@ -171,6 +173,7 @@ const editingId = ref<number | null>(null)
|
||||
const amountStr = ref('')
|
||||
const saving = ref(false)
|
||||
const syncCount = ref(0)
|
||||
const amountEditorRef = ref<InstanceType<typeof AmountEditor> | null>(null)
|
||||
|
||||
const freqMap: Record<string, string> = {
|
||||
weekly: '每周', monthly: '每月', yearly: '每年'
|
||||
@@ -230,6 +233,7 @@ function openAddModal() {
|
||||
if (cats.length > 0) form.value.category_id = cats[0].id
|
||||
amountStr.value = ''
|
||||
showModal.value = true
|
||||
nextTick(() => amountEditorRef.value?.focus())
|
||||
}
|
||||
|
||||
function openEditModal(item: RecurringTemplate) {
|
||||
@@ -244,6 +248,7 @@ function openEditModal(item: RecurringTemplate) {
|
||||
}
|
||||
amountStr.value = (item.amount / 100).toString()
|
||||
showModal.value = true
|
||||
nextTick(() => amountEditorRef.value?.focus())
|
||||
}
|
||||
|
||||
async function toggleActive(item: RecurringTemplate) {
|
||||
@@ -284,7 +289,7 @@ async function handleSubmit() {
|
||||
} else {
|
||||
await createRecurringTemplate(data)
|
||||
}
|
||||
showModal.value = false
|
||||
closeModal()
|
||||
await loadList()
|
||||
uni.showToast({ title: editingId.value ? '已更新' : '已添加', icon: 'success' })
|
||||
} catch {
|
||||
@@ -312,6 +317,20 @@ async function handleDelete(item: RecurringTemplate) {
|
||||
})
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
amountEditorRef.value?.blur()
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
function blurAmountEditor() {
|
||||
amountEditorRef.value?.blur()
|
||||
}
|
||||
|
||||
function selectFrequency(value: 'weekly' | 'monthly' | 'yearly') {
|
||||
blurAmountEditor()
|
||||
form.value.frequency = value
|
||||
}
|
||||
|
||||
function goBack() { uni.navigateBack() }
|
||||
</script>
|
||||
|
||||
@@ -463,18 +482,7 @@ function goBack() { uni.navigateBack() }
|
||||
}
|
||||
.income .slider { transform: translateX(140rpx); }
|
||||
|
||||
/* 金额 */
|
||||
.amount-row {
|
||||
text-align: center; padding: $space-sm 0 $space-md;
|
||||
}
|
||||
.currency {
|
||||
font-family: 'Fredoka', sans-serif; font-size: $space-2xl; font-weight: 500;
|
||||
color: $text-muted; margin-right: $space-xs;
|
||||
}
|
||||
.digits {
|
||||
font-family: 'Fredoka', sans-serif; font-size: 72rpx; font-weight: 600; color: $text;
|
||||
&.placeholder { color: #D0C4C4; }
|
||||
}
|
||||
|
||||
|
||||
/* 分类 */
|
||||
.category-scroll { margin-bottom: $space-md; }
|
||||
|
||||
Reference in New Issue
Block a user