Files
xiaocai/client/src/pages/profile/index.vue
wangxiaogang db81ad8eb2 refactor: API 请求整合与 Bug 修复
前端重构:
- 新增 api/ 目录统一管理所有 API 请求
- 所有 stores 和 pages 改为使用 api 模块
- 修复日期格式化函数,支持 Date 对象和 ISO 字符串
- 修复 TransactionItem 使用 CategoryIcon 组件
- 优化 ChartWrapper 区分 H5 和小程序环境

后端修复:
- 修复 auth 中间件 PUBLIC_PATHS 缺少 demo-login
- 修复备份工具命令注入漏洞,改用 execFile
- 修复 stats 路由 type 参数验证
- 优化分类排序为批量 SQL 更新
- 合并迁移和删除为数据库事务原子操作
- 修复交易和统计日期返回格式
- 新增自动备份功能(启动时备份)
2026-06-02 17:41:35 +08:00

383 lines
9.3 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. 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="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>