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:
2026-05-29 16:14:15 +08:00
commit c7f29aa7a7
70 changed files with 32282 additions and 0 deletions

View File

@@ -0,0 +1,285 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">全部账单</text>
<view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: filterType === 'all' }" @tap="switchFilter('all')">全部</view>
<view class="tab" :class="{ active: filterType === 'expense' }" @tap="switchFilter('expense')">支出</view>
<view class="tab" :class="{ active: filterType === 'income' }" @tap="switchFilter('income')">收入</view>
</view>
</view>
<view class="tx-list" v-if="!txStore.loading || txList.length > 0">
<view v-for="group in groupedTx" :key="group.date" class="date-group">
<view class="group-header">
<text class="group-date">{{ group.label }}</text>
<text class="group-total" :class="{ expense: group.expense > 0 }">
{{ group.expense > 0 ? '- ' + formatAmount(group.expense) : '' }}
{{ group.income > 0 ? '+ ' + formatAmount(group.income) : '' }}
</text>
</view>
<TransactionItem
v-for="item in group.items"
:key="item.id"
:item="item"
:hideDate="true"
@tap="onEdit(item)"
@longpress="onDelete(item)"
/>
</view>
</view>
<view class="tx-list" v-if="txStore.loading && txList.length === 0">
<view v-for="g in 2" :key="g" class="date-group">
<view class="group-header">
<Skeleton width="120rpx" height="26rpx" variant="text" />
<Skeleton width="160rpx" height="24rpx" variant="text" />
</view>
<view v-for="i in 3" :key="i" class="skeleton-item">
<Skeleton width="80rpx" height="80rpx" variant="circle" />
<view class="skeleton-info">
<Skeleton width="160rpx" height="28rpx" variant="text" />
<Skeleton width="240rpx" height="24rpx" variant="text" />
</view>
<Skeleton width="120rpx" height="32rpx" variant="rect" />
</view>
</view>
</view>
<view class="loading-more" v-if="txStore.loading && txList.length > 0">
<text class="loading-text">加载中...</text>
</view>
<view class="loading-more" v-if="noMore && txList.length > 0">
<text class="loading-text">没有更多了</text>
</view>
<view class="state-box" v-if="loadError">
<SvgIcon name="alert-circle" :size="48" color="#BFB3B3" />
<text class="state-text">加载失败下拉刷新重试</text>
</view>
<view class="empty" v-if="txList.length === 0 && !txStore.loading && !loadError">
<text class="empty-text">暂无账单记录</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format'
const txStore = useTransactionStore()
const filterType = ref('all')
const page = ref(1)
const pageSize = 20
const txList = ref<any[]>([])
const total = ref(0)
const loadError = ref(false)
const noMore = computed(() => txList.value.length >= total.value && total.value > 0)
const groupedTx = computed(() => {
const groups: Record<string, { date: string; label: string; expense: number; income: number; items: any[] }> = {}
for (const tx of txList.value) {
if (!groups[tx.date]) {
groups[tx.date] = { date: tx.date, label: formatDate(tx.date), expense: 0, income: 0, items: [] }
}
groups[tx.date].items.push(tx)
if (tx.type === 'expense') {
groups[tx.date].expense += tx.amount
} else {
groups[tx.date].income += tx.amount
}
}
return Object.values(groups).sort((a, b) => b.date.localeCompare(a.date))
})
onMounted(() => loadTx(true))
function switchFilter(type: string) {
filterType.value = type
loadTx(true)
}
async function loadTx(reset = false) {
if (reset) {
page.value = 1
txList.value = []
}
const params: any = { page: page.value, pageSize }
if (filterType.value !== 'all') params.type = filterType.value
try {
loadError.value = false
await txStore.fetchTransactions(params)
if (reset) {
txList.value = txStore.transactions
} else {
txList.value = [...txList.value, ...txStore.transactions]
}
total.value = txStore.total
} catch (e) {
console.error('Load bills error:', e)
loadError.value = true
}
}
function onEdit(item: any) {
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
}
async function onDelete(item: any) {
uni.showModal({
title: '删除记录',
content: `确定删除这笔 ${item.type === 'expense' ? '支出' : '收入'} ¥${(item.amount / 100).toFixed(2)}`,
success: async (res) => {
if (res.confirm) {
try {
await txStore.deleteTransaction(item.id)
txList.value = txList.value.filter(t => t.id !== item.id)
total.value--
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
// 触底加载更多
onReachBottom(() => {
if (noMore.value || txStore.loading) return
page.value++
loadTx(false)
})
// 下拉刷新
onPullDownRefresh(() => {
loadTx(true).finally(() => uni.stopPullDownRefresh())
})
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.status-bar {
background: #FFF8F0;
}
.page-title {
display: block;
text-align: center;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
padding: 16rpx 0 24rpx;
}
.tabs-wrap { display: flex; justify-content: center; margin: 0 0 32rpx; }
.tabs {
display: inline-flex;
background: #FFF0E6;
border-radius: 24rpx;
padding: 8rpx;
}
.tab {
padding: 16rpx 40rpx;
border-radius: 20rpx;
font-size: 28rpx;
color: #8B7E7E;
transition: all 0.2s;
&.active {
background: #FFFFFF;
color: #FF8C69;
font-weight: 600;
box-shadow: 2rpx 2rpx 8rpx rgba(45, 27, 27, 0.06);
}
}
.tx-list { padding: 0 40rpx; }
.date-group {
margin-bottom: 24rpx;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #F0E0D6;
margin-bottom: 8rpx;
}
.group-date {
font-size: 26rpx;
font-weight: 600;
color: #2D1B1B;
}
.group-total {
font-family: 'Fredoka', sans-serif;
font-size: 24rpx;
color: #8B7E7E;
&.expense { color: #2D1B1B; }
}
.skeleton-item {
display: flex;
align-items: center;
padding: 28rpx 0;
gap: 24rpx;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.loading-more {
text-align: center;
padding: 32rpx 0;
}
.loading-text { font-size: 24rpx; color: #BFB3B3; }
.empty {
text-align: center;
padding: 120rpx 0;
}
.empty-text { font-size: 28rpx; color: #BFB3B3; }
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 120rpx 0;
}
.state-text {
font-size: 28rpx;
color: #BFB3B3;
}
</style>