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)">
|
<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>
|
||||||
|
|||||||
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>
|
<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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user