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,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>

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>

View File

@@ -0,0 +1,335 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<SvgIcon name="chevronLeft" :size="40" color="#2D1B1B" />
</view>
<text class="nav-title">预算设置</text>
<view class="nav-placeholder"></view>
</view>
<view class="content">
<!-- 当前预算预览 -->
<view class="preview-card">
<view class="preview-header">
<text class="preview-label">本月预算</text>
<text class="preview-month">{{ currentMonth }}</text>
</view>
<text class="preview-amount" v-if="!loading">
{{ budget ? formatAmount(budget.amount) : '未设置' }}
</text>
<view class="preview-skeleton" v-else></view>
<BudgetBar
v-if="budget"
:total="budget.amount"
:used="monthExpense"
:showLabel="true"
/>
<text class="preview-hint" v-else>设置预算掌控开支</text>
</view>
<!-- 快捷金额 -->
<view class="section-title">快捷金额</view>
<view class="preset-grid">
<view
class="preset-item"
v-for="p in presets"
:key="p"
:class="{ active: amountStr === String(p) }"
@tap="selectPreset(p)"
>
<text class="preset-text">{{ formatPreset(p) }}</text>
</view>
</view>
<!-- 自定义金额 -->
<view class="section-title">自定义金额</view>
<view class="custom-input">
<text class="input-prefix">¥</text>
<text class="input-value" :class="{ placeholder: !amountStr }">
{{ amountStr || '0.00' }}
</text>
<text class="input-unit"></text>
</view>
<!-- Numpad -->
<Numpad
@input="onInput"
@delete="onDelete"
@confirm="onConfirm"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useBudgetStore } from '@/stores/budget'
import { useStatsStore } from '@/stores/stats'
import { formatAmount, getCurrentMonth } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
import Numpad from '@/components/Numpad/Numpad.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
const budgetStore = useBudgetStore()
const statsStore = useStatsStore()
const currentMonth = getCurrentMonth()
const budget = ref<any>(null)
const monthExpense = ref(0)
const loading = ref(true)
const amountStr = ref('')
const presets = [1000, 2000, 3000, 5000, 10000]
function formatPreset(val: number) {
return val >= 10000 ? (val / 10000) + '万' : val.toLocaleString()
}
onMounted(async () => {
loading.value = true
try {
await Promise.all([
budgetStore.fetchBudget(currentMonth),
statsStore.fetchOverview(currentMonth)
])
budget.value = budgetStore.budget
monthExpense.value = statsStore.overview.expense
if (budget.value) {
amountStr.value = (budget.value.amount / 100).toString()
}
} catch (e) {
console.error('Budget page load error:', e)
} finally {
loading.value = false
}
})
function selectPreset(val: number) {
amountStr.value = String(val)
}
function onInput(key: string) {
if (key === '.' && amountStr.value.includes('.')) return
if (key === '.' && !amountStr.value) {
amountStr.value = '0.'
return
}
let next: string
if (amountStr.value === '0') {
next = (key === '0' || key === '00') ? '0' : key
} else {
next = amountStr.value + key
}
const parts = next.split('.')
if (parts[1] && parts[1].length > 2) return
if (next.length > 10) return
amountStr.value = next
}
function onDelete() {
amountStr.value = amountStr.value.slice(0, -1)
}
async function onConfirm() {
const amount = Math.round(parseFloat(amountStr.value || '0') * 100)
if (isNaN(amount) || amount <= 0) {
uni.showToast({ title: '请输入有效金额', icon: 'none' })
return
}
if (amount > 999999999) {
uni.showToast({ title: '金额过大', icon: 'none' })
return
}
try {
await budgetStore.setBudget(amount, currentMonth)
budget.value = budgetStore.budget
uni.showToast({ title: '预算已设置', icon: 'success' })
} catch {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
}
}
function goBack() {
uni.navigateBack()
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding-bottom: 120rpx;
}
.status-bar {
background: #FFF8F0;
}
.nav-bar {
display: flex;
align-items: center;
padding: 16rpx 32rpx;
}
.nav-back {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
}
.nav-title {
flex: 1;
text-align: center;
font-size: 36rpx;
font-weight: 600;
color: #2D1B1B;
}
.nav-placeholder {
width: 72rpx;
}
.content {
padding: 0 40rpx;
}
.preview-card {
background: #FFFFFF;
border-radius: 40rpx;
padding: 48rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
margin-bottom: 48rpx;
}
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.preview-label {
font-size: 28rpx;
color: #8B7E7E;
}
.preview-month {
font-size: 24rpx;
color: #BFB3B3;
}
.preview-amount {
font-family: 'Fredoka', sans-serif;
font-size: 56rpx;
font-weight: 600;
color: #2D1B1B;
display: block;
margin-bottom: 24rpx;
}
.preview-skeleton {
width: 240rpx;
height: 64rpx;
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
background-size: 200% 100%;
border-radius: 12rpx;
margin-bottom: 24rpx;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.preview-hint {
font-size: 28rpx;
color: #BFB3B3;
display: block;
text-align: center;
padding: 16rpx 0;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #2D1B1B;
margin-bottom: 24rpx;
}
.preset-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
margin-bottom: 48rpx;
}
.preset-item {
height: 96rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:active {
background: #FFF8F0;
border-color: #FF8C69;
}
&.active {
background: #FFE8E0;
border-color: #FF8C69;
}
}
.preset-text {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-weight: 500;
color: #2D1B1B;
}
.custom-input {
display: flex;
align-items: baseline;
gap: 8rpx;
margin-bottom: 32rpx;
padding: 24rpx 32rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
}
.input-prefix {
font-family: 'Fredoka', sans-serif;
font-size: 40rpx;
font-weight: 600;
color: #FF8C69;
}
.input-value {
font-family: 'Fredoka', sans-serif;
font-size: 56rpx;
font-weight: 600;
color: #2D1B1B;
flex: 1;
&.placeholder {
color: #D0C4C4;
}
}
.input-unit {
font-size: 28rpx;
color: #8B7E7E;
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="back" @tap="uni.navigateBack()">
<SvgIcon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="title">分类管理</text>
<view style="width: 88rpx;"></view>
</view>
<view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: tabType === 'expense' }" @tap="tabType = 'expense'">支出</view>
<view class="tab" :class="{ active: tabType === 'income' }" @tap="tabType = 'income'">收入</view>
</view>
</view>
<view class="cat-list">
<view v-for="cat in currentCategories" :key="cat.id" class="cat-row">
<view class="cat-info">
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="sm" />
<text class="cat-name">{{ cat.name }}</text>
<text class="cat-badge" v-if="cat.is_custom">自定义</text>
</view>
<view class="cat-actions" v-if="cat.is_custom" @tap="onDelete(cat)">
<SvgIcon name="trash" :size="28" color="#FF6B6B" />
</view>
</view>
</view>
<view class="add-section">
<text class="add-title">添加自定义分类</text>
<view class="add-form">
<input class="add-input" v-model="newName" placeholder="分类名称" maxlength="10" />
<view class="add-btn" :class="{ disabled: !newName.trim() }" @tap="onAdd">添加</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useCategoryStore } from '@/stores/category'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import { statusBarHeight } from '@/utils/system'
const catStore = useCategoryStore()
const tabType = ref<'expense' | 'income'>('expense')
const newName = ref('')
const currentCategories = computed(() => catStore.getByType(tabType.value))
onMounted(() => catStore.fetchCategories())
async function onAdd() {
const name = newName.value.trim()
if (!name) return
const colors = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4']
const color = colors[Math.floor(Math.random() * colors.length)]
try {
await catStore.addCategory({
name,
icon: name[0],
color,
type: tabType.value
})
newName.value = ''
uni.showToast({ title: '添加成功', icon: 'success' })
} catch {
uni.showToast({ title: '添加失败', icon: 'none' })
}
}
async function onDelete(cat: any) {
uni.showModal({
title: '删除分类',
content: `确定删除「${cat.name}」?该分类下的记录将变为"未分类"。`,
success: async (res) => {
if (res.confirm) {
try {
await catStore.deleteCategory(cat.id)
uni.showToast({ title: '已删除', icon: 'success' })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #FFF8F0;
padding: 0 0 180rpx;
}
.status-bar { background: #FFF8F0; }
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 40rpx;
}
.back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
}
.title { font-size: 32rpx; font-weight: 600; color: #2D1B1B; }
.tabs-wrap { display: flex; justify-content: center; margin: 16rpx 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);
}
}
.cat-list { padding: 0 40rpx; }
.cat-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 0;
border-bottom: 1rpx solid #F0E0D6;
&:last-child { border-bottom: none; }
}
.cat-info {
display: flex;
align-items: center;
gap: 16rpx;
}
.cat-name { font-size: 28rpx; color: #2D1B1B; font-weight: 500; }
.cat-badge {
font-size: 20rpx;
color: #FF8C69;
background: #FFE8E0;
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.cat-actions {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
.add-section {
margin: 48rpx 40rpx 0;
padding: 32rpx;
background: #FFFFFF;
border-radius: 32rpx;
border: 2rpx solid #F0E0D6;
}
.add-title {
font-size: 28rpx;
font-weight: 500;
color: #2D1B1B;
display: block;
margin-bottom: 24rpx;
}
.add-form {
display: flex;
gap: 16rpx;
}
.add-input {
flex: 1;
height: 88rpx;
background: #FFF8F0;
border-radius: 20rpx;
padding: 0 24rpx;
font-size: 28rpx;
}
.add-btn {
width: 160rpx;
height: 88rpx;
background: linear-gradient(135deg, #FF8C69, #E67355);
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
color: #FFFFFF;
&.disabled { opacity: 0.5; }
&:active { opacity: 0.8; }
}
</style>

View File

@@ -0,0 +1,354 @@
<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>

View File

@@ -0,0 +1,387 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">我的</text>
<view class="profile-card">
<view class="p-avatar">
<SvgIcon name="user" :size="64" color="#FFFFFF" />
</view>
<view class="p-info">
<text class="p-name">小菜</text>
<text class="p-tagline">记账小能手</text>
<text class="p-streak">累计 {{ txStore.total }} 笔记录</text>
</view>
</view>
<view class="quick-stats">
<view class="qs-card">
<text class="qs-label">本月支出</text>
<text class="qs-value" v-if="!loading">{{ formatAmount(overview.expense) }}</text>
<view class="qs-skeleton" v-else></view>
</view>
<view class="qs-card">
<text class="qs-label">本月收入</text>
<text class="qs-value income" v-if="!loading">{{ formatAmount(overview.income) }}</text>
<view class="qs-skeleton" v-else></view>
</view>
</view>
<view class="menu-card">
<view class="menu-item" @tap="goBudget">
<text class="mi-label">预算设置</text>
<text class="mi-extra" v-if="budget">{{ formatAmount(budget.amount) }}</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goCategoryManage">
<text class="mi-label">分类管理</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="exportData">
<text class="mi-label">数据导出</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<view class="menu-card mt">
<view class="menu-item" @tap="showAbout = true">
<text class="mi-label">关于小菜</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<!-- 关于弹窗 -->
<view class="modal-mask" v-if="showAbout" @tap="showAbout = false">
<view class="modal" @tap.stop>
<text class="about-title">小菜记账</text>
<text class="about-version">v1.0.0</text>
<text class="about-desc">可爱 x 高级的个人记账小程序</text>
<text class="about-desc">让记账从负担变成享受</text>
<view class="modal-btns" style="margin-top: 40rpx;">
<view class="modal-btn confirm" @tap="showAbout = false">知道了</view>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useTransactionStore } from '@/stores/transaction'
import { useStatsStore } from '@/stores/stats'
import { useBudgetStore } from '@/stores/budget'
import { formatAmount } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import { request } from '@/utils/request'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
const txStore = useTransactionStore()
const statsStore = useStatsStore()
const budgetStore = useBudgetStore()
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
const budget = ref<any>(null)
const loading = ref(true)
const showAbout = ref(false)
onMounted(async () => {
loading.value = true
try {
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1, pageSize: 1 })
])
overview.value = statsStore.overview
budget.value = budgetStore.budget
} catch (e) {
console.error('Profile load error:', e)
} finally {
loading.value = false
}
})
function goBudget() {
uni.navigateTo({ url: '/pages/budget/index' })
}
function goCategoryManage() {
uni.navigateTo({ url: '/pages/category-manage/index' })
}
async function exportData() {
try {
const data = await request<any>({ url: '/transactions', data: { page: 1, pageSize: 9999 } })
const list = data.list
if (!list || list.length === 0) {
uni.showToast({ title: '暂无数据', icon: 'none' })
return
}
if (data.total > list.length) {
uni.showToast({ title: `${data.total}条,仅导出前${list.length}`, icon: 'none' })
}
function csvEscape(val: string): string {
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"'
}
return val
}
const header = '日期,类型,分类,金额(元),备注\n'
const rows = list.map(t => {
const amount = (t.amount / 100).toFixed(2)
const type = t.type === 'expense' ? '支出' : '收入'
return [t.date, type, t.category_name || '未分类', amount, t.note || ''].map(csvEscape).join(',')
}).join('\n')
const csv = '' + header + rows
// #ifdef H5
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `小菜记账_${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
uni.showToast({ title: '导出成功', icon: 'success' })
// #endif
// #ifdef MP-WEIXIN
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${new Date().toISOString().slice(0, 10)}.csv`
fs.writeFileSync(filePath, csv, 'utf-8')
uni.openDocument({
filePath,
fileType: 'csv',
success: () => {},
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
})
// #endif
} catch {
uni.showToast({ title: '导出失败', icon: 'none' })
}
}
</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;
}
.profile-card {
margin: 0 40rpx;
padding: 48rpx;
background: linear-gradient(135deg, #FF8C69, #FFB89A);
border-radius: 40rpx;
box-shadow: 8rpx 8rpx 24rpx rgba(45, 27, 27, 0.1);
display: flex;
align-items: center;
gap: 32rpx;
}
.p-avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
border: 6rpx solid rgba(255, 255, 255, 0.5);
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
}
.p-name {
font-size: 40rpx;
font-weight: 600;
color: #FFFFFF;
display: block;
}
.p-tagline {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
display: block;
margin-top: 4rpx;
}
.p-streak {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.65);
display: block;
margin-top: 16rpx;
}
.quick-stats {
display: flex;
gap: 24rpx;
padding: 0 40rpx;
margin-top: 32rpx;
}
.qs-card {
flex: 1;
padding: 32rpx 24rpx;
background: #FFFFFF;
border-radius: 32rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
text-align: center;
}
.qs-label {
font-size: 24rpx;
color: #8B7E7E;
display: block;
margin-bottom: 8rpx;
}
.qs-value {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-weight: 600;
color: #2D1B1B;
&.income { color: #7BC67E; }
}
.qs-skeleton {
width: 160rpx;
height: 40rpx;
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
background-size: 200% 100%;
border-radius: 8rpx;
margin: 0 auto;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.menu-card {
margin: 40rpx 40rpx 0;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
overflow: hidden;
&.mt { margin-top: 32rpx; }
}
.menu-item {
height: 112rpx;
padding: 0 40rpx;
display: flex;
align-items: center;
border-bottom: 1rpx solid #F0E0D6;
&:last-child { border-bottom: none; }
&:active { background: #FFF8F0; }
}
.mi-label { flex: 1; font-size: 28rpx; font-weight: 500; color: #2D1B1B; }
.mi-extra { font-size: 28rpx; color: #FF8C69; font-weight: 600; margin-right: 16rpx; }
.about-title {
font-size: 40rpx;
font-weight: 600;
color: #FF8C69;
display: block;
text-align: center;
}
.about-version {
font-size: 24rpx;
color: #BFB3B3;
display: block;
text-align: center;
margin: 8rpx 0 24rpx;
}
.about-desc {
font-size: 28rpx;
color: #8B7E7E;
display: block;
text-align: center;
margin-bottom: 8rpx;
}
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.modal {
width: 600rpx;
background: #FFFFFF;
border-radius: 32rpx;
padding: 48rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #2D1B1B;
display: block;
text-align: center;
margin-bottom: 32rpx;
}
.modal-input {
height: 96rpx;
background: #FFF8F0;
border-radius: 24rpx;
padding: 0 32rpx;
font-size: 28rpx;
margin-bottom: 32rpx;
}
.modal-btns {
display: flex;
gap: 24rpx;
}
.modal-btn {
flex: 1;
height: 88rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
&.cancel {
background: #FFF8F0;
color: #8B7E7E;
}
&.confirm {
background: linear-gradient(135deg, #FF8C69, #E67355);
color: #FFFFFF;
}
}
</style>

View File

@@ -0,0 +1,371 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">统计分析</text>
<view class="month-picker">
<view class="arrow" @tap="prevMonth">
<SvgIcon name="arrowLeft" :size="32" color="#8B7E7E" />
</view>
<text class="month-text">{{ formatMonth(currentMonth) }}</text>
<view class="arrow" :class="{ disabled: isCurrentMonth }" @tap="nextMonth">
<SvgIcon name="arrowRight" :size="32" :color="isCurrentMonth ? '#E0D6D0' : '#8B7E7E'" />
</view>
</view>
<view class="tabs-wrap">
<view class="tabs">
<view class="tab" :class="{ active: tabType === 'expense' }" @tap="tabType = 'expense'">支出</view>
<view class="tab" :class="{ active: tabType === 'income' }" @tap="tabType = 'income'">收入</view>
</view>
</view>
<view class="overview-card">
<view class="ov-item">
<text class="ov-label">{{ tabType === 'expense' ? '总支出' : '总收入' }}</text>
<text class="ov-value" v-if="!loading">{{ formatAmount(tabType === 'expense' ? overview.expense : overview.income) }}</text>
<Skeleton v-else width="160rpx" height="40rpx" variant="rect" />
</view>
<view class="ov-item">
<text class="ov-label">日均</text>
<text class="ov-value" v-if="!loading">{{ formatAmount(overview.daily) }}</text>
<Skeleton v-else width="120rpx" height="40rpx" variant="rect" />
</view>
<view class="ov-item">
<text class="ov-label">笔数</text>
<text class="ov-value" v-if="!loading">{{ overview.count }}</text>
<Skeleton v-else width="80rpx" height="40rpx" variant="rect" />
</view>
</view>
<view class="chart-card" v-if="!loading && categoryStats.length > 0">
<text class="chart-title">分类占比</text>
<view class="chart-legend">
<view v-for="item in categoryStats" :key="item.id" class="legend-item">
<view class="legend-dot" :style="{ background: item.color }"></view>
<text class="legend-text">{{ item.name }} {{ formatAmount(item.amount) }}</text>
</view>
</view>
</view>
<view class="chart-card" v-if="loading">
<text class="chart-title">分类占比</text>
<view class="skeleton-legend">
<Skeleton v-for="i in 4" :key="i" width="200rpx" height="28rpx" variant="text" />
</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="empty" v-if="!loading && !loadError && categoryStats.length === 0">
<text class="empty-text">暂无{{ tabType === 'expense' ? '支出' : '收入' }}数据</text>
</view>
<view class="chart-card" v-if="!loading && trendData.length > 0">
<text class="chart-title">每日趋势</text>
<view class="trend-list">
<view v-for="(point, i) in trendData" :key="i" class="trend-item">
<text class="trend-date">{{ point.date.slice(8) }}</text>
<view class="trend-bar-bg">
<view class="trend-bar" :style="{ width: getBarWidth(point.amount) + '%' }"></view>
</view>
<text class="trend-amount">{{ formatAmount(point.amount) }}</text>
</view>
</view>
</view>
<view class="chart-card" v-if="loading">
<text class="chart-title">每日趋势</text>
<view class="trend-list">
<view v-for="i in 5" :key="i" class="skeleton-trend">
<Skeleton width="48rpx" height="24rpx" variant="text" />
<Skeleton width="100%" height="12rpx" variant="text" />
<Skeleton width="120rpx" height="24rpx" variant="text" />
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useStatsStore } from '@/stores/stats'
import { formatAmount, formatMonth, getCurrentMonth } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
const statsStore = useStatsStore()
const currentMonth = ref(getCurrentMonth())
const tabType = ref<'expense' | 'income'>('expense')
const overview = ref({ expense: 0, income: 0, count: 0, daily: 0 })
const categoryStats = ref<any[]>([])
const trendData = ref<any[]>([])
const loading = ref(true)
const loadError = ref(false)
const isCurrentMonth = computed(() => currentMonth.value === getCurrentMonth())
onMounted(() => loadData())
// Only re-fetch category/trend on tab switch; re-fetch all on month change
watch(currentMonth, () => loadData())
watch(tabType, () => loadTabData())
async function loadData() {
try {
loading.value = true
loadError.value = false
await Promise.all([
statsStore.fetchOverview(currentMonth.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value)
])
overview.value = statsStore.overview
categoryStats.value = statsStore.categoryStats
trendData.value = statsStore.trendData
} catch (e) {
console.error('Stats load error:', e)
loadError.value = true
} finally {
loading.value = false
}
}
// Only re-fetch category/trend data (not overview) when tab switches
async function loadTabData() {
try {
await Promise.all([
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value)
])
categoryStats.value = statsStore.categoryStats
trendData.value = statsStore.trendData
} catch (e) {
console.error('Stats tab load error:', e)
}
}
onPullDownRefresh(() => {
loadData().finally(() => uni.stopPullDownRefresh())
})
function prevMonth() {
const [y, m] = currentMonth.value.split('-').map(Number)
currentMonth.value = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}`
}
function nextMonth() {
if (isCurrentMonth.value) return
const [y, m] = currentMonth.value.split('-').map(Number)
currentMonth.value = m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, '0')}`
}
function getBarWidth(amount: number) {
const max = Math.max(...trendData.value.map(t => t.amount), 1)
return (amount / max) * 100
}
</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;
}
.month-picker {
display: flex;
align-items: center;
justify-content: center;
gap: 48rpx;
padding: 16rpx 0;
}
.arrow {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
&.disabled { opacity: 0.5; }
}
.month-text {
font-family: 'Fredoka', sans-serif;
font-size: 32rpx;
font-weight: 500;
color: #2D1B1B;
}
.tabs-wrap { display: flex; justify-content: center; margin: 16rpx 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);
}
}
.overview-card, .chart-card {
margin: 0 40rpx 32rpx;
padding: 40rpx;
background: #FFFFFF;
border-radius: 40rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
}
.overview-card {
display: grid;
grid-template-columns: repeat(3, 1fr);
text-align: center;
}
.ov-label {
font-size: 24rpx;
color: #8B7E7E;
display: block;
margin-bottom: 12rpx;
}
.ov-value {
font-family: 'Fredoka', sans-serif;
font-size: 40rpx;
font-weight: 600;
color: #2D1B1B;
}
.chart-title {
font-size: 32rpx;
font-weight: 500;
color: #2D1B1B;
display: block;
margin-bottom: 24rpx;
}
.chart-legend {
display: flex;
flex-wrap: wrap;
gap: 16rpx 32rpx;
}
.legend-item {
display: flex;
align-items: center;
gap: 12rpx;
}
.legend-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
}
.legend-text { font-size: 24rpx; color: #8B7E7E; }
.empty {
text-align: center;
padding: 80rpx 0;
}
.empty-text { font-size: 28rpx; color: #BFB3B3; }
.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;
}
.trend-list { display: flex; flex-direction: column; gap: 16rpx; }
.skeleton-legend {
display: flex;
flex-wrap: wrap;
gap: 16rpx 32rpx;
}
.skeleton-trend {
display: flex;
align-items: center;
gap: 16rpx;
}
.trend-item {
display: flex;
align-items: center;
gap: 16rpx;
}
.trend-date {
width: 48rpx;
font-size: 24rpx;
color: #8B7E7E;
text-align: right;
}
.trend-bar-bg {
flex: 1;
height: 12rpx;
border-radius: 6rpx;
background: #F0E0D6;
overflow: hidden;
}
.trend-bar {
height: 100%;
border-radius: 6rpx;
background: linear-gradient(90deg, #FF8C69, #FFB89A);
transition: width 0.6s ease-out;
}
.trend-amount {
width: 120rpx;
font-family: 'Fredoka', sans-serif;
font-size: 24rpx;
font-weight: 500;
color: #2D1B1B;
}
</style>