前端重构: - 新增 api/ 目录统一管理所有 API 请求 - 所有 stores 和 pages 改为使用 api 模块 - 修复日期格式化函数,支持 Date 对象和 ISO 字符串 - 修复 TransactionItem 使用 CategoryIcon 组件 - 优化 ChartWrapper 区分 H5 和小程序环境 后端修复: - 修复 auth 中间件 PUBLIC_PATHS 缺少 demo-login - 修复备份工具命令注入漏洞,改用 execFile - 修复 stats 路由 type 参数验证 - 优化分类排序为批量 SQL 更新 - 合并迁移和删除为数据库事务原子操作 - 修复交易和统计日期返回格式 - 新增自动备份功能(启动时备份)
383 lines
9.3 KiB
Vue
383 lines
9.3 KiB
Vue
<template>
|
||
<view class="page">
|
||
<view class="header-fixed">
|
||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||
<text class="page-title">我的</text>
|
||
</view>
|
||
|
||
<view class="profile-card">
|
||
<view class="p-avatar">
|
||
<Icon 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>
|
||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||
</view>
|
||
<view class="menu-item" @tap="goCategoryManage">
|
||
<text class="mi-label">分类管理</text>
|
||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||
</view>
|
||
<view class="menu-item" @tap="exportData">
|
||
<text class="mi-label">数据导出</text>
|
||
<Icon 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>
|
||
<Icon 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 { getTransactions } from '@/api/transaction'
|
||
import { formatAmount } from '@/utils/format'
|
||
import { statusBarHeight } from '@/utils/system'
|
||
import Icon from '@/components/Icon/Icon.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' })
|
||
}
|
||
|
||
function getLocalDateStr(): string {
|
||
const d = new Date()
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||
}
|
||
|
||
async function exportData() {
|
||
uni.showLoading({ title: '导出中...' })
|
||
try {
|
||
const data = await getTransactions({ 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 = `小菜记账_${getLocalDateStr()}.csv`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '导出成功', icon: 'success' })
|
||
// #endif
|
||
|
||
// #ifdef MP-WEIXIN
|
||
const fs = uni.getFileSystemManager()
|
||
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${getLocalDateStr()}.csv`
|
||
fs.writeFileSync(filePath, csv, 'utf-8')
|
||
uni.hideLoading()
|
||
uni.openDocument({
|
||
filePath,
|
||
fileType: 'csv',
|
||
success: () => {},
|
||
fail: () => uni.showToast({ title: '导出失败', icon: 'none' })
|
||
})
|
||
// #endif
|
||
} catch {
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '导出失败', icon: 'none' })
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.page {
|
||
min-height: 100vh;
|
||
background: #FFF8F0;
|
||
padding: 0 0 180rpx;
|
||
}
|
||
|
||
.header-fixed {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 100;
|
||
background: #FFF8F0;
|
||
}
|
||
|
||
.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-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;
|
||
|
||
&.confirm {
|
||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
||
color: #FFFFFF;
|
||
}
|
||
}
|
||
</style>
|