Files
xiaocai/client/src/pages/add/index.vue
wangxiaogang 5df910c9f1 feat(ui): UI/交互优化 — 动画系统+Design Token+骨架屏+微反馈
1. Design Token 统一:CategoryIcon/BudgetBar/Numpad/Skeleton/TransactionItem
   硬编码hex颜色替换为 $primary/$border/$danger 等SCSS变量
2. 入场动画:mixins.scss 添加 fade-in-up/bounce-in/count-up 等 mixin+keyframes,
   首页卡片/金额/快捷按钮应用交错入场动画
3. 记账成功动画增强:SaveSuccess 增加纸屑粒子爆发+光环脉冲效果,
   prefers-reduced-motion 下自动隐藏
4. 骨架屏替代文字加载:recurring/group-manage/notifications 三个页面
   从"加载中..."替换为骨架屏;空状态增加图标容器+引导文案
5. 交互微反馈:分类按压0.92缩放、推荐虚线呼吸动画、
   Tab滑块cubic-bezier优化、快捷按钮弹性按压
6. 左滑删除提示:TransactionItem首项1.5s后自动微微左滑再回弹,
   引导用户发现滑动删除功能
2026-06-10 15:59:42 +08:00

467 lines
13 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)
amountEditorRef.value?.focus()
})
} 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;
@include fade-in-up;
}
.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 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.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;
-webkit-tap-highlight-color: transparent;
&:active { transform: scale(0.92); }
&.suggested {
.cat-icon-wrap {
border: 3rpx dashed $primary;
border-radius: $radius-lg;
padding: 6rpx;
margin: -6rpx;
animation: suggest-pulse 2s ease-in-out infinite;
}
}
}
@keyframes suggest-pulse {
0%, 100% { border-color: $primary; }
50% { border-color: lighten($primary, 30%); }
}
.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>