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