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后自动微微左滑再回弹, 引导用户发现滑动删除功能
467 lines
13 KiB
Vue
467 lines
13 KiB
Vue
<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>
|