feat: 小菜记账 v1.0 - 完整功能实现
核心功能: - 记账CRUD(支出/收入/分类/备注/日期) - 统计分析(概览/分类占比/每日趋势) - 预算管理(按月设置/进度条/超支提醒) - 数据导出CSV 安全与认证: - HMAC-SHA256签名token认证 - 用户数据隔离 - 输入验证与错误处理 - CORS配置 前端优化: - 骨架屏加载 - 账单按日期分组 - 预算页面重构(快捷预设+Numpad) - SvgIcon组件(H5+微信双端适配) - 下拉刷新 后端优化: - 共享日期工具函数 - 数据库连接池优化 - 健康检查端点 - 优雅关闭处理 技术栈: - 前端:Uni-app (Vue 3 + Pinia) - 后端:Node.js + Express + MySQL
This commit is contained in:
361
client/src/pages/add/index.vue
Normal file
361
client/src/pages/add/index.vue
Normal file
@@ -0,0 +1,361 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||
<view class="header">
|
||||
<view class="close" @tap="goBack">
|
||||
<SvgIcon name="close" :size="36" color="#8B7E7E" />
|
||||
</view>
|
||||
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text>
|
||||
<view style="width: 64rpx;"></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>
|
||||
|
||||
<view class="amount-display">
|
||||
<text class="currency">¥</text>
|
||||
<text class="digits">{{ displayAmount }}</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="category-scroll" scroll-y>
|
||||
<view class="category-grid">
|
||||
<view v-for="cat in currentCategories" :key="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" />
|
||||
<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">
|
||||
<SvgIcon name="calendar" :size="28" color="#8B7E7E" />
|
||||
<text class="extra-text">{{ dateLabel }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view class="extra-row">
|
||||
<SvgIcon name="edit" :size="28" color="#8B7E7E" />
|
||||
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="50" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<Numpad @input="onInput" @delete="onDelete" @confirm="save" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useCategoryStore } from '@/stores/category'
|
||||
import { useTransactionStore } from '@/stores/transaction'
|
||||
import Numpad from '@/components/Numpad/Numpad.vue'
|
||||
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||||
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
|
||||
import { statusBarHeight } 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 selectedDate = ref(new Date().toISOString().slice(0, 10))
|
||||
const saving = ref(false)
|
||||
const editId = ref<number | null>(null)
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
// 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 displayAmount = computed(() => {
|
||||
const num = parseFloat(amountStr.value) || 0
|
||||
return num.toFixed(2)
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await catStore.fetchCategories()
|
||||
const pages = getCurrentPages()
|
||||
const page = pages[pages.length - 1] as any
|
||||
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)
|
||||
} else {
|
||||
uni.showToast({ title: '记录不存在', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
}
|
||||
}
|
||||
if (currentCategories.value.length > 0 && !selectedCat.value) {
|
||||
selectedCat.value = currentCategories.value[0].id
|
||||
}
|
||||
})
|
||||
|
||||
function onDateChange(e: any) {
|
||||
selectedDate.value = e.detail.value
|
||||
}
|
||||
|
||||
function onInput(key: string) {
|
||||
if (key === '.' && amountStr.value.includes('.')) return
|
||||
// When current value is '0', replace with new key (but '0' and '00' should stay as '0')
|
||||
let next: string
|
||||
if (amountStr.value === '0') {
|
||||
next = (key === '0' || key === '00') ? '0' : key
|
||||
} else {
|
||||
next = amountStr.value + key
|
||||
}
|
||||
const num = parseFloat(next)
|
||||
if (!isNaN(num) && num > 9999999.99) return
|
||||
// Check decimal limit on the resulting value (handles "00" adding 2 chars)
|
||||
if (next.includes('.')) {
|
||||
const decimals = next.split('.')[1]
|
||||
if (decimals && decimals.length > 2) return
|
||||
}
|
||||
amountStr.value = next
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (amountStr.value.length > 1) {
|
||||
amountStr.value = amountStr.value.slice(0, -1)
|
||||
} else {
|
||||
amountStr.value = '0'
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving.value) return
|
||||
const amount = Math.round(parseFloat(amountStr.value) * 100)
|
||||
if (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)
|
||||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||
} else {
|
||||
await txStore.addTransaction(data)
|
||||
uni.showToast({ title: '记账成功', icon: 'success' })
|
||||
}
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '保存失败', icon: 'none' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
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>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #FFF8F0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
background: #FFF8F0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16rpx 40rpx;
|
||||
}
|
||||
|
||||
.close {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title { font-size: 32rpx; font-weight: 500; color: #2D1B1B; }
|
||||
|
||||
.type-toggle-wrap { display: flex; justify-content: center; margin: 16rpx 0; }
|
||||
|
||||
.type-toggle {
|
||||
display: inline-flex;
|
||||
background: #FFF0E6;
|
||||
border-radius: 24rpx;
|
||||
padding: 8rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab {
|
||||
width: 160rpx;
|
||||
height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
color: #8B7E7E;
|
||||
border-radius: 20rpx;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: color 0.2s;
|
||||
|
||||
&.active { color: #FF8C69; font-weight: 600; }
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
top: 8rpx;
|
||||
left: 8rpx;
|
||||
width: 160rpx;
|
||||
height: 72rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.08);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.income .slider { transform: translateX(160rpx); }
|
||||
|
||||
.amount-display {
|
||||
text-align: center;
|
||||
padding: 32rpx 40rpx 24rpx;
|
||||
}
|
||||
|
||||
.currency {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 48rpx;
|
||||
font-weight: 500;
|
||||
color: #BFB3B3;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.digits {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 88rpx;
|
||||
font-weight: 700;
|
||||
color: #2D1B1B;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.category-scroll {
|
||||
max-height: 320rpx;
|
||||
padding: 0 40rpx;
|
||||
}
|
||||
|
||||
.category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20rpx;
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
|
||||
.cat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
padding: 12rpx 0;
|
||||
|
||||
&:active { transform: scale(0.95); }
|
||||
}
|
||||
|
||||
.cat-name { font-size: 22rpx; color: #8B7E7E; }
|
||||
|
||||
.cat-name.active {
|
||||
color: #FF8C69;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.extra-fields {
|
||||
padding: 16rpx 40rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.extra-row {
|
||||
height: 88rpx;
|
||||
background: #FFFFFF;
|
||||
border-radius: 20rpx;
|
||||
border: 2rpx solid #F0E0D6;
|
||||
padding: 0 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.extra-text { font-size: 28rpx; color: #2D1B1B; flex: 1; }
|
||||
.extra-input { font-size: 28rpx; color: #2D1B1B; flex: 1; }
|
||||
|
||||
.placeholder { color: #BFB3B3; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user