feat: v1.3 交互增强
新增功能: - 账单左滑删除(替代长按,更符合移动端习惯) - 输入震动反馈(Numpad 按键时调用 uni.vibrateShort) - 保存成功动画(Checkmark 弹出动画) - 分类图标滚动到选中项(编辑记录时自动定位) 组件更新: - TransactionItem 支持左滑露出删除按钮 - Numpad 添加震动反馈 - SaveSuccess 新增保存成功动画组件
This commit is contained in:
@@ -3,10 +3,10 @@
|
||||
<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="emit('delete')">
|
||||
<view class="key fn" @tap="onDelete">
|
||||
<SvgIcon name="trash" :size="36" color="#FF8C69" />
|
||||
</view>
|
||||
<view class="key eq" @tap="emit('confirm')">
|
||||
<view class="key eq" @tap="onConfirm">
|
||||
<SvgIcon name="check" :size="36" color="#FFFFFF" />
|
||||
</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'])
|
||||
|
||||
function vibrate() {
|
||||
try {
|
||||
uni.vibrateShort({ type: 'light' })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function onKeyTap(key: string) {
|
||||
vibrate()
|
||||
emit('input', key)
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
vibrate()
|
||||
emit('delete')
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
vibrate()
|
||||
emit('confirm')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
85
client/src/components/SaveSuccess/SaveSuccess.vue
Normal file
85
client/src/components/SaveSuccess/SaveSuccess.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user