feat: 小菜记账 v1.0 - 完整功能实现
核心功能: - 记账CRUD(支出/收入/分类/备注/日期) - 统计分析(概览/分类占比/每日趋势) - 预算管理(按月设置/进度条/超支提醒) - 数据导出CSV 安全与认证: - HMAC-SHA256签名token认证 - 用户数据隔离 - 输入验证与错误处理 - CORS配置 前端优化: - 骨架屏加载 - 账单按日期分组 - 预算页面重构(快捷预设+Numpad) - SvgIcon组件(H5+微信双端适配) - 下拉刷新 后端优化: - 共享日期工具函数 - 数据库连接池优化 - 健康检查端点 - 优雅关闭处理 技术栈: - 前端:Uni-app (Vue 3 + Pinia) - 后端:Node.js + Express + MySQL
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
server/dist/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
**/.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Uni-app
|
||||||
|
client/dist/
|
||||||
|
client/unpackage/
|
||||||
|
|
||||||
|
# Deploy (keep scripts, ignore generated certs)
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
.playwright-mcp/
|
||||||
150
CLAUDE.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# 小菜记账 (Xiaocai Accounting)
|
||||||
|
|
||||||
|
可爱 × 高级的个人记账小程序。让记账从负担变成享受。
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
### 前端 (client/)
|
||||||
|
- **框架**: Uni-app (Vue 3 + Composition API)
|
||||||
|
- **状态管理**: Pinia
|
||||||
|
- **样式**: SCSS (Claymorphism 风格)
|
||||||
|
- **图标**: Lucide Icons (SVG 组件,禁止 emoji)
|
||||||
|
- **图表**: uCharts (小程序端)
|
||||||
|
- **目标平台**: 微信小程序 / H5
|
||||||
|
|
||||||
|
### 后端 (server/)
|
||||||
|
- **运行时**: Node.js + Express
|
||||||
|
- **数据库**: MySQL 8.0 (mysql2 连接池)
|
||||||
|
- **认证**: HMAC-SHA256 签名 token,30 天过期
|
||||||
|
- **CORS**: 已配置,支持 credentials
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
完整设计规格: `docs/superpowers/specs/2026-05-27-xiaocai-design.md`
|
||||||
|
|
||||||
|
### 核心设计语言: Claymorphism (软3D粘土风格)
|
||||||
|
|
||||||
|
- **主色调**: 珊瑚橙 `#FF8C69` — 温暖、活泼、有亲和力
|
||||||
|
- **背景色**: 暖奶油 `#FFF8F0` — 比纯白更柔和
|
||||||
|
- **文字色**: 暖深棕 `#2D1B1B` — 不用纯黑
|
||||||
|
- **圆角**: 卡片 20px, 按钮 14-16px
|
||||||
|
- **阴影**: 双层 (外阴影 + 内阴影,模拟粘土质感)
|
||||||
|
|
||||||
|
### 字体
|
||||||
|
- 英文: Fredoka (标题) + Nunito (正文)
|
||||||
|
- 中文: PingFang SC / 系统默认
|
||||||
|
- 数字和金额使用 Fredoka,等宽数字
|
||||||
|
|
||||||
|
### 关键原则
|
||||||
|
- 最小触摸目标 44×44px
|
||||||
|
- 禁止使用 emoji 作为图标,统一使用 SvgIcon 组件
|
||||||
|
- 尊重 prefers-reduced-motion
|
||||||
|
- 文字对比度 ≥ 4.5:1 (WCAG AA)
|
||||||
|
|
||||||
|
## Project Conventions
|
||||||
|
|
||||||
|
### 命名
|
||||||
|
- 目录: kebab-case (`category-manage/`, `transaction-item/`)
|
||||||
|
- 组件: PascalCase (`AmountCard.vue`, `TransactionItem.vue`)
|
||||||
|
- 变量/函数: camelCase (`totalExpense`, `formatAmount()`)
|
||||||
|
- 常量: UPPER_SNAKE_CASE (`MAX_BUDGET`, `STORAGE_KEY`)
|
||||||
|
- CSS 类: BEM 或 Tailwind 风格
|
||||||
|
|
||||||
|
### 文件组织
|
||||||
|
```
|
||||||
|
client/src/
|
||||||
|
├── pages/ # 页面 (uni-app 规范)
|
||||||
|
│ ├── index/ # 首页仪表盘
|
||||||
|
│ ├── add/ # 添加/编辑记录
|
||||||
|
│ ├── stats/ # 统计分析
|
||||||
|
│ ├── bills/ # 全部账单 (下拉刷新/上拉加载)
|
||||||
|
│ ├── profile/ # 个人设置
|
||||||
|
│ └── category-manage/ # 分类管理
|
||||||
|
├── components/ # 公共组件
|
||||||
|
│ ├── BudgetBar/ # 预算进度条
|
||||||
|
│ ├── CategoryIcon/ # 分类图标 (首字+颜色)
|
||||||
|
│ ├── Numpad/ # 数字键盘
|
||||||
|
│ ├── SvgIcon/ # SVG 图标 (base64 data URI)
|
||||||
|
│ └── TransactionItem/ # 交易列表项
|
||||||
|
├── stores/ # Pinia stores
|
||||||
|
│ ├── transaction.ts # 交易 CRUD
|
||||||
|
│ ├── category.ts # 分类 CRUD
|
||||||
|
│ ├── budget.ts # 预算管理
|
||||||
|
│ └── stats.ts # 统计概览
|
||||||
|
├── utils/
|
||||||
|
│ ├── format.ts # 金额/日期格式化
|
||||||
|
│ ├── request.ts # HTTP 请求封装 (401 处理/自动重登录)
|
||||||
|
│ └── system.ts # 系统信息 (状态栏高度等)
|
||||||
|
└── static/icons/ # tabBar 图标 (PNG)
|
||||||
|
|
||||||
|
server/src/
|
||||||
|
├── index.ts # Express 入口 (CORS, JSON 解析)
|
||||||
|
├── middleware/auth.ts # HMAC token 验证中间件
|
||||||
|
├── routes/
|
||||||
|
│ ├── auth.ts # 登录 / token 签发
|
||||||
|
│ ├── transaction.ts # 交易 CRUD (用户隔离)
|
||||||
|
│ ├── category.ts # 分类 CRUD (默认+自定义)
|
||||||
|
│ ├── budget.ts # 预算 CRUD (按月)
|
||||||
|
│ └── stats.ts # 统计查询
|
||||||
|
└── db/
|
||||||
|
├── connection.ts # MySQL 连接池
|
||||||
|
├── init.ts # 建表 + seed (multipleStatements)
|
||||||
|
├── schema.sql # 表结构
|
||||||
|
└── seed.sql # 默认分类数据
|
||||||
|
```
|
||||||
|
|
||||||
|
### 金额处理
|
||||||
|
- 存储单位: 分 (整数,避免浮点精度问题)
|
||||||
|
- 显示单位: 元 (保留两位小数)
|
||||||
|
- 格式化: `¥ 1,234.56` (¥ 后空格,千分位分隔)
|
||||||
|
- 收入前缀 `+`,支出前缀 `-`
|
||||||
|
- 使用 `formatAmount()` 带 ¥ 前缀,`formatAmountRaw()` 纯数字
|
||||||
|
|
||||||
|
### 数据持久化
|
||||||
|
- 前端本地存储 Key: `xc:token`, `xc:userId`
|
||||||
|
- 后端 MySQL 数据库
|
||||||
|
- API 基础地址: `request.ts` 中的 `BASE_URL`
|
||||||
|
|
||||||
|
### 认证流程
|
||||||
|
- H5: 页面加载时自动 login 获取 token
|
||||||
|
- 微信小程序: `App.vue` onLaunch 调用 `wx.login` 获取 code,换 token
|
||||||
|
- Token: HMAC-SHA256 签名,30 天过期
|
||||||
|
- 401 处理: 自动清除 token,小程序触发 reLogin,H5 刷新页面
|
||||||
|
|
||||||
|
### Git
|
||||||
|
- 分支: `master` → `main` (注意远程)
|
||||||
|
- Commit 风格: 中文描述,简洁明了
|
||||||
|
- `feat: 添加记账页面`
|
||||||
|
- `fix: 修复金额计算精度问题`
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 前端开发 (H5)
|
||||||
|
cd client && npm run dev:h5
|
||||||
|
|
||||||
|
# 前端开发 (微信小程序)
|
||||||
|
cd client && npm run dev:mp-weixin
|
||||||
|
|
||||||
|
# 前端构建
|
||||||
|
cd client && npm run build:mp-weixin
|
||||||
|
|
||||||
|
# 后端开发
|
||||||
|
cd server && npm run dev
|
||||||
|
|
||||||
|
# 后端构建
|
||||||
|
cd server && npm run build
|
||||||
|
|
||||||
|
# 数据库初始化
|
||||||
|
cd server && npm run db:init
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
- 服务器: `106.14.208.43:3000`
|
||||||
|
- MySQL: 本地 3306 端口
|
||||||
|
- 微信小程序上线需配置 HTTPS 域名 (替换 `request.ts` 中的 `BASE_URL`)
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
@DEV.md
|
||||||
42
DEV.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
## 通用原则
|
||||||
|
|
||||||
|
- 优先保持现有代码风格与架构模式,不要无理由重构。
|
||||||
|
- 任何功能或修复变更,需确保现有测试通过,必要时补充新测试。
|
||||||
|
- 所有输出(代码、注释、文档、提交信息)默认使用**简体中文**,专有名词或技术术语可保留英文。
|
||||||
|
|
||||||
|
## 编码规范
|
||||||
|
|
||||||
|
### 文件与编码
|
||||||
|
- 所有文件必须保持 UTF-8编码。
|
||||||
|
- 修改包含中文的文件时,不得造成中文注释、中文文案、错误提示乱码。
|
||||||
|
- 如果发现文件已有乱码,不要擅自猜测业务含义,应标记出来让人工确认。
|
||||||
|
|
||||||
|
## 测试要求
|
||||||
|
|
||||||
|
- 新增功能必须编写对应的单元测试或集成测试。
|
||||||
|
- 修复 Bug 时,先添加能复现问题的测试用例,再修复。
|
||||||
|
- 测试覆盖率不应低于当前项目基线(若未设置,尽量保持 ≥80%)。
|
||||||
|
- 测试命名清晰,能描述场景,如 should return error when user not found。
|
||||||
|
|
||||||
|
## 注释与文档
|
||||||
|
|
||||||
|
### 注释要求
|
||||||
|
- **保留**已有的重要注释(如文件头说明、JSDoc、业务解释)。
|
||||||
|
- 新增复杂逻辑、公共 API、关键算法时,必须补充**简洁的中文注释**,解释“为什么这么做”而非“做了什么”。
|
||||||
|
- 不要为了注释而注释,避免冗余的废话(例如 `i++; // 自增 i`)。
|
||||||
|
- 多行 JSDoc/文档注释保持可读性,**不要压缩成单行**。
|
||||||
|
|
||||||
|
## Never 规则
|
||||||
|
|
||||||
|
- Never 修改 dist/ 目录
|
||||||
|
- Never 使用内联样式,除非需要动态计算
|
||||||
|
- Never 在渲染路径中执行耗时操作
|
||||||
|
- Never 在列表渲染中省略 key 属性
|
||||||
|
- Never 修改 lock 文件
|
||||||
|
- Never 使用 git stash
|
||||||
|
- Never 切换分支
|
||||||
|
- Never 过度封装,保持代码简洁明了
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- 代码提交前检查代码,包括但不限于代码格式、注释、测试覆盖率等。
|
||||||
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()],
|
||||||
|
})
|
||||||
5295
client/yarn.lock
Normal file
14
deploy/backup.sh
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 数据库备份脚本 (每天执行)
|
||||||
|
|
||||||
|
BACKUP_DIR="/var/backups/xiaocai"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
mysqldump -u xiaocai -pxiaocai123 xiaocai | gzip > "$BACKUP_DIR/xiaocai_$DATE.sql.gz"
|
||||||
|
|
||||||
|
# 保留最近 7 天的备份
|
||||||
|
find "$BACKUP_DIR" -name "*.sql.gz" -mtime +7 -delete
|
||||||
|
|
||||||
|
echo "备份完成: $BACKUP_DIR/xiaocai_$DATE.sql.gz"
|
||||||
56
deploy/deploy.sh
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 小菜记账部署脚本
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SERVER_DIR="/var/www/xiaocai"
|
||||||
|
LOG_DIR="/var/log/xiaocai"
|
||||||
|
|
||||||
|
echo "=== 开始部署 ==="
|
||||||
|
|
||||||
|
# 进入项目目录
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# 构建服务端
|
||||||
|
echo "1/4 构建服务端..."
|
||||||
|
cd server
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
# 复制文件到服务器目录
|
||||||
|
echo "2/4 复制文件..."
|
||||||
|
cp -r server/dist "$SERVER_DIR/"
|
||||||
|
cp -r server/node_modules "$SERVER_DIR/"
|
||||||
|
cp server/package.json "$SERVER_DIR/"
|
||||||
|
cp deploy/ecosystem.config.js "$SERVER_DIR/"
|
||||||
|
|
||||||
|
# 初始化数据库
|
||||||
|
echo "3/4 初始化数据库..."
|
||||||
|
cd "$SERVER_DIR"
|
||||||
|
mysql -u xiaocai -pxiaocai123 xiaocai < dist/db/schema.sql
|
||||||
|
mysql -u xiaocai -pxiaocai123 xiaocai < dist/db/seed.sql 2>/dev/null || true
|
||||||
|
cd -
|
||||||
|
|
||||||
|
# 配置 Nginx
|
||||||
|
echo "4/4 配置 Nginx..."
|
||||||
|
sudo cp deploy/nginx.conf /etc/nginx/sites-available/xiaocai
|
||||||
|
sudo ln -sf /etc/nginx/sites-available/xiaocai /etc/nginx/sites-enabled/
|
||||||
|
sudo rm -f /etc/nginx/sites-enabled/default
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
echo "重启 PM2 服务..."
|
||||||
|
cd "$SERVER_DIR"
|
||||||
|
pm2 delete xiaocai-server 2>/dev/null || true
|
||||||
|
pm2 start ecosystem.config.js
|
||||||
|
pm2 save
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 部署完成 ==="
|
||||||
|
echo "服务地址: http://your-domain.com"
|
||||||
|
echo "API 健康检查: http://your-domain.com/api/health"
|
||||||
|
echo ""
|
||||||
|
echo "查看日志: pm2 logs xiaocai-server"
|
||||||
|
echo "查看状态: pm2 status"
|
||||||
19
deploy/ecosystem.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'xiaocai-server',
|
||||||
|
script: './dist/index.js',
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '300M',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3000
|
||||||
|
},
|
||||||
|
error_file: '/var/log/xiaocai/error.log',
|
||||||
|
out_file: '/var/log/xiaocai/out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
26
deploy/nginx.conf
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# 小程序 API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSL 配置 (上线前取消注释)
|
||||||
|
# listen 443 ssl;
|
||||||
|
# ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
|
||||||
|
# HTTP 重定向到 HTTPS
|
||||||
|
# return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
57
deploy/setup.sh
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# 小菜记账服务器初始化脚本
|
||||||
|
# 适用于 Ubuntu 22.04 LTS + 阿里云 2H2G
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== 小菜记账服务器初始化 ==="
|
||||||
|
|
||||||
|
# 更新系统
|
||||||
|
echo "1/8 更新系统包..."
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# 安装 Node.js 20
|
||||||
|
echo "2/8 安装 Node.js 20..."
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
|
||||||
|
# 安装 MySQL 8.0
|
||||||
|
echo "3/8 安装 MySQL 8.0..."
|
||||||
|
sudo apt install -y mysql-server
|
||||||
|
|
||||||
|
# 安全配置 MySQL
|
||||||
|
echo "4/8 配置 MySQL..."
|
||||||
|
sudo mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root_password';"
|
||||||
|
sudo mysql -e "CREATE DATABASE IF NOT EXISTS xiaocai DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
|
sudo mysql -e "CREATE USER IF NOT EXISTS 'xiaocai'@'localhost' IDENTIFIED BY 'xiaocai123';"
|
||||||
|
sudo mysql -e "GRANT ALL PRIVILEGES ON xiaocai.* TO 'xiaocai'@'localhost';"
|
||||||
|
sudo mysql -e "FLUSH PRIVILEGES;"
|
||||||
|
|
||||||
|
# 安装 Nginx
|
||||||
|
echo "5/8 安装 Nginx..."
|
||||||
|
sudo apt install -y nginx
|
||||||
|
|
||||||
|
# 安装 PM2
|
||||||
|
echo "6/8 安装 PM2..."
|
||||||
|
sudo npm install -g pm2
|
||||||
|
|
||||||
|
# 创建部署目录
|
||||||
|
echo "7/8 创建部署目录..."
|
||||||
|
sudo mkdir -p /var/www/xiaocai
|
||||||
|
sudo mkdir -p /var/log/xiaocai
|
||||||
|
sudo chown -R $USER:$USER /var/www/xiaocai
|
||||||
|
|
||||||
|
# 配置防火墙
|
||||||
|
echo "8/8 配置防火墙..."
|
||||||
|
sudo ufw allow 22/tcp
|
||||||
|
sudo ufw allow 80/tcp
|
||||||
|
sudo ufw allow 443/tcp
|
||||||
|
sudo ufw --force enable
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== 服务器初始化完成 ==="
|
||||||
|
echo "MySQL root 密码: root_password"
|
||||||
|
echo "MySQL xiaocai 密码: xiaocai123"
|
||||||
|
echo "请修改 deploy/ecosystem.config.js 中的数据库密码"
|
||||||
|
echo ""
|
||||||
|
echo "下一步: ./deploy.sh"
|
||||||
490
docs/development-plan.md
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
# 小菜记账 — 小程序开发方案
|
||||||
|
|
||||||
|
> 版本: v1.0 | 日期: 2026-05-27
|
||||||
|
> UI 原型稿: `docs/mockup/index.html`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、项目概述
|
||||||
|
|
||||||
|
| 项目 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 产品名称 | 小菜记账 |
|
||||||
|
| 产品类型 | 个人记账微信小程序 |
|
||||||
|
| AppID | wx854804dff2928c53 |
|
||||||
|
| 设计风格 | Claymorphism (粘土风格) |
|
||||||
|
| 目标用户 | 18-35岁年轻人,注重生活品质 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、技术架构
|
||||||
|
|
||||||
|
### 前端 (小程序)
|
||||||
|
|
||||||
|
| 技术 | 选型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 框架 | Uni-app (Vue 3 + Composition API) | 一套代码,跨端运行 |
|
||||||
|
| 状态管理 | Pinia | 轻量、TypeScript 友好 |
|
||||||
|
| 样式 | UnoCSS + SCSS | 原子化 + 变量系统 |
|
||||||
|
| 图标 | Lucide Icons (SVG) | 统一图标集 |
|
||||||
|
| 图表 | uCharts (qiun-data-charts) | 小程序端图表组件 |
|
||||||
|
| 字体 | Fredoka + Nunito | 通过 @font-face 加载 |
|
||||||
|
|
||||||
|
### 后端 (阿里云 2H2G)
|
||||||
|
|
||||||
|
| 技术 | 选型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 运行时 | Node.js 20 LTS | 轻量高效 |
|
||||||
|
| 框架 | Express.js | 简单可靠 |
|
||||||
|
| 数据库 | MySQL 8.0 | 成熟稳定,生态丰富 |
|
||||||
|
| ORM | Prisma | 类型安全,迁移方便 |
|
||||||
|
| 缓存 | 无 (内存足够) | 2G 内存可支撑 |
|
||||||
|
| 部署 | PM2 + Nginx | 进程管理 + 反向代理 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、功能清单
|
||||||
|
|
||||||
|
### P0 (MVP - 第一版)
|
||||||
|
|
||||||
|
| 模块 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 记账 | 添加支出/收入 | 金额、分类、日期、备注 |
|
||||||
|
| 记账 | 分类选择 | 预设 12 个分类 + 自定义 |
|
||||||
|
| 首页 | 月度总览 | 本月支出、预算进度 |
|
||||||
|
| 首页 | 近期账单 | 最近 20 条交易 |
|
||||||
|
| 统计 | 分类占比 | 环形图 |
|
||||||
|
| 统计 | 每日趋势 | 折线图 |
|
||||||
|
| 我的 | 预算设置 | 月度预算 |
|
||||||
|
| 我的 | 数据导出 | CSV 格式 |
|
||||||
|
|
||||||
|
### P1 (第二版)
|
||||||
|
|
||||||
|
| 模块 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 记账 | 图片识别 | 拍照识别金额 |
|
||||||
|
| 统计 | 周/月/年切换 | 多维度分析 |
|
||||||
|
| 我的 | 数据备份 | 云同步 |
|
||||||
|
| 我的 | 深色模式 | 主题切换 |
|
||||||
|
|
||||||
|
### P2 (第三版)
|
||||||
|
|
||||||
|
| 模块 | 功能 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 高级 | 多账本 | 不同场景分类 |
|
||||||
|
| 高级 | 预算提醒 | 超支通知 |
|
||||||
|
| 高级 | 团队记账 | AA 记账 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、数据库设计
|
||||||
|
|
||||||
|
### MySQL 安装 (阿里云服务器)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 MySQL 8.0
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y mysql-server
|
||||||
|
|
||||||
|
# 安全配置
|
||||||
|
sudo mysql_secure_installation
|
||||||
|
|
||||||
|
# 创建数据库和用户
|
||||||
|
sudo mysql -u root -p
|
||||||
|
CREATE DATABASE xiaocai DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE USER 'xiaocai'@'localhost' IDENTIFIED BY 'your_password';
|
||||||
|
GRANT ALL PRIVILEGES ON xiaocai.* TO 'xiaocai'@'localhost';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
EXIT;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 核心表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 分类表
|
||||||
|
CREATE TABLE categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
icon VARCHAR(50) NOT NULL,
|
||||||
|
color VARCHAR(20) NOT NULL,
|
||||||
|
type ENUM('expense', 'income') NOT NULL,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
is_custom TINYINT(1) DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 交易表
|
||||||
|
CREATE TABLE transactions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
amount INT NOT NULL COMMENT '单位:分',
|
||||||
|
type ENUM('expense', 'income') NOT NULL,
|
||||||
|
category_id INT,
|
||||||
|
note VARCHAR(200),
|
||||||
|
date DATE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_date (date),
|
||||||
|
INDEX idx_type (type),
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 预算表
|
||||||
|
CREATE TABLE budgets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
amount INT NOT NULL COMMENT '单位:分',
|
||||||
|
month VARCHAR(7) NOT NULL COMMENT '格式:2026-05',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_month (month)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 用户表 (预留微信登录)
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
openid VARCHAR(100) UNIQUE,
|
||||||
|
session_key VARCHAR(100),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 预设分类数据
|
||||||
|
|
||||||
|
| 分类 | 图标 (Lucide) | 颜色 | 类型 |
|
||||||
|
|------|---------------|------|------|
|
||||||
|
| 餐饮 | Coffee | #FF8C69 | 支出 |
|
||||||
|
| 饮品 | Coffee | #FF8C69 | 支出 |
|
||||||
|
| 购物 | ShoppingBag | #FFB347 | 支出 |
|
||||||
|
| 交通 | Car | #6C9BCF | 支出 |
|
||||||
|
| 住房 | Home | #7BC67E | 支出 |
|
||||||
|
| 医疗 | Heart | #FF6B6B | 支出 |
|
||||||
|
| 教育 | BookOpen | #6C9BCF | 支出 |
|
||||||
|
| 娱乐 | Film | #B39DDB | 支出 |
|
||||||
|
| 服饰 | Shirt | #FFD166 | 支出 |
|
||||||
|
| 美容 | Smile | #FF8C69 | 支出 |
|
||||||
|
| 通讯 | Smartphone | #6C9BCF | 支出 |
|
||||||
|
| 其他 | MoreHorizontal | #BFB3B3 | 支出 |
|
||||||
|
| 工资 | DollarSign | #7BC67E | 收入 |
|
||||||
|
| 副业 | TrendingUp | #7BC67E | 收入 |
|
||||||
|
| 理财 | PieChart | #7BC67E | 收入 |
|
||||||
|
| 其他收入 | Plus | #BFB3B3 | 收入 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、API 设计
|
||||||
|
|
||||||
|
### 基础信息
|
||||||
|
|
||||||
|
- 基础 URL: `https://your-domain.com/api`
|
||||||
|
- 认证: 微信登录 (code2session)
|
||||||
|
- 响应格式: JSON
|
||||||
|
|
||||||
|
### 接口列表
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | /auth/login | 微信登录 |
|
||||||
|
| GET | /categories | 获取分类列表 |
|
||||||
|
| POST | /transactions | 添加交易 |
|
||||||
|
| GET | /transactions | 查询交易列表 |
|
||||||
|
| PUT | /transactions/:id | 更新交易 |
|
||||||
|
| DELETE | /transactions/:id | 删除交易 |
|
||||||
|
| GET | /stats/overview | 月度概览 |
|
||||||
|
| GET | /stats/category | 分类统计 |
|
||||||
|
| GET | /stats/trend | 每日趋势 |
|
||||||
|
| GET | /budget | 获取预算 |
|
||||||
|
| POST | /budget | 设置预算 |
|
||||||
|
| GET | /export/csv | 导出 CSV |
|
||||||
|
|
||||||
|
### 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 成功
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": { ... },
|
||||||
|
"message": "success"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失败
|
||||||
|
{
|
||||||
|
"code": 40001,
|
||||||
|
"data": null,
|
||||||
|
"message": "参数错误"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
xiaocai/
|
||||||
|
├── client/ # 小程序端
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ ├── index/ # 首页
|
||||||
|
│ │ │ ├── add/ # 记账
|
||||||
|
│ │ │ ├── stats/ # 统计
|
||||||
|
│ │ │ ├── bills/ # 账单
|
||||||
|
│ │ │ └── profile/ # 我的
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── AmountCard/
|
||||||
|
│ │ │ ├── TransactionItem/
|
||||||
|
│ │ │ ├── CategoryIcon/
|
||||||
|
│ │ │ ├── BudgetBar/
|
||||||
|
│ │ │ ├── BottomNav/
|
||||||
|
│ │ │ ├── Numpad/
|
||||||
|
│ │ │ └── Chart/
|
||||||
|
│ │ ├── stores/
|
||||||
|
│ │ │ ├── transaction.ts
|
||||||
|
│ │ │ ├── category.ts
|
||||||
|
│ │ │ └── budget.ts
|
||||||
|
│ │ ├── utils/
|
||||||
|
│ │ │ ├── format.ts
|
||||||
|
│ │ │ ├── storage.ts
|
||||||
|
│ │ │ └── request.ts
|
||||||
|
│ │ ├── static/
|
||||||
|
│ │ │ └── fonts/
|
||||||
|
│ │ ├── App.vue
|
||||||
|
│ │ ├── main.ts
|
||||||
|
│ │ ├── manifest.json
|
||||||
|
│ │ ├── pages.json
|
||||||
|
│ │ └── uni.scss
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── vite.config.ts
|
||||||
|
│
|
||||||
|
├── server/ # 后端
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── routes/
|
||||||
|
│ │ │ ├── auth.ts
|
||||||
|
│ │ │ ├── transaction.ts
|
||||||
|
│ │ │ ├── category.ts
|
||||||
|
│ │ │ ├── stats.ts
|
||||||
|
│ │ │ └── budget.ts
|
||||||
|
│ │ ├── db/
|
||||||
|
│ │ │ ├── schema.sql
|
||||||
|
│ │ │ └── seed.sql
|
||||||
|
│ │ ├── middleware/
|
||||||
|
│ │ │ └── auth.ts
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ ├── data/ # 数据库迁移文件
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── tsconfig.json
|
||||||
|
│
|
||||||
|
├── docs/ # 文档
|
||||||
|
│ ├── mockup/ # UI 原型
|
||||||
|
│ ├── ui-design.md # 设计规格
|
||||||
|
│ └── development-plan.md # 本文件
|
||||||
|
│
|
||||||
|
└── deploy/ # 部署脚本
|
||||||
|
├── nginx.conf
|
||||||
|
├── ecosystem.config.js # PM2 配置
|
||||||
|
└── setup.sh # 服务器初始化
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、开发计划
|
||||||
|
|
||||||
|
### 阶段一: 基础搭建 (第 1 周)
|
||||||
|
|
||||||
|
| 任务 | 说明 | 产出 |
|
||||||
|
|------|------|------|
|
||||||
|
| 项目初始化 | Uni-app 项目 + 后端项目 | 可运行的空项目 |
|
||||||
|
| 数据库设计 | MySQL 建表 + 种子数据 | 数据库就绪 |
|
||||||
|
| 微信登录 | code2session 对接 | 登录流程 |
|
||||||
|
| API 框架 | Express + 中间件 | API 可调用 |
|
||||||
|
|
||||||
|
### 阶段二: 核心功能 (第 2-3 周)
|
||||||
|
|
||||||
|
| 任务 | 说明 | 产出 |
|
||||||
|
|------|------|------|
|
||||||
|
| 记账功能 | 添加/编辑/删除交易 | 核心记账流程 |
|
||||||
|
| 分类管理 | 预设分类 + 自定义 | 12 个分类 |
|
||||||
|
| 首页展示 | 月度总览 + 账单列表 | 首页完整 |
|
||||||
|
| 预算功能 | 设置/展示预算 | 预算进度条 |
|
||||||
|
|
||||||
|
### 阶段三: 统计分析 (第 4 周)
|
||||||
|
|
||||||
|
| 任务 | 说明 | 产出 |
|
||||||
|
|------|------|------|
|
||||||
|
| 分类统计 | 环形图 + 排行 | 分类占比 |
|
||||||
|
| 趋势分析 | 折线图 | 每日趋势 |
|
||||||
|
| 数据导出 | CSV 导出 | 可导出数据 |
|
||||||
|
|
||||||
|
### 阶段四: 优化上线 (第 5 周)
|
||||||
|
|
||||||
|
| 任务 | 说明 | 产出 |
|
||||||
|
|------|------|------|
|
||||||
|
| UI 细节 | 动画、交互优化 | 精细化 |
|
||||||
|
| 性能优化 | 首屏加载、缓存 | 流畅体验 |
|
||||||
|
| 服务器部署 | Nginx + PM2 | 上线运行 |
|
||||||
|
| 微信审核 | 提交审核 | 正式发布 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、服务器部署方案
|
||||||
|
|
||||||
|
### 环境配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 系统: Ubuntu 22.04 LTS
|
||||||
|
# 配置: 2 核 CPU, 2GB 内存
|
||||||
|
|
||||||
|
# 安装 Node.js 20
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt install -y nodejs
|
||||||
|
|
||||||
|
# 安装 MySQL 8.0
|
||||||
|
sudo apt install -y mysql-server
|
||||||
|
sudo mysql_secure_installation
|
||||||
|
|
||||||
|
# 安装 Nginx
|
||||||
|
sudo apt install -y nginx
|
||||||
|
|
||||||
|
# 安装 PM2
|
||||||
|
sudo npm install -g pm2
|
||||||
|
|
||||||
|
# 创建部署目录
|
||||||
|
sudo mkdir -p /var/www/xiaocai
|
||||||
|
sudo chown $USER:$USER /var/www/xiaocai
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx 配置
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# 小程序 API
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SSL (上线前配置)
|
||||||
|
# listen 443 ssl;
|
||||||
|
# ssl_certificate /path/to/cert.pem;
|
||||||
|
# ssl_certificate_key /path/to/key.pem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### PM2 配置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// ecosystem.config.js
|
||||||
|
module.exports = {
|
||||||
|
apps: [{
|
||||||
|
name: 'xiaocai-server',
|
||||||
|
script: './dist/index.js',
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '300M',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 3000
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 性能预估 (2H2G)
|
||||||
|
|
||||||
|
| 指标 | 预估 |
|
||||||
|
|------|------|
|
||||||
|
| 并发用户 | 50-100 |
|
||||||
|
| QPS | 200-500 |
|
||||||
|
| 内存占用 | Node.js 150MB + MySQL 300MB |
|
||||||
|
| 响应时间 | < 100ms |
|
||||||
|
| 数据库大小 | < 100MB/年 |
|
||||||
|
|
||||||
|
**内存分配**: MySQL 约 300MB,Node.js 约 150MB,系统约 500MB,剩余可用约 1GB。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、微信小程序配置
|
||||||
|
|
||||||
|
### manifest.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mp-weixin": {
|
||||||
|
"appid": "wx854804dff2928c53",
|
||||||
|
"setting": {
|
||||||
|
"urlCheck": true,
|
||||||
|
"es6": true,
|
||||||
|
"postcss": true,
|
||||||
|
"minified": true
|
||||||
|
},
|
||||||
|
"usingComponents": true,
|
||||||
|
"permission": {
|
||||||
|
"scope.userLocation": {
|
||||||
|
"desc": "用于获取您的位置信息"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### pages.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pages": [
|
||||||
|
{ "path": "pages/index/index", "style": { "navigationBarTitleText": "小菜记账" } },
|
||||||
|
{ "path": "pages/add/index", "style": { "navigationBarTitleText": "记一笔" } },
|
||||||
|
{ "path": "pages/stats/index", "style": { "navigationBarTitleText": "统计分析" } },
|
||||||
|
{ "path": "pages/bills/index", "style": { "navigationBarTitleText": "全部账单" } },
|
||||||
|
{ "path": "pages/profile/index", "style": { "navigationBarTitleText": "我的" } }
|
||||||
|
],
|
||||||
|
"tabBar": {
|
||||||
|
"color": "#BFB3B3",
|
||||||
|
"selectedColor": "#FF8C69",
|
||||||
|
"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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、风险与应对
|
||||||
|
|
||||||
|
| 风险 | 影响 | 应对方案 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 2G 内存不足 | 服务崩溃 | 监控内存,必要时升级配置 |
|
||||||
|
| 微信审核不通过 | 延迟上线 | 提前了解审核规范 |
|
||||||
|
| 数据丢失 | 用户体验差 | 每日自动备份 MySQL (mysqldump) |
|
||||||
|
| 并发量突增 | 响应变慢 | PM2 自动重启 + 限流 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、预算
|
||||||
|
|
||||||
|
| 项目 | 费用 |
|
||||||
|
|------|------|
|
||||||
|
| 阿里云服务器 | 已有 (2H2G) |
|
||||||
|
| 域名 | ¥55/年 (.com) |
|
||||||
|
| SSL 证书 | 免费 (Let's Encrypt) |
|
||||||
|
| 微信认证 | ¥300/年 (企业) |
|
||||||
|
| **总计** | ¥355/年 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、下一步行动
|
||||||
|
|
||||||
|
1. **立即开始**: 初始化 Uni-app 项目 + 后端项目
|
||||||
|
2. **本周完成**: 数据库设计 + 微信登录 + API 框架
|
||||||
|
3. **下周目标**: 完成记账核心功能
|
||||||
|
4. **月底目标**: 完成全部 P0 功能,提交微信审核
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档版本: v1.0 | 最后更新: 2026-05-27*
|
||||||
1638
docs/mockup/index.html
Normal file
354
docs/superpowers/specs/2026-05-27-xiaocai-design.md
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
# 小菜记账 — 设计规格书
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
**产品名称**: 小菜记账 (Xiaocai Accounting)
|
||||||
|
**产品类型**: 个人记账小程序
|
||||||
|
**设计方向**: 可爱 × 高级 (Cute × Premium)
|
||||||
|
**设计风格**: Claymorphism (软3D粘土风格) + 极简主义融合
|
||||||
|
**目标平台**: 移动端优先 (微信小程序 / Web App)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计理念
|
||||||
|
|
||||||
|
"小菜"暗示温暖、家常、轻松。设计需要传递 **「记账也可以很可爱」** 的情感,同时通过精致的细节呈现 **高级感**。
|
||||||
|
|
||||||
|
### 品牌人格
|
||||||
|
- **友好温暖** — 不冰冷的财务工具,是陪你记账的小伙伴
|
||||||
|
- **精致克制** — 可爱但不幼稚,高级但不冷淡
|
||||||
|
- **轻松治愈** — 让记账从负担变成享受
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 色彩系统
|
||||||
|
|
||||||
|
| 角色 | 色值 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| Primary | `#FF8C69` | 主色调,珊瑚橙,温暖活泼 |
|
||||||
|
| Primary Light | `#FFB89A` | 浅色变体,背景装饰 |
|
||||||
|
| Primary Dark | `#E67355` | 深色变体,按下状态 |
|
||||||
|
| Secondary | `#6C9BCF` | 辅助色,柔和蓝,收入/信任 |
|
||||||
|
| Accent | `#FFD166` | 强调色,暖金,高亮点缀 |
|
||||||
|
| Background | `#FFF8F0` | 全局背景,暖奶油色 |
|
||||||
|
| Surface | `#FFFFFF` | 卡片/面板背景 |
|
||||||
|
| Surface Warm | `#FFF0E6` | 暖色卡片背景 |
|
||||||
|
| Text Primary | `#2D1B1B` | 主文字,暖深棕 |
|
||||||
|
| Text Secondary | `#8B7E7E` | 辅助文字 |
|
||||||
|
| Text Muted | `#BFB3B3` | 禁用/占位文字 |
|
||||||
|
| Success | `#7BC67E` | 收入/成功,软绿 |
|
||||||
|
| Warning | `#FFB347` | 提醒,暖橘 |
|
||||||
|
| Danger | `#FF6B6B` | 超支/删除,软红 |
|
||||||
|
| Border | `#F0E0D6` | 分割线/边框 |
|
||||||
|
|
||||||
|
### 色彩使用原则
|
||||||
|
- 主色调占界面约 15-20%,避免过度使用
|
||||||
|
- 背景保持温暖但不刺眼,`#FFF8F0` 比纯白更有亲和力
|
||||||
|
- 文字对比度确保 WCAG AA (4.5:1 以上)
|
||||||
|
- 深棕色文字替代纯黑,与整体暖色调统一
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 字体系统
|
||||||
|
|
||||||
|
| 用途 | 字体 | 备选 | 权重 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 标题 | Fredoka | PingFang SC (中文) | 500-700 |
|
||||||
|
| 正文 | Nunito | PingFang SC (中文) | 400-500 |
|
||||||
|
| 数字/金额 | Fredoka | - | 600-700 |
|
||||||
|
| 特殊标注 | Caveat (手写体) | - | 400-600 |
|
||||||
|
|
||||||
|
### 字号规范
|
||||||
|
| 级别 | 大小 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| H1 | 28px/1.3 | 页面主标题 |
|
||||||
|
| H2 | 22px/1.3 | 区块标题 |
|
||||||
|
| H3 | 18px/1.4 | 卡片标题 |
|
||||||
|
| Body L | 16px/1.6 | 正文 |
|
||||||
|
| Body | 14px/1.6 | 辅助正文 |
|
||||||
|
| Caption | 12px/1.5 | 说明文字 |
|
||||||
|
| Amount XL | 36px/1.2 | 首页总金额 |
|
||||||
|
| Amount L | 24px/1.2 | 列表金额 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI 组件规范
|
||||||
|
|
||||||
|
### 粘土风格核心参数
|
||||||
|
- `border-radius`: 16-24px (卡片), 12-16px (按钮)
|
||||||
|
- `border`: 2-3px solid (深色边框强调立体感)
|
||||||
|
- `box-shadow`: 内阴影 + 外阴影双层结构
|
||||||
|
- 外阴影: `4px 4px 12px rgba(45, 27, 27, 0.08)`
|
||||||
|
- 内阴影: `inset -2px -2px 6px rgba(45, 27, 27, 0.04)`
|
||||||
|
- 按钮按下: `inset 2px 2px 6px rgba(45, 27, 27, 0.1)` + scale(0.97)
|
||||||
|
|
||||||
|
### 按钮
|
||||||
|
| 类型 | 样式 |
|
||||||
|
|------|------|
|
||||||
|
| Primary | 珊瑚橙背景 + 白字 + 粘土阴影 + 圆角16px |
|
||||||
|
| Secondary | 白色背景 + 3px珊瑚橙边框 + 粘土阴影 |
|
||||||
|
| Ghost | 无背景 + 珊瑚橙文字 + hover浅色背景 |
|
||||||
|
| Danger | 软红背景 + 白字 |
|
||||||
|
|
||||||
|
### 卡片
|
||||||
|
- 白色背景 + 粘土阴影
|
||||||
|
- 圆角 20px
|
||||||
|
- 内边距 16-20px
|
||||||
|
- 点击态: 轻微下沉 (translateY 2px + 阴影缩减)
|
||||||
|
|
||||||
|
### 输入框
|
||||||
|
- 圆角 14px
|
||||||
|
- 2px 边框 `#F0E0D6`,聚焦时变为 Primary
|
||||||
|
- 内阴影 `inset 1px 1px 4px rgba(45,27,27,0.04)`
|
||||||
|
- 高度 48px,方便触摸
|
||||||
|
|
||||||
|
### 图标
|
||||||
|
- 使用 **Lucide Icons** (一致的 SVG 图标集)
|
||||||
|
- 尺寸: 24px (标准), 20px (小), 32px (大)
|
||||||
|
- 颜色跟随内容层级
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
### 1. 首页 — 仪表盘
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ 👤 头像 小菜记账 📊 │ 顶栏
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ 本月支出 │
|
||||||
|
│ ¥ 3,280.00 │ 金额大卡片 (粘土)
|
||||||
|
│ ━━━━━━━━━━━━━━ 65% │ 预算进度条
|
||||||
|
│ 预算剩余 ¥1,720 │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ [收入] [支出] [转账] │ 快捷操作 (3个大按钮)
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ 近期账单 │
|
||||||
|
│ 🍜 午餐 -¥35 今天 │
|
||||||
|
│ ☕ 咖啡 -¥18 今天 │ 交易列表
|
||||||
|
│ 💰 工资 +¥8,000 昨天 │
|
||||||
|
│ 📦 快递 -¥28 昨天 │
|
||||||
|
├──────────────────────────┤
|
||||||
|
│ 🏠 📊 💰 👤 │ 底部导航
|
||||||
|
└──────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 记账页 — 添加记录
|
||||||
|
- 大号金额输入区 (居中,大字体)
|
||||||
|
- 分类选择 (圆形图标网格,粘土风格)
|
||||||
|
- 日期选择
|
||||||
|
- 备注输入
|
||||||
|
- 支出/收入切换标签
|
||||||
|
|
||||||
|
### 3. 统计页 — 图表分析
|
||||||
|
- 月度总览卡片
|
||||||
|
- 分类饼图/环形图
|
||||||
|
- 日支出趋势折线图
|
||||||
|
- 分类排行榜
|
||||||
|
|
||||||
|
### 4. 我的 — 设置
|
||||||
|
- 用户头像和信息
|
||||||
|
- 预算设置
|
||||||
|
- 分类管理
|
||||||
|
- 数据导出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 动效规范
|
||||||
|
|
||||||
|
| 类型 | 时长 | 缓动 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 按钮按下 | 150ms | ease-out | 轻微下沉 + 缩放 |
|
||||||
|
| 页面切换 | 250ms | ease-in-out | 淡入 + 上移 8px |
|
||||||
|
| 数字变化 | 400ms | ease-out | 数字递增动画 |
|
||||||
|
| 列表出现 | 300ms | ease-out | 错位入场 (stagger 50ms) |
|
||||||
|
| 悬浮反馈 | 200ms | ease | 阴影加深 + 微上浮 |
|
||||||
|
|
||||||
|
### 动画原则
|
||||||
|
- 尊重 `prefers-reduced-motion`
|
||||||
|
- 不使用夸张弹跳 (粘土风格的自然按压感即可)
|
||||||
|
- 数字跳动使用 `font-variant-numeric: tabular-nums` 防止宽度变化
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 设计令牌系统
|
||||||
|
|
||||||
|
### 间距 (4px 基准)
|
||||||
|
|
||||||
|
| Token | 值 | 用途 |
|
||||||
|
|-------|-----|------|
|
||||||
|
| `--space-xs` | 4px | 图标与文字间距 |
|
||||||
|
| `--space-sm` | 8px | 紧凑内边距 |
|
||||||
|
| `--space-md` | 12px | 按钮内边距 |
|
||||||
|
| `--space-base` | 16px | 卡片内边距、列表间距 |
|
||||||
|
| `--space-lg` | 20px | 区块间距 |
|
||||||
|
| `--space-xl` | 24px | 页面内边距 |
|
||||||
|
| `--space-2xl` | 32px | 大区块间距 |
|
||||||
|
|
||||||
|
### 圆角
|
||||||
|
|
||||||
|
| Token | 值 | 用途 |
|
||||||
|
|-------|-----|------|
|
||||||
|
| `--radius-sm` | 8px | 标签、小按钮 |
|
||||||
|
| `--radius-md` | 12px | 输入框、普通按钮 |
|
||||||
|
| `--radius-lg` | 16px | 主按钮、分类图标 |
|
||||||
|
| `--radius-xl` | 20px | 卡片 |
|
||||||
|
| `--radius-full` | 9999px | 圆形头像、药丸标签 |
|
||||||
|
|
||||||
|
### 阴影 (粘土风格)
|
||||||
|
|
||||||
|
| Token | 值 | 用途 |
|
||||||
|
|-------|-----|------|
|
||||||
|
| `--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)` | 按钮按下 |
|
||||||
|
| `--shadow-clay-float` | `8px 8px 24px rgba(45,27,27,0.1), inset -3px -3px 8px rgba(45,27,27,0.05)` | 浮动元素 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 组件库规范
|
||||||
|
|
||||||
|
### AmountCard (金额卡片)
|
||||||
|
|
||||||
|
显示金额的卡片容器,用于首页总览、统计概览等。
|
||||||
|
|
||||||
|
| Prop | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `title` | string | 标题文字 |
|
||||||
|
| `amount` | number | 金额 (分) |
|
||||||
|
| `prefix` | `'+'` \| `'-'` \| `''` | 前缀符号 |
|
||||||
|
| `variant` | `'default'` \| `'warm'` \| `'primary'` | 样式变体 |
|
||||||
|
| `trend` | `{ value, direction }` | 趋势数据 |
|
||||||
|
|
||||||
|
变体:
|
||||||
|
- `default`: 白色背景 + 粘土阴影
|
||||||
|
- `warm`: 背景 `#FFF0E6`
|
||||||
|
- `primary`: 渐变 `#FF8C69 → #FFB89A`,文字白色
|
||||||
|
|
||||||
|
### TransactionItem (交易列表项)
|
||||||
|
|
||||||
|
| Prop | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `icon` | string | Lucide 图标名 |
|
||||||
|
| `iconColor` | string | 图标颜色 |
|
||||||
|
| `iconBg` | string | 图标背景色 |
|
||||||
|
| `name` | string | 交易名称 |
|
||||||
|
| `category` | string | 分类名 |
|
||||||
|
| `time` | string | 时间 |
|
||||||
|
| `amount` | number | 金额 (分) |
|
||||||
|
| `type` | `'expense'` \| `'income'` | 类型 |
|
||||||
|
|
||||||
|
布局: 64px 高,flex 对齐,gap 12px。按下态: 背景 `#FFF8F0`,100ms。
|
||||||
|
|
||||||
|
### CategoryIcon (分类图标)
|
||||||
|
|
||||||
|
| Prop | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `name` | string | Lucide 图标名 |
|
||||||
|
| `size` | `'sm'` \| `'md'` \| `'lg'` | 尺寸 (20/24/28px) |
|
||||||
|
| `color` | string | 图标颜色 |
|
||||||
|
| `bgColor` | string | 背景色 |
|
||||||
|
| `selected` | boolean | 是否选中 |
|
||||||
|
|
||||||
|
尺寸映射:
|
||||||
|
- sm: 容器 32px,图标 16px,圆角 8px
|
||||||
|
- md: 容器 44px,图标 22px,圆角 12px
|
||||||
|
- lg: 容器 56px,图标 28px,圆角 14px
|
||||||
|
|
||||||
|
### BudgetBar (预算进度条)
|
||||||
|
|
||||||
|
| Prop | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `total` | number | 预算总额 (分) |
|
||||||
|
| `used` | number | 已用金额 (分) |
|
||||||
|
| `showLabel` | boolean | 是否显示标签 |
|
||||||
|
|
||||||
|
轨道: 高 8px,圆角 4px,背景 `#F0E0D6`。
|
||||||
|
填充: 渐变 `#FF8C69 → #FFB89A`,宽度 = used/total。
|
||||||
|
超支态: 填充色变为 `#FF6B6B`。
|
||||||
|
动画: 宽度变化 600ms ease-out。
|
||||||
|
|
||||||
|
### BottomNav (底部导航栏)
|
||||||
|
|
||||||
|
| Prop | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `current` | `'home'` \| `'stats'` \| `'profile'` | 当前页 |
|
||||||
|
| `onNavigate` | function | 导航回调 |
|
||||||
|
|
||||||
|
高度: 56px + safe-area。4 个导航项 + 中间浮动 "+" 按钮。
|
||||||
|
选中态: 图标填充 `#FF8C69`,文字 `#FF8C69` Nunito 600。
|
||||||
|
默认态: 图标线条 `#BFB3B3`,文字 `#BFB3B3` Nunito 400。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 暗色模式 (v2 预留)
|
||||||
|
|
||||||
|
| Token | Light | Dark |
|
||||||
|
|-------|-------|------|
|
||||||
|
| Background | `#FFF8F0` | `#1A1412` |
|
||||||
|
| Surface | `#FFFFFF` | `#2D2422` |
|
||||||
|
| Surface Warm | `#FFF0E6` | `#3D2E2A` |
|
||||||
|
| Text Primary | `#2D1B1B` | `#F5EDE8` |
|
||||||
|
| Text Secondary | `#8B7E7E` | `#A89890` |
|
||||||
|
| Border | `#F0E0D6` | `#4A3C38` |
|
||||||
|
| Primary | `#FF8C69` | `#FF8C69` (不变) |
|
||||||
|
| Shadow | `rgba(45,27,27,0.08)` | `rgba(0,0,0,0.3)` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术选型建议
|
||||||
|
|
||||||
|
| 方案 | 技术栈 | 适用场景 |
|
||||||
|
|------|--------|----------|
|
||||||
|
| A | Uni-app (Vue3) + Pinia | 跨端小程序,一套代码多平台 |
|
||||||
|
| B | Taro (React) + Zustand | React 生态,跨端 |
|
||||||
|
| C | 原生微信小程序 | 纯微信生态,性能最优 |
|
||||||
|
| D | HTML + Tailwind CSS + Vanilla JS | Web App,最快原型 |
|
||||||
|
|
||||||
|
**推荐 A (Uni-app)**: 跨端能力 + Vue 生态成熟 + 社区资源丰富,适合快速开发上线。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 反模式 (避免)
|
||||||
|
|
||||||
|
- ❌ 使用 emoji 作为 UI 图标 → ✅ 使用 Lucide SVG 图标
|
||||||
|
- ❌ 纯黑或冷灰色文字 → ✅ 暖棕色调文字
|
||||||
|
- ❌ 扁平无层次 → ✅ 粘土阴影创造深度
|
||||||
|
- ❌ 过于幼稚 → ✅ 克制的圆角和色彩饱和度
|
||||||
|
- ❌ 文字过多 → ✅ 图标+数字为主,文字为辅
|
||||||
|
- ❌ 弹出式键盘遮挡 → ✅ 金额输入固定在键盘上方
|
||||||
|
- ❌ 忽略触摸区域 → ✅ 最小 44×44px 触摸目标
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 响应式与适配
|
||||||
|
|
||||||
|
### 断点
|
||||||
|
|
||||||
|
| 断点 | 宽度 | 设备 | 布局调整 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| xs | < 375px | 小屏手机 | 缩小间距,字号减 1px |
|
||||||
|
| sm | 375-414px | 标准手机 | 基准设计 |
|
||||||
|
| md | 415-768px | 大手机/小平板 | 内容区 max-width 480px 居中 |
|
||||||
|
| lg | > 768px | 平板/H5 | 双列布局,侧边导航 |
|
||||||
|
|
||||||
|
### 小程序 rpx 换算
|
||||||
|
|
||||||
|
设计稿 375px → 750rpx (1px = 2rpx)
|
||||||
|
|
||||||
|
| 设计稿 | rpx |
|
||||||
|
|--------|-----|
|
||||||
|
| 12px | 24rpx |
|
||||||
|
| 14px | 28rpx |
|
||||||
|
| 16px | 32rpx |
|
||||||
|
| 18px | 36rpx |
|
||||||
|
| 20px | 40rpx |
|
||||||
|
| 24px | 48rpx |
|
||||||
|
| 36px | 72rpx |
|
||||||
|
| 48px | 96rpx |
|
||||||
|
|
||||||
|
### H5 桌面端
|
||||||
|
|
||||||
|
- 内容区: `max-width: 420px`,居中
|
||||||
|
- 背景: `#F0E0D6` (深色底)
|
||||||
|
- 手机框: 圆角 32px + 阴影,模拟手机外观
|
||||||
1100
docs/ui-design.md
Normal file
238
scripts/gen-icons.js
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* 生成 Tab Bar 图标 PNG 文件 (81x81)
|
||||||
|
* 使用 Node.js 内置模块,无需安装额外依赖
|
||||||
|
*/
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const zlib = require('zlib')
|
||||||
|
|
||||||
|
const SIZE = 81
|
||||||
|
const OUT_DIR = path.join(__dirname, '../client/src/static/icons')
|
||||||
|
|
||||||
|
// 颜色
|
||||||
|
const INACTIVE = [191, 179, 179] // #BFB3B3
|
||||||
|
const ACTIVE = [255, 140, 105] // #FF8C69
|
||||||
|
const TRANSPARENT = [0, 0, 0, 0]
|
||||||
|
|
||||||
|
function createPNG(pixels) {
|
||||||
|
// PNG signature
|
||||||
|
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
||||||
|
|
||||||
|
// IHDR chunk
|
||||||
|
const ihdr = Buffer.alloc(13)
|
||||||
|
ihdr.writeUInt32BE(SIZE, 0)
|
||||||
|
ihdr.writeUInt32BE(SIZE, 4)
|
||||||
|
ihdr[8] = 8 // bit depth
|
||||||
|
ihdr[9] = 6 // color type: RGBA
|
||||||
|
ihdr[10] = 0 // compression
|
||||||
|
ihdr[11] = 0 // filter
|
||||||
|
ihdr[12] = 0 // interlace
|
||||||
|
|
||||||
|
// Raw image data: filter byte (0) + RGBA pixels per row
|
||||||
|
const rawData = Buffer.alloc(SIZE * (1 + SIZE * 4))
|
||||||
|
for (let y = 0; y < SIZE; y++) {
|
||||||
|
const rowOffset = y * (1 + SIZE * 4)
|
||||||
|
rawData[rowOffset] = 0 // no filter
|
||||||
|
for (let x = 0; x < SIZE; x++) {
|
||||||
|
const srcIdx = (y * SIZE + x) * 4
|
||||||
|
const dstIdx = rowOffset + 1 + x * 4
|
||||||
|
rawData[dstIdx] = pixels[srcIdx]
|
||||||
|
rawData[dstIdx + 1] = pixels[srcIdx + 1]
|
||||||
|
rawData[dstIdx + 2] = pixels[srcIdx + 2]
|
||||||
|
rawData[dstIdx + 3] = pixels[srcIdx + 3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressed = zlib.deflateSync(rawData)
|
||||||
|
|
||||||
|
function makeChunk(type, data) {
|
||||||
|
const typeBuffer = Buffer.from(type, 'ascii')
|
||||||
|
const length = Buffer.alloc(4)
|
||||||
|
length.writeUInt32BE(data.length, 0)
|
||||||
|
const combined = Buffer.concat([typeBuffer, data])
|
||||||
|
const crc = crc32(combined)
|
||||||
|
return Buffer.concat([length, combined, crc])
|
||||||
|
}
|
||||||
|
|
||||||
|
const ihdrChunk = makeChunk('IHDR', ihdr)
|
||||||
|
const idatChunk = makeChunk('IDAT', compressed)
|
||||||
|
const iendChunk = makeChunk('IEND', Buffer.alloc(0))
|
||||||
|
|
||||||
|
return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk])
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRC32 table
|
||||||
|
const crcTable = new Uint32Array(256)
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n
|
||||||
|
for (let k = 0; k < 8; k++) {
|
||||||
|
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)
|
||||||
|
}
|
||||||
|
crcTable[n] = c
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let crc = 0xFFFFFFFF
|
||||||
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
crc = crcTable[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8)
|
||||||
|
}
|
||||||
|
const result = Buffer.alloc(4)
|
||||||
|
result.writeUInt32BE((crc ^ 0xFFFFFFFF) >>> 0, 0)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePixels(drawFn, color) {
|
||||||
|
const pixels = new Uint8Array(SIZE * SIZE * 4)
|
||||||
|
// Fill transparent
|
||||||
|
for (let i = 0; i < pixels.length; i += 4) {
|
||||||
|
pixels[i + 3] = 0
|
||||||
|
}
|
||||||
|
drawFn(pixels, color)
|
||||||
|
return pixels
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPixel(pixels, x, y, r, g, b, a = 255) {
|
||||||
|
if (x < 0 || x >= SIZE || y < 0 || y >= SIZE) return
|
||||||
|
const idx = (y * SIZE + x) * 4
|
||||||
|
// Alpha blending
|
||||||
|
const srcA = a / 255
|
||||||
|
const dstA = pixels[idx + 3] / 255
|
||||||
|
const outA = srcA + dstA * (1 - srcA)
|
||||||
|
if (outA > 0) {
|
||||||
|
pixels[idx] = Math.round((r * srcA + pixels[idx] * dstA * (1 - srcA)) / outA)
|
||||||
|
pixels[idx + 1] = Math.round((g * srcA + pixels[idx + 1] * dstA * (1 - srcA)) / outA)
|
||||||
|
pixels[idx + 2] = Math.round((b * srcA + pixels[idx + 2] * dstA * (1 - srcA)) / outA)
|
||||||
|
pixels[idx + 3] = Math.round(outA * 255)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLine(pixels, x0, y0, x1, y1, r, g, b, thickness = 2) {
|
||||||
|
const dx = Math.abs(x1 - x0)
|
||||||
|
const dy = Math.abs(y1 - y0)
|
||||||
|
const sx = x0 < x1 ? 1 : -1
|
||||||
|
const sy = y0 < y1 ? 1 : -1
|
||||||
|
let err = dx - dy
|
||||||
|
while (true) {
|
||||||
|
for (let tx = -thickness; tx <= thickness; tx++) {
|
||||||
|
for (let ty = -thickness; ty <= thickness; ty++) {
|
||||||
|
if (tx * tx + ty * ty <= thickness * thickness) {
|
||||||
|
setPixel(pixels, x0 + tx, y0 + ty, r, g, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (x0 === x1 && y0 === y1) break
|
||||||
|
const e2 = 2 * err
|
||||||
|
if (e2 > -dy) { err -= dy; x0 += sx }
|
||||||
|
if (e2 < dx) { err += dx; y0 += sy }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCircle(pixels, cx, cy, radius, r, g, b, filled = true) {
|
||||||
|
for (let y = -radius; y <= radius; y++) {
|
||||||
|
for (let x = -radius; x <= radius; x++) {
|
||||||
|
const dist = Math.sqrt(x * x + y * y)
|
||||||
|
if (filled ? dist <= radius : (dist >= radius - 1.5 && dist <= radius + 0.5)) {
|
||||||
|
setPixel(pixels, cx + x, cy + y, r, g, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRect(pixels, x, y, w, h, r, g, b) {
|
||||||
|
for (let dy = 0; dy < h; dy++) {
|
||||||
|
for (let dx = 0; dx < w; dx++) {
|
||||||
|
setPixel(pixels, x + dx, y + dy, r, g, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Icon drawing functions ===
|
||||||
|
|
||||||
|
function drawHome(pixels, color) {
|
||||||
|
const [r, g, b] = color
|
||||||
|
const cx = 40, cy = 40
|
||||||
|
// House body (rectangle)
|
||||||
|
drawRect(pixels, 24, 38, 34, 26, r, g, b)
|
||||||
|
// Roof (triangle)
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
drawLine(pixels, cx, 18, cx - 20 + i * 2, 38, r, g, b, 1)
|
||||||
|
}
|
||||||
|
// Door
|
||||||
|
drawRect(pixels, 35, 50, 12, 14, 255, 248, 240)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawChart(pixels, color) {
|
||||||
|
const [r, g, b] = color
|
||||||
|
// Three bars
|
||||||
|
drawRect(pixels, 18, 45, 12, 20, r, g, b)
|
||||||
|
drawRect(pixels, 35, 30, 12, 35, r, g, b)
|
||||||
|
drawRect(pixels, 52, 20, 12, 45, r, g, b)
|
||||||
|
// Base line
|
||||||
|
drawRect(pixels, 14, 65, 54, 2, r, g, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawBill(pixels, color) {
|
||||||
|
const [r, g, b] = color
|
||||||
|
// Document shape
|
||||||
|
drawRect(pixels, 22, 14, 38, 54, r, g, b)
|
||||||
|
// Inner white area
|
||||||
|
drawRect(pixels, 28, 22, 26, 38, 255, 248, 240)
|
||||||
|
// Lines on document
|
||||||
|
drawRect(pixels, 31, 30, 20, 2, r, g, b)
|
||||||
|
drawRect(pixels, 31, 38, 16, 2, r, g, b)
|
||||||
|
drawRect(pixels, 31, 46, 20, 2, r, g, b)
|
||||||
|
drawRect(pixels, 31, 54, 12, 2, r, g, b)
|
||||||
|
// Corner fold
|
||||||
|
drawLine(pixels, 52, 14, 60, 22, r, g, b, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawUser(pixels, color) {
|
||||||
|
const [r, g, b] = color
|
||||||
|
// Head (circle)
|
||||||
|
drawCircle(pixels, 40, 28, 12, r, g, b)
|
||||||
|
// Body (half circle / arc)
|
||||||
|
for (let x = -24; x <= 24; x++) {
|
||||||
|
const y = Math.round(Math.sqrt(Math.max(0, 24 * 24 - x * x)))
|
||||||
|
for (let dy = 0; dy < 3; dy++) {
|
||||||
|
setPixel(pixels, 40 + x, 58 - y + dy, r, g, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fill body
|
||||||
|
for (let y = 48; y <= 66; y++) {
|
||||||
|
for (let x = 16; x <= 64; x++) {
|
||||||
|
const dx = x - 40
|
||||||
|
const dy = y - 58
|
||||||
|
if (dy * dy + dx * dx <= 26 * 26) {
|
||||||
|
setPixel(pixels, x, y, r, g, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate all icons
|
||||||
|
const icons = [
|
||||||
|
{ name: 'home', draw: drawHome },
|
||||||
|
{ name: 'chart', draw: drawChart },
|
||||||
|
{ name: 'bill', draw: drawBill },
|
||||||
|
{ name: 'user', draw: drawUser },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (!fs.existsSync(OUT_DIR)) {
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const icon of icons) {
|
||||||
|
// Inactive
|
||||||
|
const inactivePixels = makePixels(icon.draw, INACTIVE)
|
||||||
|
const inactivePNG = createPNG(inactivePixels)
|
||||||
|
fs.writeFileSync(path.join(OUT_DIR, `${icon.name}.png`), inactivePNG)
|
||||||
|
|
||||||
|
// Active
|
||||||
|
const activePixels = makePixels(icon.draw, ACTIVE)
|
||||||
|
const activePNG = createPNG(activePixels)
|
||||||
|
fs.writeFileSync(path.join(OUT_DIR, `${icon.name}-active.png`), activePNG)
|
||||||
|
|
||||||
|
console.log(`Generated: ${icon.name}.png, ${icon.name}-active.png`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('All icons generated successfully!')
|
||||||
12
server/.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Server
|
||||||
|
PORT=3000
|
||||||
|
|
||||||
|
# MySQL
|
||||||
|
DB_HOST=your_mysql_host_here
|
||||||
|
DB_USER=your_mysql_user_here
|
||||||
|
DB_PASSWORD=your_mysql_password_here
|
||||||
|
DB_NAME=your_mysql_name_here
|
||||||
|
|
||||||
|
# WeChat Mini Program
|
||||||
|
WX_APPID=your_wx_appid_here
|
||||||
|
WX_SECRET=your_wx_secret_here
|
||||||
2236
server/package-lock.json
generated
Normal file
25
server/package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "xiaocai-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc && shx cp src/db/*.sql dist/db/",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:init": "tsx src/db/init.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"mysql2": "^3.9.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"shx": "^0.4.0",
|
||||||
|
"tsx": "^4.7.0",
|
||||||
|
"typescript": "^5.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
17
server/src/db/connection.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import mysql from 'mysql2/promise'
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
user: process.env.DB_USER || 'xiaocai',
|
||||||
|
password: process.env.DB_PASSWORD || 'xiaocai123',
|
||||||
|
database: process.env.DB_NAME || 'xiaocai',
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
;(pool as any).on('error', (err: any) => {
|
||||||
|
console.error('[DB] Pool error:', err?.message || err)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default pool
|
||||||
51
server/src/db/init.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import mysql from 'mysql2/promise'
|
||||||
|
import pool from './connection'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
export async function initDatabase() {
|
||||||
|
const dbName = process.env.DB_NAME || 'xiaocai'
|
||||||
|
|
||||||
|
// 先用不指定数据库的连接,确保数据库存在
|
||||||
|
const bootstrap = await mysql.createConnection({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
user: process.env.DB_USER || 'xiaocai',
|
||||||
|
password: process.env.DB_PASSWORD || 'xiaocai123',
|
||||||
|
multipleStatements: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
await bootstrap.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`)
|
||||||
|
await bootstrap.end()
|
||||||
|
|
||||||
|
// 用独立连接执行建表和填充(需要 multipleStatements)
|
||||||
|
const initConn = await mysql.createConnection({
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
user: process.env.DB_USER || 'xiaocai',
|
||||||
|
password: process.env.DB_PASSWORD || 'xiaocai123',
|
||||||
|
database: dbName,
|
||||||
|
multipleStatements: true,
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
const schemaPath = join(__dirname, 'schema.sql')
|
||||||
|
const seedPath = join(__dirname, 'seed.sql')
|
||||||
|
|
||||||
|
const schema = readFileSync(schemaPath, 'utf-8')
|
||||||
|
const cleanSchema = schema
|
||||||
|
.replace(/CREATE DATABASE.*?;/gi, '')
|
||||||
|
.replace(/USE\s+\w+;/gi, '')
|
||||||
|
await initConn.query(cleanSchema)
|
||||||
|
|
||||||
|
const seed = readFileSync(seedPath, 'utf-8')
|
||||||
|
await initConn.query(seed)
|
||||||
|
|
||||||
|
console.log('[DB] Database initialized')
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === 'ER_DUP_ENTRY' || err.errno === 1062) {
|
||||||
|
console.log('[DB] Already initialized, skipping seed')
|
||||||
|
} else {
|
||||||
|
console.error('[DB] Init failed:', err.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await initConn.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
48
server/src/db/schema.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
openid VARCHAR(100) UNIQUE,
|
||||||
|
session_key VARCHAR(100),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT DEFAULT 0 COMMENT '0=默认分类, 其他=用户自定义',
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
icon VARCHAR(50) NOT NULL,
|
||||||
|
color VARCHAR(20) NOT NULL,
|
||||||
|
type ENUM('expense', 'income') NOT NULL,
|
||||||
|
sort_order INT DEFAULT 0,
|
||||||
|
is_custom TINYINT(1) DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_user_name_type (user_id, name, type),
|
||||||
|
INDEX idx_user_custom (user_id, is_custom)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS transactions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
amount INT NOT NULL COMMENT '单位:分',
|
||||||
|
type ENUM('expense', 'income') NOT NULL,
|
||||||
|
category_id INT,
|
||||||
|
note VARCHAR(200),
|
||||||
|
date DATE NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user_date (user_id, date),
|
||||||
|
INDEX idx_user_type_date (user_id, type, date),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS budgets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
amount INT NOT NULL COMMENT '单位:分',
|
||||||
|
month VARCHAR(7) NOT NULL COMMENT '格式:2026-05',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_user_month (user_id, month),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
17
server/src/db/seed.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
INSERT IGNORE INTO categories (user_id, name, icon, color, type, sort_order) VALUES
|
||||||
|
(0, '餐饮', 'Coffee', '#FF8C69', 'expense', 1),
|
||||||
|
(0, '饮品', 'Coffee', '#FF8C69', 'expense', 2),
|
||||||
|
(0, '购物', 'ShoppingBag', '#FFB347', 'expense', 3),
|
||||||
|
(0, '交通', 'Car', '#6C9BCF', 'expense', 4),
|
||||||
|
(0, '住房', 'Home', '#7BC67E', 'expense', 5),
|
||||||
|
(0, '医疗', 'Heart', '#FF6B6B', 'expense', 6),
|
||||||
|
(0, '教育', 'BookOpen', '#6C9BCF', 'expense', 7),
|
||||||
|
(0, '娱乐', 'Film', '#B39DDB', 'expense', 8),
|
||||||
|
(0, '服饰', 'Shirt', '#FFD166', 'expense', 9),
|
||||||
|
(0, '美容', 'Smile', '#FF8C69', 'expense', 10),
|
||||||
|
(0, '通讯', 'Smartphone', '#6C9BCF', 'expense', 11),
|
||||||
|
(0, '其他', 'MoreHorizontal', '#BFB3B3', 'expense', 12),
|
||||||
|
(0, '工资', 'DollarSign', '#7BC67E', 'income', 1),
|
||||||
|
(0, '副业', 'TrendingUp', '#7BC67E', 'income', 2),
|
||||||
|
(0, '理财', 'PieChart', '#7BC67E', 'income', 3),
|
||||||
|
(0, '其他收入', 'Plus', '#BFB3B3', 'income', 4);
|
||||||
74
server/src/index.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import express from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import { authMiddleware } from './middleware/auth'
|
||||||
|
import { initDatabase } from './db/init'
|
||||||
|
import pool from './db/connection'
|
||||||
|
import authRoutes from './routes/auth'
|
||||||
|
import transactionRoutes from './routes/transaction'
|
||||||
|
import statsRoutes from './routes/stats'
|
||||||
|
import budgetRoutes from './routes/budget'
|
||||||
|
import categoryRoutes from './routes/category'
|
||||||
|
|
||||||
|
// Warn if token secret is using default fallback
|
||||||
|
if (!process.env.TOKEN_SECRET) {
|
||||||
|
console.warn('[Security] TOKEN_SECRET not set — using default fallback. Set TOKEN_SECRET in .env for production!')
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const PORT = process.env.PORT || 3000
|
||||||
|
const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS || '').split(',').filter(Boolean)
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
origin: (origin, callback) => {
|
||||||
|
// No origin = WeChat mini-program request (always allow)
|
||||||
|
if (!origin) return callback(null, true)
|
||||||
|
// If no CORS_ORIGINS configured, allow all (dev mode)
|
||||||
|
if (ALLOWED_ORIGINS.length === 0) return callback(null, true)
|
||||||
|
// Check whitelist
|
||||||
|
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true)
|
||||||
|
callback(null, false)
|
||||||
|
},
|
||||||
|
credentials: true,
|
||||||
|
}))
|
||||||
|
app.use(express.json({ limit: '10kb' }))
|
||||||
|
app.use(authMiddleware)
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes)
|
||||||
|
app.use('/api/transactions', transactionRoutes)
|
||||||
|
app.use('/api/stats', statsRoutes)
|
||||||
|
app.use('/api/budget', budgetRoutes)
|
||||||
|
app.use('/api/categories', categoryRoutes)
|
||||||
|
|
||||||
|
app.get('/api/health', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1')
|
||||||
|
res.json({ status: 'ok', db: 'connected', time: new Date().toISOString() })
|
||||||
|
} catch {
|
||||||
|
res.status(503).json({ status: 'error', db: 'disconnected', time: new Date().toISOString() })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let server: any
|
||||||
|
|
||||||
|
initDatabase().then(() => {
|
||||||
|
server = app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
function shutdown(signal: string) {
|
||||||
|
console.log(`[Server] ${signal} received, shutting down gracefully...`)
|
||||||
|
server?.close(() => {
|
||||||
|
pool.end().then(() => {
|
||||||
|
console.log('[Server] All connections closed')
|
||||||
|
process.exit(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// Force exit after 10s
|
||||||
|
setTimeout(() => process.exit(1), 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||||
64
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express'
|
||||||
|
import { createHmac } from 'crypto'
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
userId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||||
|
const TOKEN_EXPIRY = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
|
||||||
|
// 不需要认证的路径
|
||||||
|
const PUBLIC_PATHS = ['/api/auth/login', '/api/health']
|
||||||
|
|
||||||
|
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
// 公开路径跳过认证
|
||||||
|
if (PUBLIC_PATHS.some(p => req.path === p)) {
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 Authorization header 获取 token
|
||||||
|
const authHeader = req.headers.authorization
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ code: 40100, message: '未登录' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7)
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(token, 'base64').toString('utf-8')
|
||||||
|
const parts = decoded.split(':')
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return res.status(401).json({ code: 40100, message: 'token无效' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userIdStr, timestamp, signature] = parts
|
||||||
|
const userId = parseInt(userIdStr)
|
||||||
|
const ts = parseInt(timestamp)
|
||||||
|
|
||||||
|
if (isNaN(userId) || isNaN(ts)) {
|
||||||
|
return res.status(401).json({ code: 40100, message: 'token无效' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check expiry and reject future timestamps
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - ts > TOKEN_EXPIRY) {
|
||||||
|
return res.status(401).json({ code: 40101, message: 'token已过期' })
|
||||||
|
}
|
||||||
|
if (ts > now + 60000) {
|
||||||
|
return res.status(401).json({ code: 40100, message: 'token无效' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify HMAC signature (full length)
|
||||||
|
const payload = `${userId}:${timestamp}`
|
||||||
|
const expectedSig = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||||||
|
if (signature !== expectedSig) {
|
||||||
|
return res.status(401).json({ code: 40100, message: 'token无效' })
|
||||||
|
}
|
||||||
|
|
||||||
|
req.userId = userId
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ code: 40100, message: 'token无效' })
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
53
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Router, Request, Response } from 'express'
|
||||||
|
import { createHmac } from 'crypto'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
const APPID = process.env.WX_APPID || ''
|
||||||
|
const SECRET = process.env.WX_SECRET || ''
|
||||||
|
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||||
|
|
||||||
|
export function signToken(userId: number): string {
|
||||||
|
const payload = `${userId}:${Date.now()}`
|
||||||
|
const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||||||
|
return Buffer.from(`${payload}:${signature}`).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { code } = req.body
|
||||||
|
if (!code) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '缺少code参数' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用微信 code2session 接口
|
||||||
|
const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${APPID}&secret=${SECRET}&js_code=${code}&grant_type=authorization_code`
|
||||||
|
const wxRes = await fetch(url)
|
||||||
|
const wxData = await wxRes.json() as any
|
||||||
|
|
||||||
|
if (wxData.errcode) {
|
||||||
|
console.error('[Auth] WeChat API error:', wxData.errcode, wxData.errmsg)
|
||||||
|
return res.status(400).json({ code: 40002, message: '微信登录失败' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { openid, session_key } = wxData
|
||||||
|
|
||||||
|
// 查找或创建用户 (atomic: handles race condition)
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO users (openid, session_key) VALUES (?, ?) ON DUPLICATE KEY UPDATE session_key = ?, id = LAST_INSERT_ID(id)',
|
||||||
|
[openid, session_key, session_key]
|
||||||
|
)
|
||||||
|
const userId = (result as any).insertId
|
||||||
|
|
||||||
|
// HMAC signed token with expiry
|
||||||
|
const token = signToken(userId)
|
||||||
|
|
||||||
|
res.json({ code: 0, data: { token, userId } })
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Auth] Login failed:', err.message)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
49
server/src/routes/budget.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Router, Response } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { AuthRequest } from '../middleware/auth'
|
||||||
|
import { getCurrentMonth, getMonthRange } from '../utils/date'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
function validateMonth(m: string): string {
|
||||||
|
return getMonthRange(m) ? m : getCurrentMonth()
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const month = validateMonth((req.query.month as string) || getCurrentMonth())
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT * FROM budgets WHERE user_id = ? AND month = ?',
|
||||||
|
[req.userId, month]
|
||||||
|
)
|
||||||
|
|
||||||
|
const budget = (rows as any[])[0] || null
|
||||||
|
res.json({ code: 0, data: budget })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Budget] GET error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { amount, month } = req.body
|
||||||
|
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数(单位:分)' })
|
||||||
|
}
|
||||||
|
const m = validateMonth(month || getCurrentMonth())
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO budgets (user_id, amount, month) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = ?',
|
||||||
|
[req.userId, amount, m, amount]
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Budget] POST error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
57
server/src/routes/category.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Router, Response } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { AuthRequest } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Returns: default categories (user_id=0) + user's own custom categories
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT * FROM categories WHERE user_id = 0 OR user_id = ? ORDER BY sort_order',
|
||||||
|
[req.userId]
|
||||||
|
)
|
||||||
|
res.json({ code: 0, data: rows })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Category] GET error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { name, icon, color, type } = req.body
|
||||||
|
if (!name || !icon || !color || !type) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
|
||||||
|
}
|
||||||
|
if (!['expense', 'income'].includes(type)) {
|
||||||
|
return res.status(400).json({ code: 40002, message: '类型无效' })
|
||||||
|
}
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO categories (user_id, name, icon, color, type, is_custom) VALUES (?, ?, ?, ?, ?, 1)',
|
||||||
|
[req.userId, name, icon, color, type]
|
||||||
|
)
|
||||||
|
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Category] POST error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'DELETE FROM categories WHERE id = ? AND is_custom = 1 AND user_id = ?',
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
)
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '分类不存在' })
|
||||||
|
}
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Category] DELETE error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
91
server/src/routes/stats.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Router, Response } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { AuthRequest } from '../middleware/auth'
|
||||||
|
import { getCurrentMonth, getMonthRange } from '../utils/date'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
router.get('/overview', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { month } = req.query
|
||||||
|
const m = (month as string) || getCurrentMonth()
|
||||||
|
const range = getMonthRange(m)
|
||||||
|
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||||
|
const { startDate, endDate } = range
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as expense,
|
||||||
|
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as income,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM transactions WHERE user_id = ? AND date >= ? AND date <= ?`,
|
||||||
|
[req.userId, startDate, endDate]
|
||||||
|
)
|
||||||
|
const row = (rows as any[])[0]
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const [year, mon] = m.split('-').map(Number)
|
||||||
|
const isCurrentMonth = year === now.getFullYear() && mon === now.getMonth() + 1
|
||||||
|
const elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
|
||||||
|
const daily = elapsedDays > 0 ? Math.round(row.expense / elapsedDays) : 0
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: { expense: row.expense, income: row.income, count: row.count, daily }
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Stats] overview error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/category', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { month, type = 'expense' } = req.query
|
||||||
|
const m = (month as string) || getCurrentMonth()
|
||||||
|
const range = getMonthRange(m)
|
||||||
|
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||||
|
const { startDate, endDate } = range
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT c.id, COALESCE(c.name, '未分类') as name, COALESCE(c.icon, '?') as icon, COALESCE(c.color, '#BFB3B3') as color, SUM(t.amount) as amount, COUNT(t.id) as count
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
WHERE t.user_id = ? AND t.type = ? AND t.date >= ? AND t.date <= ?
|
||||||
|
GROUP BY c.id
|
||||||
|
ORDER BY amount DESC`,
|
||||||
|
[req.userId, type, startDate, endDate]
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ code: 0, data: rows })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Stats] category error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/trend', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { month, type = 'expense' } = req.query
|
||||||
|
const m = (month as string) || getCurrentMonth()
|
||||||
|
const range = getMonthRange(m)
|
||||||
|
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||||
|
const { startDate, endDate } = range
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT date, SUM(amount) as amount
|
||||||
|
FROM transactions
|
||||||
|
WHERE user_id = ? AND type = ? AND date >= ? AND date <= ?
|
||||||
|
GROUP BY date
|
||||||
|
ORDER BY date`,
|
||||||
|
[req.userId, type, startDate, endDate]
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ code: 0, data: rows })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Stats] trend error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
165
server/src/routes/transaction.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { Router, Response } from 'express'
|
||||||
|
import pool from '../db/connection'
|
||||||
|
import { AuthRequest } from '../middleware/auth'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
const VALID_TYPES = ['expense', 'income']
|
||||||
|
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||||
|
|
||||||
|
function safeInt(val: any, fallback: number, min: number, max: number): number {
|
||||||
|
const n = parseInt(val)
|
||||||
|
if (isNaN(n)) return fallback
|
||||||
|
return Math.min(Math.max(n, min), max)
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
WHERE t.id = ? AND t.user_id = ?`,
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
)
|
||||||
|
const tx = (rows as any[])[0]
|
||||||
|
if (!tx) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '记录不存在' })
|
||||||
|
}
|
||||||
|
res.json({ code: 0, data: tx })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Transaction] GET by ID error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { page, pageSize, type, startDate, endDate } = req.query
|
||||||
|
const pSize = safeInt(pageSize, 20, 1, 100)
|
||||||
|
const pNum = safeInt(page, 1, 1, 99999)
|
||||||
|
const offset = (pNum - 1) * pSize
|
||||||
|
|
||||||
|
let where = 'WHERE t.user_id = ?'
|
||||||
|
const params: any[] = [req.userId]
|
||||||
|
|
||||||
|
if (type && VALID_TYPES.includes(type as string)) {
|
||||||
|
where += ' AND t.type = ?'; params.push(type)
|
||||||
|
}
|
||||||
|
if (startDate && DATE_REGEX.test(startDate as string)) {
|
||||||
|
where += ' AND t.date >= ?'; params.push(startDate)
|
||||||
|
}
|
||||||
|
if (endDate && DATE_REGEX.test(endDate as string)) {
|
||||||
|
where += ' AND t.date <= ?'; params.push(endDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT t.*, c.name as category_name, c.icon as category_icon, c.color as category_color
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN categories c ON t.category_id = c.id
|
||||||
|
${where}
|
||||||
|
ORDER BY t.date DESC, t.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
[...params, pSize, offset]
|
||||||
|
)
|
||||||
|
|
||||||
|
const [countResult] = await pool.query(
|
||||||
|
`SELECT COUNT(*) as total FROM transactions t ${where}`,
|
||||||
|
params
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
list: rows,
|
||||||
|
total: (countResult as any)[0].total,
|
||||||
|
page: pNum,
|
||||||
|
pageSize: pSize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Transaction] GET error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { amount, type, category_id, note, date } = req.body
|
||||||
|
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数(单位:分)' })
|
||||||
|
}
|
||||||
|
if (!type || !date) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
|
||||||
|
}
|
||||||
|
if (!VALID_TYPES.includes(type)) {
|
||||||
|
return res.status(400).json({ code: 40002, message: '类型无效' })
|
||||||
|
}
|
||||||
|
if (!DATE_REGEX.test(date)) {
|
||||||
|
return res.status(400).json({ code: 40003, message: '日期格式无效' })
|
||||||
|
}
|
||||||
|
if (note && note.length > 200) {
|
||||||
|
return res.status(400).json({ code: 40004, message: '备注不能超过200字' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO transactions (user_id, amount, type, category_id, note, date) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[req.userId, amount, type, category_id || null, note || null, date]
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Transaction] POST error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { amount, type, category_id, note, date } = req.body
|
||||||
|
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数(单位:分)' })
|
||||||
|
}
|
||||||
|
if (!type || !date) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
|
||||||
|
}
|
||||||
|
if (!VALID_TYPES.includes(type)) {
|
||||||
|
return res.status(400).json({ code: 40002, message: '类型无效' })
|
||||||
|
}
|
||||||
|
if (!DATE_REGEX.test(date)) {
|
||||||
|
return res.status(400).json({ code: 40003, message: '日期格式无效' })
|
||||||
|
}
|
||||||
|
if (note && note.length > 200) {
|
||||||
|
return res.status(400).json({ code: 40004, message: '备注不能超过200字' })
|
||||||
|
}
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'UPDATE transactions SET amount = ?, type = ?, category_id = ?, note = ?, date = ? WHERE id = ? AND user_id = ?',
|
||||||
|
[amount, type, category_id || null, note || null, date, req.params.id, req.userId]
|
||||||
|
)
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '记录不存在' })
|
||||||
|
}
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Transaction] PUT error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'DELETE FROM transactions WHERE id = ? AND user_id = ?',
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
)
|
||||||
|
if ((result as any).affectedRows === 0) {
|
||||||
|
return res.status(404).json({ code: 40400, message: '记录不存在' })
|
||||||
|
}
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Transaction] DELETE error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
17
server/src/utils/date.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const MONTH_REGEX = /^\d{4}-(0[1-9]|1[0-2])$/
|
||||||
|
|
||||||
|
export function getCurrentMonth(): string {
|
||||||
|
const d = new Date()
|
||||||
|
const offset = 8 * 60 * 60 * 1000
|
||||||
|
const local = new Date(d.getTime() + offset)
|
||||||
|
return local.toISOString().slice(0, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMonthRange(m: string): { startDate: string; endDate: string } | null {
|
||||||
|
if (!MONTH_REGEX.test(m)) return null
|
||||||
|
const [year, mon] = m.split('-').map(Number)
|
||||||
|
const startDate = `${m}-01`
|
||||||
|
const lastDay = new Date(year, mon, 0).getDate()
|
||||||
|
const endDate = `${m}-${String(lastDay).padStart(2, '0')}`
|
||||||
|
return { startDate, endDate }
|
||||||
|
}
|
||||||
19
server/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||