Files
xiaocai/client/src/pages/add/index.vue
wangxiaogang 768c72d7b7 feat: 金额编辑器交互优化 — 支持光标定位、插入删除、长按删除
- 新增 AmountEditor 组件,页面只接一个组件即可获得完整金额编辑体验
- Numpad 增强:支持 cursorIndex 光标输入 + 长按连续删除
- 金额编辑纯函数 amount-edit.ts 集中管理插入/删除/校验规则
- 记一笔、预算页、周期账单弹窗统一接入 AmountEditor
- 默认聚焦金额、点击备注/日期/分类隐藏 Numpad、再次点击金额恢复
- 空值输入小数点规范为 0.
2026-06-09 14:09:14 +08:00

455 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text>
<view :style="{ width: capsuleRight + 88 + 'px' }"></view>
</view>
</view>
<view class="type-toggle-wrap">
<view class="type-toggle" :class="{ income: type === 'income' }">
<view class="slider"></view>
<view class="tab" :class="{ active: type === 'expense' }" @tap="switchType('expense')">支出</view>
<view class="tab" :class="{ active: type === 'income' }" @tap="switchType('income')">收入</view>
</view>
</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" @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">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
<view v-if="suggestedCatId === cat.id && selectedCat !== cat.id" class="suggest-badge">
<text class="suggest-badge-text">推荐</text>
</view>
</view>
<text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text>
</view>
</view>
</scroll-view>
<view class="extra-fields">
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
<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" @focus="blurAmountEditor" />
</view>
</view>
<SaveSuccess
v-model:visible="showSuccess"
:text="editId ? '修改成功' : '记账成功'"
:showContinue="!editId"
@continue="onContinue"
@back="onBack"
/>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useCategoryStore } from '@/stores/category'
import { useTransactionStore } from '@/stores/transaction'
import { waitForReady } from '@/utils/app-ready'
import { suggestCategory } from '@/api/stats'
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'
import { statusBarHeight, capsuleRight } from '@/utils/system'
const catStore = useCategoryStore()
const txStore = useTransactionStore()
const type = ref<'expense' | 'income'>('expense')
const amountStr = ref('0')
const selectedCat = ref<number | null>(null)
const note = ref('')
const suggestedCatId = ref<number | null>(null)
const suggestConfidence = ref(0)
let suggestTimer: ReturnType<typeof setTimeout> | null = null
const now = new Date()
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
const selectedDate = ref(localToday)
const saving = ref(false)
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 }
const dateLabel = computed(() => {
const d = new Date(selectedDate.value)
return `${d.getMonth() + 1}${d.getDate()}`
})
const currentCategories = computed(() => catStore.getByType(type.value))
function switchType(newType: 'expense' | 'income') {
// Remember current selection before switching
lastCatByType[type.value] = selectedCat.value
type.value = newType
const cats = catStore.getByType(newType)
// Restore last selection for this type, or fall back to first category
const saved = lastCatByType[newType]
if (saved && cats.find(c => c.id === saved)) {
selectedCat.value = saved
} else {
selectedCat.value = cats.length > 0 ? cats[0].id : null
}
}
watch(currentCategories, (cats) => {
if (cats.length > 0 && !cats.find(c => c.id === selectedCat.value)) {
selectedCat.value = cats[0].id
}
})
// 智能分类推荐:监听金额和备注变化,防抖 600ms 后请求建议
watch([amountStr, note], () => {
if (suggestTimer) clearTimeout(suggestTimer)
suggestTimer = setTimeout(async () => {
const amt = Math.round(parseFloat(amountStr.value || '0') * 100)
if (amt <= 0 || editId.value) {
suggestedCatId.value = null
suggestConfidence.value = 0
return
}
try {
const result = await suggestCategory(amt, type.value, note.value)
if (result.category && result.confidence >= 30) {
suggestedCatId.value = result.category.id
suggestConfidence.value = result.confidence
// 置信度 >= 60 时自动选中
if (result.confidence >= 60 && !editId.value) {
selectedCat.value = result.category.id
}
} else {
suggestedCatId.value = null
suggestConfidence.value = 0
}
} catch {
suggestedCatId.value = null
}
}, 600)
})
onMounted(async () => {
await waitForReady()
// 加载分类列表,失败时重试一次
try {
await catStore.fetchCategories()
} catch {
try {
await catStore.fetchCategories()
} catch {
uni.showToast({ title: '分类加载失败', icon: 'none' })
}
}
const pages = getCurrentPages()
const page = pages[pages.length - 1] as { options?: Record<string, string> }
if (page?.options?.type && ['expense', 'income'].includes(page.options.type)) {
type.value = page.options.type
}
if (page?.options?.editId) {
editId.value = Number(page.options.editId)
// Try to find in store first, otherwise fetch from server
let existing = txStore.transactions.find(t => t.id === editId.value)
if (!existing) {
existing = await txStore.fetchTransactionById(editId.value!)
}
if (existing) {
type.value = existing.type
amountStr.value = (existing.amount / 100).toFixed(2)
selectedCat.value = existing.category_id
lastCatByType[existing.type] = existing.category_id
note.value = existing.note || ''
selectedDate.value = existing.date.slice(0, 10)
// 等待分类列表渲染完成后滚动到选中项
nextTick(() => {
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
})
} else {
uni.showToast({ title: '记录不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
}
}
if (currentCategories.value.length > 0 && !selectedCat.value) {
selectedCat.value = currentCategories.value[0].id
}
initialLoaded.value = true
})
// 返回页面时刷新分类(可能新增了分类)
const initialLoaded = ref(false)
onShow(() => {
if (initialLoaded.value) catStore.fetchCategories()
})
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)
if (isNaN(amount) || amount <= 0) {
uni.showToast({ title: '请输入金额', icon: 'none' })
return
}
if (!selectedCat.value) {
uni.showToast({ title: '请选择分类', icon: 'none' })
return
}
saving.value = true
try {
const data = {
amount,
type: type.value,
category_id: selectedCat.value,
note: note.value,
date: selectedDate.value
}
if (editId.value) {
await txStore.updateTransaction(editId.value, data)
} else {
await txStore.addTransaction(data)
}
showSuccess.value = true
// 编辑模式自动返回,新增模式显示继续按钮
if (editId.value) {
setTimeout(() => uni.navigateBack(), 2000)
}
} catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
/** 继续记一笔 */
function onContinue() {
// 重置表单,保留分类和日期
amountStr.value = ''
note.value = ''
showSuccess.value = false
nextTick(() => amountEditorRef.value?.focus())
}
/** 返回上一页 */
function onBack() {
uni.navigateBack()
}
function goBack() {
// 保存成功后直接返回,不弹确认框
if (showSuccess.value) return
const hasInput = amountStr.value !== '0' || note.value
if (hasInput) {
uni.showModal({
title: '提示',
content: '放弃当前记录?',
success: (res) => {
if (res.confirm) uni.navigateBack()
}
})
} else {
uni.navigateBack()
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins.scss';
.page {
min-height: 100vh;
background: $bg;
display: flex;
flex-direction: column;
// 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 {
@include sticky-header;
}
.status-bar {
@include status-bar;
}
.header {
@include nav-bar;
justify-content: space-between;
}
.nav-back {
@include nav-back;
border-radius: 50%;
&:active { background: $border; }
}
.title { font-size: $font-xl; font-weight: 500; color: $text; }
.type-toggle-wrap { display: flex; justify-content: center; margin: $space-sm 0; }
.type-toggle {
display: inline-flex;
background: $surface-warm;
border-radius: $radius-lg;
padding: $space-xs;
position: relative;
}
.tab {
width: 160rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-lg;
color: $text-sec;
border-radius: $radius-lg;
position: relative;
z-index: 1;
transition: color $transition-normal;
&.active { color: $primary; font-weight: 600; }
}
.slider {
position: absolute;
top: $space-xs;
left: $space-xs;
width: 160rpx;
height: 72rpx;
background: $surface;
border-radius: $radius-lg;
box-shadow: $shadow-sm;
transition: transform $transition-normal;
}
.income .slider { transform: translateX(160rpx); }
.category-scroll {
max-height: 320rpx;
}
.category-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: $space-sm;
padding: $space-xs 0;
}
.cat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
padding: 12rpx 0;
position: relative;
&:active { transform: scale(0.95); }
&.suggested {
.cat-icon-wrap {
border: 3rpx dashed $primary;
border-radius: $radius-lg;
padding: 6rpx;
margin: -6rpx;
}
}
}
.cat-icon-wrap {
position: relative;
transition: all $transition-normal;
}
.suggest-badge {
position: absolute;
top: -8rpx;
right: -12rpx;
padding: 2rpx 10rpx;
background: $primary;
border-radius: $radius-xs;
}
.suggest-badge-text {
font-size: 18rpx;
color: $surface;
font-weight: 600;
}
.cat-name { font-size: $font-sm; color: $text-sec; }
.cat-name.active {
color: $primary;
font-weight: 600;
}
.extra-fields {
padding: $space-sm $space-xl;
display: flex;
flex-direction: column;
gap: 12rpx;
}
.extra-row {
height: 88rpx;
background: $surface;
border-radius: $radius-lg;
border: 2rpx solid $border;
padding: 0 $space-md;
display: flex;
align-items: center;
gap: 12rpx;
}
.extra-text { font-size: $font-lg; color: $text; flex: 1; }
.extra-input { font-size: $font-lg; color: $text; flex: 1; }
.placeholder { color: $text-muted; }
</style>