feat: 小菜记账 v1.0 - 完整功能实现
核心功能: - 记账CRUD(支出/收入/分类/备注/日期) - 统计分析(概览/分类占比/每日趋势) - 预算管理(按月设置/进度条/超支提醒) - 数据导出CSV 安全与认证: - HMAC-SHA256签名token认证 - 用户数据隔离 - 输入验证与错误处理 - CORS配置 前端优化: - 骨架屏加载 - 账单按日期分组 - 预算页面重构(快捷预设+Numpad) - SvgIcon组件(H5+微信双端适配) - 下拉刷新 后端优化: - 共享日期工具函数 - 数据库连接池优化 - 健康检查端点 - 优雅关闭处理 技术栈: - 前端:Uni-app (Vue 3 + Pinia) - 后端:Node.js + Express + MySQL
14
client/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>小菜记账</title>
|
||||
<!--preload-links-->
|
||||
<!--app-context-->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"><!--app-html--></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
15354
client/package-lock.json
generated
Normal file
35
client/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "xiaocai-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:h5": "uni -p h5",
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:h5": "uni build -p h5",
|
||||
"build:mp-weixin": "uni build -p mp-weixin",
|
||||
"lint": "eslint src --ext .ts,.vue"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-alpha-5010120260525001",
|
||||
"@dcloudio/uni-app-plus": "3.0.0-alpha-5010120260525001",
|
||||
"@dcloudio/uni-components": "3.0.0-alpha-5010120260525001",
|
||||
"@dcloudio/uni-h5": "3.0.0-alpha-5010120260525001",
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-alpha-5010120260525001",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.4.0",
|
||||
"@dcloudio/uni-automator": "3.0.0-alpha-5010120260525001",
|
||||
"@dcloudio/uni-cli-shared": "3.0.0-alpha-5010120260525001",
|
||||
"@dcloudio/uni-stacktracey": "3.0.0-alpha-5010120260525001",
|
||||
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5010120260525001",
|
||||
"@types/node": "^20.11.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"eslint": "^8.56.0",
|
||||
"sass": "^1.70.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
39
client/src/App.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { onLaunch } from '@dcloudio/uni-app'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
onLaunch(() => {
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: async (loginRes) => {
|
||||
if (loginRes.code) {
|
||||
try {
|
||||
const data = await request<{ token: string; userId: number }>({
|
||||
url: '/auth/login',
|
||||
method: 'POST',
|
||||
data: { code: loginRes.code }
|
||||
})
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
console.log('[Auth] Login success')
|
||||
} catch (e) {
|
||||
console.error('[Auth] Login failed:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import './uni.scss';
|
||||
|
||||
page {
|
||||
background-color: #FFF8F0;
|
||||
font-family: 'Nunito', 'PingFang SC', sans-serif;
|
||||
color: #2D1B1B;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
</style>
|
||||
75
client/src/components/BudgetBar/BudgetBar.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<view class="budget-bar-wrap">
|
||||
<view class="bar-track">
|
||||
<view class="bar-fill" :style="{ width: percentage + '%', background: fillColor }"></view>
|
||||
</view>
|
||||
<view class="bar-info" v-if="showLabel">
|
||||
<text class="bar-text" v-if="rawPercentage <= 100">预算 {{ formatAmount(total) }} · 剩余 {{ formatAmount(Math.max(0, total - used)) }}</text>
|
||||
<text class="bar-text over" v-else>已超支 {{ formatAmount(used - total) }}</text>
|
||||
<text class="bar-pct" :class="{ over: rawPercentage > 100 }">{{ rawPercentage }}%</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatAmount } from '@/utils/format'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
total: number
|
||||
used: number
|
||||
showLabel?: boolean
|
||||
}>(), { showLabel: true })
|
||||
|
||||
const rawPercentage = computed(() => {
|
||||
if (props.total <= 0) return 0
|
||||
return Math.round((props.used / props.total) * 100)
|
||||
})
|
||||
|
||||
const percentage = computed(() => Math.min(100, rawPercentage.value))
|
||||
|
||||
const fillColor = computed(() => {
|
||||
if (rawPercentage.value > 100) return '#FF6B6B'
|
||||
return 'linear-gradient(90deg, #FF8C69, #FFB89A)'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bar-track {
|
||||
height: 16rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #F0E0D6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 8rpx;
|
||||
transition: width 0.6s ease-out;
|
||||
}
|
||||
|
||||
.bar-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.bar-text {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
}
|
||||
|
||||
.bar-pct {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #FF8C69;
|
||||
|
||||
&.over { color: #FF6B6B; }
|
||||
}
|
||||
|
||||
.bar-text.over {
|
||||
color: #FF6B6B;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
48
client/src/components/CategoryIcon/CategoryIcon.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<view class="cat-icon" :class="[size, { selected }]" :style="iconStyle">
|
||||
<text class="icon-text" :style="{ color: color, fontSize: fontSize }">{{ label }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
color: string
|
||||
bgColor: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const sizeMap = { sm: 64, md: 88, lg: 112 }
|
||||
const fontMap = { sm: 28, md: 36, lg: 48 }
|
||||
|
||||
const iconStyle = computed(() => ({
|
||||
background: props.bgColor || (props.color + '15'),
|
||||
width: (sizeMap[props.size || 'md']) + 'rpx',
|
||||
height: (sizeMap[props.size || 'md']) + 'rpx',
|
||||
}))
|
||||
|
||||
const fontSize = computed(() => (fontMap[props.size || 'md']) + 'rpx')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cat-icon {
|
||||
border-radius: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: #FF8C69;
|
||||
background: #FFE8E0 !important;
|
||||
}
|
||||
</style>
|
||||
71
client/src/components/Numpad/Numpad.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<view class="numpad">
|
||||
<view class="key" v-for="key in keys" :key="key" @tap="onKeyTap(key)">
|
||||
<text class="key-text">{{ key }}</text>
|
||||
</view>
|
||||
<view class="key fn" @tap="emit('delete')">
|
||||
<SvgIcon name="trash" :size="36" color="#FF8C69" />
|
||||
</view>
|
||||
<view class="key eq" @tap="emit('confirm')">
|
||||
<SvgIcon name="check" :size="36" color="#FFFFFF" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
|
||||
|
||||
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
|
||||
|
||||
const emit = defineEmits(['input', 'delete', 'confirm'])
|
||||
|
||||
function onKeyTap(key: string) {
|
||||
emit('input', key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.numpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.key {
|
||||
height: 96rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #FFF8F0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
background: #F0E0D6;
|
||||
}
|
||||
}
|
||||
|
||||
.key-text {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 44rpx;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
}
|
||||
|
||||
.key.fn {
|
||||
background: #FFE8E0;
|
||||
|
||||
&:active {
|
||||
background: #FFD0C0;
|
||||
}
|
||||
}
|
||||
|
||||
.key.eq {
|
||||
background: linear-gradient(135deg, #FF8C69, #E67355);
|
||||
box-shadow: 0 4rpx 16rpx rgba(255, 140, 105, 0.3);
|
||||
|
||||
&:active {
|
||||
background: #E67355;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
40
client/src/components/Skeleton/Skeleton.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<view class="skeleton" :style="style" :class="[variant]"></view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
width?: string
|
||||
height?: string
|
||||
variant?: 'text' | 'rect' | 'circle'
|
||||
}>(), { variant: 'rect' })
|
||||
|
||||
const style = computed(() => ({
|
||||
width: props.width || (props.variant === 'circle' ? props.height : '100%'),
|
||||
height: props.height || '32rpx',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 8rpx;
|
||||
|
||||
&.text {
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
&.circle {
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
</style>
|
||||
70
client/src/components/SvgIcon/SvgIcon.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<!-- #ifdef H5 -->
|
||||
<image class="svg-icon" :src="svgData" :style="sizeStyle" mode="aspectFit" />
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view class="svg-icon-wrap" :style="sizeStyle">
|
||||
<rich-text :nodes="svgNode" :style="sizeStyle"></rich-text>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: string
|
||||
size?: number
|
||||
color?: string
|
||||
}>(), { size: 40, color: '#2D1B1B' })
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
close: '<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
||||
bell: '<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'trend-down': '<path d="M23 18l-9.5-9.5-5 5L1 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 18h6v-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'trend-up': '<path d="M23 6l-9.5 9.5-5-5L1 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 6h6v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
calendar: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/><path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
||||
edit: '<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
budget: '<path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
category: '<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/>',
|
||||
export: '<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
moon: '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'alert-circle': '<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/><path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
||||
info: '<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/><path d="M12 16v-4M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
||||
chevronRight: '<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
chevronLeft: '<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
arrowLeft: '<path d="M19 12H5M12 19l-7-7 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
arrowRight: '<path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
trash: '<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
check: '<path d="M20 6L9 17l-5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
}
|
||||
|
||||
const svgData = computed(() => {
|
||||
const path = icons[props.name] || ''
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${props.color}" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
|
||||
return 'data:image/svg+xml;base64,' + btoa(svg)
|
||||
})
|
||||
|
||||
const svgNode = computed(() => {
|
||||
const path = icons[props.name] || ''
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${props.color}" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
|
||||
})
|
||||
|
||||
const sizeStyle = computed(() => {
|
||||
const s = props.size + 'rpx'
|
||||
return { width: s, height: s }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.svg-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
.svg-icon-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
95
client/src/components/TransactionItem/TransactionItem.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<view class="tx-item" @tap="$emit('tap', item)" @longpress="$emit('longpress', item)">
|
||||
<view class="icon-wrap" :style="{ background: item.category_color + '20' }">
|
||||
<text class="icon-text" :style="{ color: item.category_color }">{{ item.category_name?.[0] || '?' }}</text>
|
||||
</view>
|
||||
<view class="info">
|
||||
<text class="name">{{ item.note || item.category_name || '未分类' }}</text>
|
||||
<text class="meta">{{ item.category_name || '未分类' }}{{ hideDate ? '' : ' · ' + formatDate(item.date) }}</text>
|
||||
</view>
|
||||
<text class="amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatAmount, formatDate } from '@/utils/format'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
item: {
|
||||
id: number
|
||||
amount: number
|
||||
type: 'expense' | 'income'
|
||||
note?: string
|
||||
date: string
|
||||
category_name?: string
|
||||
category_icon?: string
|
||||
category_color?: string
|
||||
}
|
||||
hideDate?: boolean
|
||||
}>(), { hideDate: false })
|
||||
|
||||
defineEmits(['tap', 'longpress'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tx-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 0;
|
||||
gap: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:active {
|
||||
background: #FFF0E6;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-wrap {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
|
||||
&.expense { color: #2D1B1B; }
|
||||
&.income { color: #7BC67E; }
|
||||
}
|
||||
</style>
|
||||
14
client/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
// WeChat Mini Program global
|
||||
declare const wx: UniApp.Wx & {
|
||||
env: {
|
||||
USER_DATA_PATH: string
|
||||
}
|
||||
}
|
||||
10
client/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
return { app }
|
||||
}
|
||||
27
client/src/manifest.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "小菜记账",
|
||||
"appid": "wx854804dff2928c53",
|
||||
"description": "可爱高级的个人记账小程序",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"mp-weixin": {
|
||||
"appid": "wx854804dff2928c53",
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"es6": true,
|
||||
"postcss": true,
|
||||
"minified": true
|
||||
},
|
||||
"usingComponents": true,
|
||||
"optimization": {
|
||||
"subPackages": true
|
||||
}
|
||||
},
|
||||
"h5": {
|
||||
"title": "小菜记账",
|
||||
"router": {
|
||||
"mode": "hash"
|
||||
}
|
||||
}
|
||||
}
|
||||
95
client/src/pages.json
Normal file
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "小菜记账",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/add/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "记一笔"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/stats/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "统计分析",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/bills/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "全部账单",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "我的"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/category-manage/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "分类管理"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/budget/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "预算设置"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "小菜记账",
|
||||
"navigationBarBackgroundColor": "#FFF8F0",
|
||||
"backgroundColor": "#FFF8F0",
|
||||
"backgroundTextStyle": "dark"
|
||||
},
|
||||
"tabBar": {
|
||||
"color": "#BFB3B3",
|
||||
"selectedColor": "#FF8C69",
|
||||
"backgroundColor": "#FFF8F0",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/index/index",
|
||||
"text": "首页",
|
||||
"iconPath": "static/icons/home.png",
|
||||
"selectedIconPath": "static/icons/home-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/stats/index",
|
||||
"text": "统计",
|
||||
"iconPath": "static/icons/chart.png",
|
||||
"selectedIconPath": "static/icons/chart-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/bills/index",
|
||||
"text": "账单",
|
||||
"iconPath": "static/icons/bill.png",
|
||||
"selectedIconPath": "static/icons/bill-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/index",
|
||||
"text": "我的",
|
||||
"iconPath": "static/icons/user.png",
|
||||
"selectedIconPath": "static/icons/user-active.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
361
client/src/pages/add/index.vue
Normal 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>
|
||||
285
client/src/pages/bills/index.vue
Normal 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>
|
||||
335
client/src/pages/budget/index.vue
Normal 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>
|
||||
227
client/src/pages/category-manage/index.vue
Normal 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>
|
||||
354
client/src/pages/index/index.vue
Normal 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>
|
||||
387
client/src/pages/profile/index.vue
Normal 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>
|
||||
371
client/src/pages/stats/index.vue
Normal 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>
|
||||
BIN
client/src/static/icons/bill-active.png
Normal file
|
After Width: | Height: | Size: 286 B |
BIN
client/src/static/icons/bill.png
Normal file
|
After Width: | Height: | Size: 288 B |
BIN
client/src/static/icons/chart-active.png
Normal file
|
After Width: | Height: | Size: 240 B |
BIN
client/src/static/icons/chart.png
Normal file
|
After Width: | Height: | Size: 241 B |
BIN
client/src/static/icons/home-active.png
Normal file
|
After Width: | Height: | Size: 288 B |
BIN
client/src/static/icons/home.png
Normal file
|
After Width: | Height: | Size: 290 B |
BIN
client/src/static/icons/user-active.png
Normal file
|
After Width: | Height: | Size: 293 B |
BIN
client/src/static/icons/user.png
Normal file
|
After Width: | Height: | Size: 294 B |
40
client/src/stores/budget.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { getCurrentMonth } from '@/utils/format'
|
||||
|
||||
export interface Budget {
|
||||
id: number
|
||||
amount: number
|
||||
month: string
|
||||
}
|
||||
|
||||
export const useBudgetStore = defineStore('budget', () => {
|
||||
const budget = ref<Budget | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchBudget(month?: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
budget.value = await request<Budget>({
|
||||
url: '/budget',
|
||||
data: { month: month || getCurrentMonth() }
|
||||
})
|
||||
} catch {
|
||||
budget.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setBudget(amount: number, month?: string) {
|
||||
await request({
|
||||
url: '/budget',
|
||||
method: 'POST',
|
||||
data: { amount, month: month || getCurrentMonth() }
|
||||
})
|
||||
await fetchBudget(month)
|
||||
}
|
||||
|
||||
return { budget, loading, fetchBudget, setBudget }
|
||||
})
|
||||
54
client/src/stores/category.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
icon: string
|
||||
color: string
|
||||
type: 'expense' | 'income'
|
||||
sort_order: number
|
||||
is_custom: number
|
||||
}
|
||||
|
||||
export const useCategoryStore = defineStore('category', () => {
|
||||
const categories = ref<Category[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchCategories() {
|
||||
loading.value = true
|
||||
try {
|
||||
categories.value = await request<Category[]>({ url: '/categories' })
|
||||
} catch {
|
||||
categories.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getByType(type: 'expense' | 'income') {
|
||||
return categories.value.filter(c => c.type === type)
|
||||
}
|
||||
|
||||
function getById(id: number) {
|
||||
return categories.value.find(c => c.id === id)
|
||||
}
|
||||
|
||||
async function addCategory(data: { name: string; icon: string; color: string; type: 'expense' | 'income' }) {
|
||||
const result = await request<{ id: number }>({
|
||||
url: '/categories',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
await fetchCategories()
|
||||
return result.id
|
||||
}
|
||||
|
||||
async function deleteCategory(id: number) {
|
||||
await request({ url: `/categories/${id}`, method: 'DELETE' })
|
||||
await fetchCategories()
|
||||
}
|
||||
|
||||
return { categories, loading, fetchCategories, getByType, getById, addCategory, deleteCategory }
|
||||
})
|
||||
66
client/src/stores/stats.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { request } from '@/utils/request'
|
||||
import { getCurrentMonth } from '@/utils/format'
|
||||
|
||||
export interface Overview {
|
||||
expense: number
|
||||
income: number
|
||||
count: number
|
||||
daily: number
|
||||
}
|
||||
|
||||
export interface CategoryStat {
|
||||
id: number
|
||||
name: string
|
||||
icon: string
|
||||
color: string
|
||||
amount: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
date: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
export const useStatsStore = defineStore('stats', () => {
|
||||
const overview = ref<Overview>({ expense: 0, income: 0, count: 0, daily: 0 })
|
||||
const categoryStats = ref<CategoryStat[]>([])
|
||||
const trendData = ref<TrendPoint[]>([])
|
||||
|
||||
async function fetchOverview(month?: string) {
|
||||
try {
|
||||
overview.value = await request<Overview>({
|
||||
url: '/stats/overview',
|
||||
data: { month: month || getCurrentMonth() }
|
||||
})
|
||||
} catch {
|
||||
overview.value = { expense: 0, income: 0, count: 0, daily: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCategoryStats(month?: string, type: string = 'expense') {
|
||||
try {
|
||||
categoryStats.value = await request<CategoryStat[]>({
|
||||
url: '/stats/category',
|
||||
data: { month: month || getCurrentMonth(), type }
|
||||
})
|
||||
} catch {
|
||||
categoryStats.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTrend(month?: string, type: string = 'expense') {
|
||||
try {
|
||||
trendData.value = await request<TrendPoint[]>({
|
||||
url: '/stats/trend',
|
||||
data: { month: month || getCurrentMonth(), type }
|
||||
})
|
||||
} catch {
|
||||
trendData.value = []
|
||||
}
|
||||
}
|
||||
|
||||
return { overview, categoryStats, trendData, fetchOverview, fetchCategoryStats, fetchTrend }
|
||||
})
|
||||
74
client/src/stores/transaction.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
export interface Transaction {
|
||||
id: number
|
||||
amount: number
|
||||
type: 'expense' | 'income'
|
||||
category_id: number
|
||||
note?: string
|
||||
date: string
|
||||
category_name?: string
|
||||
category_icon?: string
|
||||
category_color?: string
|
||||
}
|
||||
|
||||
export interface TransactionList {
|
||||
list: Transaction[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export const useTransactionStore = defineStore('transaction', () => {
|
||||
const transactions = ref<Transaction[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
|
||||
async function fetchTransactions(params?: { type?: string; startDate?: string; endDate?: string; page?: number; pageSize?: number }) {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await request<TransactionList>({
|
||||
url: '/transactions',
|
||||
data: params
|
||||
})
|
||||
transactions.value = data.list
|
||||
total.value = data.total
|
||||
currentPage.value = data.page
|
||||
} catch {
|
||||
transactions.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addTransaction(data: { amount: number; type: string; category_id?: number; note?: string; date: string }) {
|
||||
const result = await request<{ id: number }>({
|
||||
url: '/transactions',
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
return result.id
|
||||
}
|
||||
|
||||
async function updateTransaction(id: number, data: { amount: number; type: string; category_id?: number; note?: string; date: string }) {
|
||||
await request({ url: `/transactions/${id}`, method: 'PUT', data })
|
||||
}
|
||||
|
||||
async function deleteTransaction(id: number) {
|
||||
await request({ url: `/transactions/${id}`, method: 'DELETE' })
|
||||
}
|
||||
|
||||
async function fetchTransactionById(id: number): Promise<Transaction | undefined> {
|
||||
try {
|
||||
return await request<Transaction>({ url: `/transactions/${id}` })
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return { transactions, total, loading, currentPage, fetchTransactions, addTransaction, updateTransaction, deleteTransaction, fetchTransactionById }
|
||||
})
|
||||
35
client/src/uni.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
/* Design Tokens */
|
||||
$primary: #FF8C69;
|
||||
$primary-light: #FFB89A;
|
||||
$primary-dark: #E67355;
|
||||
$secondary: #6C9BCF;
|
||||
$accent: #FFD166;
|
||||
$bg: #FFF8F0;
|
||||
$surface: #FFFFFF;
|
||||
$surface-warm: #FFF0E6;
|
||||
$text: #2D1B1B;
|
||||
$text-sec: #8B7E7E;
|
||||
$text-muted: #BFB3B3;
|
||||
$success: #7BC67E;
|
||||
$warning: #FFB347;
|
||||
$danger: #FF6B6B;
|
||||
$border: #F0E0D6;
|
||||
|
||||
$shadow-clay: 4px 4px 12px rgba(45, 27, 27, 0.08), inset -2px -2px 6px rgba(45, 27, 27, 0.04);
|
||||
$shadow-clay-hover: 6px 6px 16px rgba(45, 27, 27, 0.12), inset -2px -2px 6px rgba(45, 27, 27, 0.04);
|
||||
$shadow-clay-pressed: inset 2px 2px 6px rgba(45, 27, 27, 0.1);
|
||||
|
||||
$radius-sm: 8rpx;
|
||||
$radius-md: 12rpx;
|
||||
$radius-lg: 16rpx;
|
||||
$radius-xl: 20rpx;
|
||||
$radius-full: 9999rpx;
|
||||
|
||||
/* uCharts theme */
|
||||
page {
|
||||
--u-charts-color-0: #FF8C69;
|
||||
--u-charts-color-1: #FFD166;
|
||||
--u-charts-color-2: #6C9BCF;
|
||||
--u-charts-color-3: #7BC67E;
|
||||
--u-charts-color-4: #BFB3B3;
|
||||
}
|
||||
37
client/src/utils/format.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export function formatAmount(fen: number): string {
|
||||
const yuan = (fen / 100).toFixed(2)
|
||||
return '¥ ' + yuan.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
|
||||
export function formatDate(date: string): string {
|
||||
// Parse YYYY-MM-DD as local time to avoid timezone off-by-one
|
||||
const parts = date.split('-').map(Number)
|
||||
const d = new Date(parts[0], parts[1] - 1, parts[2])
|
||||
const now = new Date()
|
||||
// Reset time portion for accurate day diff
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const target = new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
const diff = Math.floor((today.getTime() - target.getTime()) / 86400000)
|
||||
|
||||
if (diff === 0) return '今天'
|
||||
if (diff === 1) return '昨天'
|
||||
if (diff === 2) return '前天'
|
||||
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`
|
||||
}
|
||||
|
||||
export function formatMonth(date: string): string {
|
||||
const [year, month] = date.split('-')
|
||||
return `${year}年${parseInt(month)}月`
|
||||
}
|
||||
|
||||
export function getCurrentMonth(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatAmountRaw(fen: number): string {
|
||||
const yuan = (fen / 100).toFixed(2)
|
||||
return yuan.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
}
|
||||
|
||||
90
client/src/utils/request.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// API 基础地址 - 上线后改成真实域名
|
||||
let BASE_URL = 'http://106.14.208.43:3000/api'
|
||||
if (typeof wx !== 'undefined' && wx.getSystemInfoSync) {
|
||||
// 微信小程序环境使用 HTTPS 域名(上线后配置)
|
||||
// BASE_URL = 'https://your-domain.com/api'
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
data?: any
|
||||
}
|
||||
|
||||
let isRedirectingToLogin = false
|
||||
|
||||
export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('xc:token')
|
||||
uni.request({
|
||||
url: BASE_URL + options.url,
|
||||
method: options.method || 'GET',
|
||||
data: options.data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||
},
|
||||
success: (res: any) => {
|
||||
const { statusCode, data } = res
|
||||
|
||||
// HTTP 401 - token invalid or expired
|
||||
if (statusCode === 401) {
|
||||
uni.removeStorageSync('xc:token')
|
||||
uni.removeStorageSync('xc:userId')
|
||||
if (!isRedirectingToLogin) {
|
||||
isRedirectingToLogin = true
|
||||
uni.showToast({ title: '请重新登录', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
// #ifdef H5
|
||||
window.location.reload()
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
// 重新触发 wx.login
|
||||
reLogin()
|
||||
// #endif
|
||||
isRedirectingToLogin = false
|
||||
}, 1500)
|
||||
}
|
||||
reject({ code: 40100, message: '未登录' })
|
||||
return
|
||||
}
|
||||
|
||||
// Business logic success
|
||||
if (data.code === 0) {
|
||||
resolve(data.data)
|
||||
} else {
|
||||
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
|
||||
reject(data)
|
||||
}
|
||||
},
|
||||
fail: (err: any) => {
|
||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 微信小程序自动重新登录
|
||||
function reLogin() {
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: async (loginRes) => {
|
||||
if (loginRes.code) {
|
||||
try {
|
||||
const data = await request<{ token: string; userId: number }>({
|
||||
url: '/auth/login',
|
||||
method: 'POST',
|
||||
data: { code: loginRes.code }
|
||||
})
|
||||
uni.setStorageSync('xc:token', data.token)
|
||||
uni.setStorageSync('xc:userId', data.userId)
|
||||
} catch (e) {
|
||||
console.error('[Auth] Re-login failed:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// #endif
|
||||
}
|
||||
10
client/src/utils/system.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
let statusBarHeight = 0
|
||||
|
||||
try {
|
||||
const info = uni.getSystemInfoSync()
|
||||
statusBarHeight = info.statusBarHeight || 0
|
||||
} catch {
|
||||
statusBarHeight = 20
|
||||
}
|
||||
|
||||
export { statusBarHeight }
|
||||
19
client/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["esnext", "dom"],
|
||||
"types": ["@dcloudio/types"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
6
client/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [uni()],
|
||||
})
|
||||