feat: v1.3 交互增强

新增功能:
- 账单左滑删除(替代长按,更符合移动端习惯)
- 输入震动反馈(Numpad 按键时调用 uni.vibrateShort)
- 保存成功动画(Checkmark 弹出动画)
- 分类图标滚动到选中项(编辑记录时自动定位)

组件更新:
- TransactionItem 支持左滑露出删除按钮
- Numpad 添加震动反馈
- SaveSuccess 新增保存成功动画组件
This commit is contained in:
2026-05-29 16:35:31 +08:00
parent 7f8b301f95
commit 35bfe8293a
5 changed files with 196 additions and 16 deletions

View File

@@ -1,18 +1,33 @@
<template>
<view class="tx-item" @tap="$emit('tap', item)" @longpress="$emit('longpress', item)">
<view class="icon-wrap" :style="{ background: item.category_color + '20' }">
<text class="icon-text" :style="{ color: item.category_color }">{{ item.category_name?.[0] || '?' }}</text>
<view class="tx-item-wrap">
<view
class="tx-item"
:style="{ transform: `translateX(${offsetX}rpx)` }"
@tap="$emit('tap', item)"
@longpress="$emit('longpress', item)"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<view class="icon-wrap" :style="{ background: item.category_color + '20' }">
<text class="icon-text" :style="{ color: item.category_color }">{{ item.category_name?.[0] || '?' }}</text>
</view>
<view class="info">
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
<text class="meta">{{ item.category_name || '未分类' }}{{ hideDate ? '' : ' · ' + formatDate(item.date) }}</text>
</view>
<text class="amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
</view>
<view class="info">
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
<text class="meta">{{ item.category_name || '未分类' }}{{ hideDate ? '' : ' · ' + formatDate(item.date) }}</text>
<view class="delete-btn" :class="{ visible: showDelete }" @tap="$emit('delete', item)">
<SvgIcon name="trash" :size="32" color="#FFFFFF" />
</view>
<text class="amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { formatAmount, formatDate } from '@/utils/format'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
withDefaults(defineProps<{
item: {
@@ -28,23 +43,79 @@ withDefaults(defineProps<{
hideDate?: boolean
}>(), { hideDate: false })
defineEmits(['tap', 'longpress'])
defineEmits(['tap', 'longpress', 'delete'])
const startX = ref(0)
const offsetX = ref(0)
const showDelete = ref(false)
function onTouchStart(e: any) {
startX.value = e.touches[0].clientX
}
function onTouchMove(e: any) {
const diff = e.touches[0].clientX - startX.value
if (diff < 0) {
offsetX.value = Math.max(diff * 2, -160)
} else if (showDelete.value) {
offsetX.value = Math.min(diff * 2 - 160, 0)
}
}
function onTouchEnd() {
if (offsetX.value < -80) {
offsetX.value = -160
showDelete.value = true
} else {
offsetX.value = 0
showDelete.value = false
}
}
</script>
<style lang="scss" scoped>
.tx-item-wrap {
position: relative;
overflow: hidden;
margin-bottom: 8rpx;
}
.tx-item {
display: flex;
align-items: center;
padding: 28rpx 0;
gap: 24rpx;
border-radius: 20rpx;
transition: background 0.15s;
transition: transform 0.2s ease;
background: #FFFFFF;
position: relative;
z-index: 1;
&:active {
background: #FFF0E6;
}
}
.delete-btn {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 160rpx;
background: #FF6B6B;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0 20rpx 20rpx 0;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 0;
&.visible {
opacity: 1;
}
}
.icon-wrap {
width: 80rpx;
height: 80rpx;