核心功能: - 记账CRUD(支出/收入/分类/备注/日期) - 统计分析(概览/分类占比/每日趋势) - 预算管理(按月设置/进度条/超支提醒) - 数据导出CSV 安全与认证: - HMAC-SHA256签名token认证 - 用户数据隔离 - 输入验证与错误处理 - CORS配置 前端优化: - 骨架屏加载 - 账单按日期分组 - 预算页面重构(快捷预设+Numpad) - SvgIcon组件(H5+微信双端适配) - 下拉刷新 后端优化: - 共享日期工具函数 - 数据库连接池优化 - 健康检查端点 - 优雅关闭处理 技术栈: - 前端:Uni-app (Vue 3 + Pinia) - 后端:Node.js + Express + MySQL
355 lines
8.4 KiB
Vue
355 lines
8.4 KiB
Vue
<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>
|