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

@@ -3,10 +3,10 @@
<view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)"> <view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)">
<text class="key-text">{{ key }}</text> <text class="key-text">{{ key }}</text>
</view> </view>
<view class="key fn" @tap="emit('delete')"> <view class="key fn" @tap="onDelete">
<SvgIcon name="trash" :size="36" color="#FF8C69" /> <SvgIcon name="trash" :size="36" color="#FF8C69" />
</view> </view>
<view class="key eq" @tap="emit('confirm')"> <view class="key eq" @tap="onConfirm">
<SvgIcon name="check" :size="36" color="#FFFFFF" /> <SvgIcon name="check" :size="36" color="#FFFFFF" />
</view> </view>
</view> </view>
@@ -19,9 +19,26 @@ const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
const emit = defineEmits(['input', 'delete', 'confirm']) const emit = defineEmits(['input', 'delete', 'confirm'])
function vibrate() {
try {
uni.vibrateShort({ type: 'light' })
} catch {}
}
function onKeyTap(key: string) { function onKeyTap(key: string) {
vibrate()
emit('input', key) emit('input', key)
} }
function onDelete() {
vibrate()
emit('delete')
}
function onConfirm() {
vibrate()
emit('confirm')
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -0,0 +1,85 @@
<template>
<view class="save-success" v-if="visible" @tap="hide">
<view class="success-icon" :class="{ show: animating }">
<SvgIcon name="check" :size="80" color="#FFFFFF" />
</view>
<text class="success-text" :class="{ show: animating }">{{ text }}</text>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
const props = withDefaults(defineProps<{
visible: boolean
text?: string
}>(), { text: '保存成功' })
const emit = defineEmits(['update:visible'])
const animating = ref(false)
watch(() => props.visible, (val) => {
if (val) {
setTimeout(() => { animating.value = true }, 50)
setTimeout(() => { hide() }, 2000)
} else {
animating.value = false
}
})
function hide() {
animating.value = false
setTimeout(() => { emit('update:visible', false) }, 300)
}
</script>
<style lang="scss" scoped>
.save-success {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
}
.success-icon {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: linear-gradient(135deg, #7BC67E, #5BA85E);
display: flex;
align-items: center;
justify-content: center;
transform: scale(0);
opacity: 0;
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
&.show {
transform: scale(1);
opacity: 1;
}
}
.success-text {
font-size: 32rpx;
font-weight: 600;
color: #FFFFFF;
margin-top: 24rpx;
transform: translateY(20rpx);
opacity: 0;
transition: all 0.3s ease 0.2s;
&.show {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@@ -1,18 +1,33 @@
<template> <template>
<view class="tx-item" @tap="$emit('tap', item)" @longpress="$emit('longpress', item)"> <view class="tx-item-wrap">
<view class="icon-wrap" :style="{ background: item.category_color + '20' }"> <view
<text class="icon-text" :style="{ color: item.category_color }">{{ item.category_name?.[0] || '?' }}</text> 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>
<view class="info"> <view class="delete-btn" :class="{ visible: showDelete }" @tap="$emit('delete', item)">
<text class="name">{{ item.note || item.category_name || '未分类' }}</text> <SvgIcon name="trash" :size="32" color="#FFFFFF" />
<text class="meta">{{ item.category_name || '未分类' }}{{ hideDate ? '' : ' · ' + formatDate(item.date) }}</text>
</view> </view>
<text class="amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'
import { formatAmount, formatDate } from '@/utils/format' import { formatAmount, formatDate } from '@/utils/format'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
withDefaults(defineProps<{ withDefaults(defineProps<{
item: { item: {
@@ -28,23 +43,79 @@ withDefaults(defineProps<{
hideDate?: boolean hideDate?: boolean
}>(), { hideDate: false }) }>(), { 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.tx-item-wrap {
position: relative;
overflow: hidden;
margin-bottom: 8rpx;
}
.tx-item { .tx-item {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 28rpx 0; padding: 28rpx 0;
gap: 24rpx; gap: 24rpx;
border-radius: 20rpx; border-radius: 20rpx;
transition: background 0.15s; transition: transform 0.2s ease;
background: #FFFFFF;
position: relative;
z-index: 1;
&:active { &:active {
background: #FFF0E6; 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 { .icon-wrap {
width: 80rpx; width: 80rpx;
height: 80rpx; height: 80rpx;

View File

@@ -22,9 +22,9 @@
<text class="digits">{{ displayAmount }}</text> <text class="digits">{{ displayAmount }}</text>
</view> </view>
<scroll-view class="category-scroll" scroll-y> <scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat">
<view class="category-grid"> <view class="category-grid">
<view v-for="cat in currentCategories" :key="cat.id" class="cat-item" @tap="selectedCat = cat.id"> <view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" @tap="selectedCat = cat.id">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" /> <CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
<text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text> <text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text>
</view> </view>
@@ -45,6 +45,8 @@
</view> </view>
<Numpad @input="onInput" @delete="onDelete" @confirm="save" /> <Numpad @input="onInput" @delete="onDelete" @confirm="save" />
<SaveSuccess v-model:visible="showSuccess" :text="editId ? '修改成功' : '记账成功'" />
</view> </view>
</template> </template>
@@ -55,6 +57,7 @@ import { useTransactionStore } from '@/stores/transaction'
import Numpad from '@/components/Numpad/Numpad.vue' import Numpad from '@/components/Numpad/Numpad.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue' import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
import { statusBarHeight } from '@/utils/system' import { statusBarHeight } from '@/utils/system'
const catStore = useCategoryStore() const catStore = useCategoryStore()
@@ -67,6 +70,8 @@ const note = ref('')
const selectedDate = ref(new Date().toISOString().slice(0, 10)) const selectedDate = ref(new Date().toISOString().slice(0, 10))
const saving = ref(false) const saving = ref(false)
const editId = ref<number | null>(null) const editId = ref<number | null>(null)
const showSuccess = ref(false)
const scrollToCat = ref('')
const todayStr = new Date().toISOString().slice(0, 10) const todayStr = new Date().toISOString().slice(0, 10)
// Remember last selected category per type // Remember last selected category per type
const lastCatByType: Record<string, number | null> = { expense: null, income: null } const lastCatByType: Record<string, number | null> = { expense: null, income: null }
@@ -124,6 +129,8 @@ onMounted(async () => {
lastCatByType[existing.type] = existing.category_id lastCatByType[existing.type] = existing.category_id
note.value = existing.note || '' note.value = existing.note || ''
selectedDate.value = existing.date.slice(0, 10) selectedDate.value = existing.date.slice(0, 10)
// Scroll to selected category
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 100)
} else { } else {
uni.showToast({ title: '记录不存在', icon: 'none' }) uni.showToast({ title: '记录不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500) setTimeout(() => uni.navigateBack(), 1500)
@@ -188,12 +195,11 @@ async function save() {
} }
if (editId.value) { if (editId.value) {
await txStore.updateTransaction(editId.value, data) await txStore.updateTransaction(editId.value, data)
uni.showToast({ title: '修改成功', icon: 'success' })
} else { } else {
await txStore.addTransaction(data) await txStore.addTransaction(data)
uni.showToast({ title: '记账成功', icon: 'success' })
} }
setTimeout(() => uni.navigateBack(), 1500) showSuccess.value = true
setTimeout(() => uni.navigateBack(), 2500)
} catch (e) { } catch (e) {
uni.showToast({ title: '保存失败', icon: 'none' }) uni.showToast({ title: '保存失败', icon: 'none' })
} finally { } finally {

View File

@@ -27,6 +27,7 @@
:hideDate="true" :hideDate="true"
@tap="onEdit(item)" @tap="onEdit(item)"
@longpress="onDelete(item)" @longpress="onDelete(item)"
@delete="onDelete(item)"
/> />
</view> </view>
</view> </view>