Files
xiaocai/client/src/pages/index/index.vue
wangxiaogang c7f29aa7a7 feat: 小菜记账 v1.0 - 完整功能实现
核心功能:
- 记账CRUD(支出/收入/分类/备注/日期)
- 统计分析(概览/分类占比/每日趋势)
- 预算管理(按月设置/进度条/超支提醒)
- 数据导出CSV

安全与认证:
- HMAC-SHA256签名token认证
- 用户数据隔离
- 输入验证与错误处理
- CORS配置

前端优化:
- 骨架屏加载
- 账单按日期分组
- 预算页面重构(快捷预设+Numpad)
- SvgIcon组件(H5+微信双端适配)
- 下拉刷新

后端优化:
- 共享日期工具函数
- 数据库连接池优化
- 健康检查端点
- 优雅关闭处理

技术栈:
- 前端:Uni-app (Vue 3 + Pinia)
- 后端:Node.js + Express + MySQL
2026-05-29 16:14:15 +08:00

355 lines
8.4 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="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="avatar-wrap">
<SvgIcon name="user" :size="36" color="#8B7E7E" />
</view>
<text class="greeting">{{ greeting }}小菜</text>
<view class="bell" @tap="uni.showToast({ title: '暂无通知', icon: 'none' })">
<SvgIcon name="bell" :size="40" color="#8B7E7E" />
</view>
</view>
<view class="overview-card">
<text class="card-label">本月支出</text>
<view class="amount-row" v-if="!loading">
<text class="currency">¥</text>
<text class="amount">{{ formatAmountRaw(overview.expense) }}</text>
</view>
<view class="amount-skeleton" v-else>
<Skeleton width="280rpx" height="72rpx" variant="rect" />
</view>
<BudgetBar v-if="!loading" :total="budget?.amount || DEFAULT_BUDGET" :used="overview.expense" />
<Skeleton v-else width="100%" height="16rpx" variant="text" />
<view class="mini-stats">
<view class="mini-stat">
<text class="mini-label">收入</text>
<text class="mini-value income" v-if="!loading">+{{ formatAmount(overview.income) }}</text>
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
</view>
<view class="mini-stat">
<text class="mini-label">支出</text>
<text class="mini-value expense" v-if="!loading">-{{ formatAmount(overview.expense) }}</text>
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
</view>
</view>
</view>
<view class="quick-actions">
<view class="quick-btn" @tap="goAdd('expense')">
<SvgIcon name="trend-down" :size="48" color="#FF6B6B" />
<text class="quick-text">记支出</text>
</view>
<view class="quick-btn" @tap="goAdd('income')">
<SvgIcon name="trend-up" :size="48" color="#7BC67E" />
<text class="quick-text">记收入</text>
</view>
</view>
<view class="section-header">
<text class="section-title">近期账单</text>
<text class="section-more" @tap="goBills">查看全部</text>
</view>
<view class="tx-list" v-if="!loading && !loadError && recentTx.length > 0">
<TransactionItem v-for="item in recentTx" :key="item.id" :item="item" @tap="onTxTap(item)" />
</view>
<view class="tx-list" v-if="loading">
<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 class="state-box" v-if="loadError">
<SvgIcon name="alert-circle" :size="48" color="#BFB3B3" />
<text class="state-text">加载失败下拉刷新重试</text>
</view>
<view class="state-box" v-if="!loading && !loadError && recentTx.length === 0">
<SvgIcon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">还没有记录快去记一笔吧</text>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import { useStatsStore } from '@/stores/stats'
import { useBudgetStore } from '@/stores/budget'
import { formatAmount, formatAmountRaw } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
const txStore = useTransactionStore()
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const DEFAULT_BUDGET = 500000 // 5000元
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
const budget = ref<any>(null)
const recentTx = ref<any[]>([])
const loading = ref(true)
const loadError = ref(false)
const greeting = computed(() => {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 12) return '早上好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
return '晚上好'
})
async function loadData() {
try {
loading.value = true
loadError.value = false
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1 })
])
overview.value = statsStore.overview
budget.value = budgetStore.budget
recentTx.value = txStore.transactions.slice(0, 10)
} catch (e) {
console.error('Load error:', e)
loadError.value = true
} finally {
loading.value = false
}
}
onMounted(() => loadData())
// Refresh data when returning from other pages (e.g., after adding a transaction)
onShow(() => {
if (!loading.value) loadData()
})
onPullDownRefresh(() => {
loadData().finally(() => uni.stopPullDownRefresh())
})
function goAdd(type: string) {
uni.navigateTo({ url: `/pages/add/index?type=${type}` })
}
function onTxTap(item: any) {
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
}
function goBills() {
uni.switchTab({ url: '/pages/bills/index' })
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.status-bar {
background: #FFF8F0;
}
.header {
display: flex;
align-items: center;
padding: 16rpx 40rpx 32rpx;
}
.avatar-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
border: 2rpx solid #F0E0D6;
background: #FFF0E6;
display: flex;
align-items: center;
justify-content: center;
}
.greeting {
flex: 1;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
margin-left: 16rpx;
}
.bell {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.overview-card {
margin: 0 40rpx;
padding: 48rpx;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08),
inset -2rpx -2rpx 6rpx rgba(45, 27, 27, 0.04);
}
.card-label {
font-size: 28rpx;
color: #8B7E7E;
display: block;
margin-bottom: 12rpx;
}
.amount-row {
display: flex;
align-items: baseline;
margin-bottom: 32rpx;
}
.currency {
font-family: 'Fredoka', sans-serif;
font-size: 44rpx;
font-weight: 500;
color: #8B7E7E;
margin-right: 4rpx;
}
.amount {
font-family: 'Fredoka', sans-serif;
font-size: 72rpx;
font-weight: 600;
color: #2D1B1B;
font-variant-numeric: tabular-nums;
}
.mini-stats {
display: flex;
gap: 24rpx;
margin-top: 40rpx;
}
.mini-stat {
flex: 1;
padding: 24rpx;
border-radius: 24rpx;
background: #FFF8F0;
text-align: center;
}
.mini-label {
font-size: 24rpx;
color: #8B7E7E;
display: block;
margin-bottom: 8rpx;
}
.mini-value {
font-family: 'Fredoka', sans-serif;
font-size: 36rpx;
font-weight: 600;
&.income { color: #7BC67E; }
&.expense { color: #FF6B6B; }
}
.quick-actions {
display: flex;
gap: 24rpx;
padding: 0 40rpx;
margin-top: 40rpx;
}
.quick-btn {
flex: 1;
height: 144rpx;
border-radius: 32rpx;
background: #FFFFFF;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
&:active {
transform: scale(0.97);
box-shadow: inset 2rpx 2rpx 6rpx rgba(45, 27, 27, 0.1);
}
}
.quick-text { font-size: 24rpx; font-weight: 500; color: #2D1B1B; }
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 48rpx 40rpx 24rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 500;
color: #8B7E7E;
}
.section-more {
font-size: 24rpx;
color: #FF8C69;
}
.tx-list {
padding: 0 40rpx;
}
.amount-skeleton {
margin-bottom: 32rpx;
}
.skeleton-item {
display: flex;
align-items: center;
padding: 28rpx 0;
gap: 24rpx;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
padding: 80rpx 0;
}
.state-text {
font-size: 28rpx;
color: #BFB3B3;
}
</style>