Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6bb65fac0 | |||
| 76b7c056c1 | |||
| b7a0b64ab7 | |||
| 6a3f2eb1fa | |||
| 3f8a58d4fc | |||
| f92d698cf2 | |||
| a8b733fe4d | |||
| d7f7c666e2 | |||
| fe5a4f4845 | |||
| c190e86c6e | |||
| cc01572654 | |||
| a7767b0383 | |||
| 2340fba8e5 | |||
| 737af6b070 | |||
| 682ea278bf | |||
| 0b7d9b3c59 | |||
| d3c740dc67 | |||
| dc63b2a01a | |||
| ffef757fad | |||
| a0e08a313d | |||
| 2f549ff808 | |||
| e557217cd0 | |||
| 482dfc7af5 | |||
| d0a78c096b | |||
| 5766d56315 | |||
| 9b5ded4cc7 | |||
| b886e37218 | |||
| 54f19459ff | |||
| a8d83f9659 | |||
| 871413aecb | |||
| f3743bf6ab | |||
| efd8a55fda | |||
| 14735fcf28 | |||
| 97f9da0348 | |||
| dd632d53a0 | |||
| 9dc5e23693 | |||
| eaaa19201d | |||
| 830ca92c1a | |||
| 226b206a28 | |||
| 59601395d4 | |||
| 218c144046 | |||
| 9ad03e70ce | |||
| f8dbcbd111 | |||
| bdd8ea536f | |||
| 8f5f40617f | |||
| 73f83a62bb | |||
| fe6778b7b5 | |||
| d2b2e9887a |
+58
@@ -0,0 +1,58 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Build output
|
||||
frontend/dist/
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
frontend/tsconfig.node.tsbuildinfo
|
||||
build/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Database (个人使用可保留提交,生产环境建议取消注释)
|
||||
# prisma/dev.db
|
||||
# prisma/prod.db
|
||||
|
||||
# Test scripts
|
||||
test-*.js
|
||||
test-*.ps1
|
||||
test-*.mjs
|
||||
verify-*.cjs
|
||||
verify-*.mjs
|
||||
visual-check-*.js
|
||||
|
||||
# Debug scripts
|
||||
debug-*.cjs
|
||||
|
||||
# Test results
|
||||
test-results/
|
||||
test-screenshots/
|
||||
*.png
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# Full test files (legacy)
|
||||
full-test.js
|
||||
tdd_*.js
|
||||
TEST_CASES.md
|
||||
@@ -0,0 +1,345 @@
|
||||
# 部署指南
|
||||
|
||||
> 本文档提供个人记账与预算管理系统的部署方案,包括本地部署、服务器部署、数据库备份与恢复。
|
||||
|
||||
---
|
||||
|
||||
## 一、本地部署(开发环境)
|
||||
|
||||
### 1.1 前置条件
|
||||
|
||||
- 安装 Node.js 18+:https://nodejs.org/
|
||||
- 安装 Git:https://git-scm.com/
|
||||
|
||||
### 1.2 克隆项目
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd personal-finance-budget-system
|
||||
```
|
||||
|
||||
### 1.3 配置环境变量
|
||||
|
||||
**后端**:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
copy .env.example .env # Windows
|
||||
# cp .env.example .env # macOS/Linux
|
||||
```
|
||||
|
||||
**前端**:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
copy .env.example .env # Windows
|
||||
# cp .env.example .env # macOS/Linux
|
||||
```
|
||||
|
||||
### 1.4 安装依赖并启动
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend
|
||||
npm install
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
npm run dev
|
||||
|
||||
# 新开终端,启动前端
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 1.5 访问应用
|
||||
|
||||
- 前端:`http://localhost:5173`
|
||||
- 后端 API:`http://localhost:3001`
|
||||
- Prisma Studio:`http://localhost:5555`
|
||||
|
||||
---
|
||||
|
||||
## 二、服务器部署(生产环境)
|
||||
|
||||
### 2.1 使用 PM2 部署(推荐)
|
||||
|
||||
#### 2.1.1 安装 PM2
|
||||
|
||||
```bash
|
||||
npm install -g pm2
|
||||
```
|
||||
|
||||
#### 2.1.2 后端部署
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 安装生产依赖
|
||||
npm install --production
|
||||
|
||||
# 配置生产环境变量
|
||||
# 编辑 .env 文件,修改数据库路径等
|
||||
DATABASE_URL="file:./prod.db"
|
||||
PORT=3001
|
||||
|
||||
# 生成 Prisma 客户端并推送数据库结构
|
||||
npm run db:generate
|
||||
npm run db:push
|
||||
|
||||
# 使用 PM2 启动服务
|
||||
pm2 start src/index.js --name finance-backend
|
||||
|
||||
# 设置开机自启
|
||||
pm2 save
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
#### 2.1.3 前端部署
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# 配置生产环境变量
|
||||
# 编辑 .env.production
|
||||
VITE_API_BASE_URL=http://your-server-ip:3001
|
||||
|
||||
# 构建
|
||||
npm run build
|
||||
|
||||
# 构建产物在 frontend/dist/ 目录
|
||||
```
|
||||
|
||||
#### 2.1.4 使用 Nginx 托管前端
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# 前端静态文件
|
||||
location / {
|
||||
root /path/to/personal-finance-budget-system/frontend/dist;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 后端 API 代理(可选,避免跨域)
|
||||
location /api {
|
||||
proxy_pass http://localhost:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 使用 Docker 部署
|
||||
|
||||
#### 2.2.1 创建 Dockerfile(后端)
|
||||
|
||||
```dockerfile
|
||||
# backend/Dockerfile
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --production
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run db:generate
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
```
|
||||
|
||||
#### 2.2.2 创建 docker-compose.yml
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./backend/prisma:/app/prisma # 持久化数据库
|
||||
environment:
|
||||
- DATABASE_URL=file:./prod.db
|
||||
- PORT=3001
|
||||
restart: always
|
||||
|
||||
frontend:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./frontend:/app
|
||||
command: sh -c "npm install && npm run build"
|
||||
depends_on:
|
||||
- backend
|
||||
```
|
||||
|
||||
#### 2.2.3 启动
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、数据库备份与恢复
|
||||
|
||||
### 3.1 备份数据库
|
||||
|
||||
SQLite 数据库为单文件,直接拷贝即可:
|
||||
|
||||
```bash
|
||||
# 备份
|
||||
cp backend/prisma/dev.db backend/prisma/dev.db.backup.$(date +%Y%m%d)
|
||||
|
||||
# 或使用 tar 打包
|
||||
tar czf db-backup-$(date +%Y%m%d).tar.gz backend/prisma/dev.db
|
||||
```
|
||||
|
||||
### 3.2 恢复数据库
|
||||
|
||||
```bash
|
||||
# 恢复
|
||||
cp backend/prisma/dev.db.backup.20260428 backend/prisma/dev.db
|
||||
|
||||
# 或从 tar 恢复
|
||||
tar xzf db-backup-20260428.tar.gz -C backend/prisma/
|
||||
```
|
||||
|
||||
### 3.3 导出 SQL(可选)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx prisma db pull # 从现有数据库拉取 schema
|
||||
npx prisma db push # 推送到新的数据库实例
|
||||
```
|
||||
|
||||
### 3.4 自动备份脚本
|
||||
|
||||
创建定时备份脚本 `backup.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
BACKUP_DIR="./backups"
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
DB_FILE="./backend/prisma/dev.db"
|
||||
|
||||
mkdir -p $BACKUP_DIR
|
||||
cp $DB_FILE "$BACKUP_DIR/dev.db.$DATE.backup"
|
||||
|
||||
# 保留最近 7 天的备份
|
||||
find $BACKUP_DIR -name "*.backup" -mtime +7 -delete
|
||||
|
||||
echo "备份完成: $BACKUP_DIR/dev.db.$DATE.backup"
|
||||
```
|
||||
|
||||
添加到 crontab(Linux/macOS):
|
||||
|
||||
```bash
|
||||
# 每天凌晨 2 点备份
|
||||
0 2 * * * /path/to/backup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、常见问题排查
|
||||
|
||||
### 4.1 后端启动失败
|
||||
|
||||
**问题**: `Error: Cannot find module '@prisma/client'`
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
cd backend
|
||||
npm run db:generate
|
||||
```
|
||||
|
||||
**问题**: `Error: P1001: Can't reach database server`
|
||||
|
||||
**解决**: 检查 `.env` 中的 `DATABASE_URL` 是否正确。
|
||||
|
||||
### 4.2 前端构建失败
|
||||
|
||||
**问题**: `Type error: Cannot find module '@/services/apiClient'`
|
||||
|
||||
**解决**: 检查 `tsconfig.json` 中的 `paths` 配置是否正确。
|
||||
|
||||
**问题**: 构建后页面空白
|
||||
|
||||
**解决**: 检查 `vite.config.js` 中的 `base` 配置,确保部署路径正确。
|
||||
|
||||
### 4.3 API 跨域问题
|
||||
|
||||
**问题**: 前端请求后端报 CORS 错误
|
||||
|
||||
**解决**:
|
||||
1. 确认后端已安装并启用 `cors` 中间件
|
||||
2. 生产环境应配置具体的前端域名,而非 `*`
|
||||
|
||||
### 4.4 数据库文件权限问题(Linux)
|
||||
|
||||
**问题**: `SQLITE_ERROR: unable to open database file`
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 检查权限
|
||||
ls -la backend/prisma/dev.db
|
||||
|
||||
# 修改权限
|
||||
chmod 644 backend/prisma/dev.db
|
||||
chown $(whoami) backend/prisma/dev.db
|
||||
```
|
||||
|
||||
### 4.5 端口被占用
|
||||
|
||||
**问题**: `Error: listen EADDRINUSE: address already in use 0.0.0.0:3001`
|
||||
|
||||
**解决**:
|
||||
```bash
|
||||
# 查看占用端口的进程
|
||||
# Windows
|
||||
netstat -ano | findstr :3001
|
||||
taskkill /PID <PID> /F
|
||||
|
||||
# Linux/macOS
|
||||
lsof -i :3001
|
||||
kill -9 <PID>
|
||||
```
|
||||
|
||||
或修改端口:
|
||||
|
||||
```bash
|
||||
# 修改 .env
|
||||
PORT=3002
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、生产环境安全检查清单
|
||||
|
||||
部署前请确认以下事项:
|
||||
|
||||
- [ ] 移除 `/api/init-test-data` 测试接口
|
||||
- [ ] 接入 JWT 鉴权,从 Token 解析 userId
|
||||
- [ ] CORS 限制为前端域名
|
||||
- [ ] 配置请求频率限制(Rate Limit)
|
||||
- [ ] 配置 HTTPS 强制
|
||||
- [ ] 限制请求体大小(防 DoS)
|
||||
- [ ] 数据库文件定期备份
|
||||
- [ ] 配置日志监控和告警
|
||||
- [ ] 移除 `.env` 文件中的敏感信息(如提交到 Git)
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-04-28
|
||||
@@ -0,0 +1,434 @@
|
||||
# 个人记账与预算管理系统
|
||||
|
||||
> 一款帮助用户管理日常收支、控制消费预算的财务管理工具。
|
||||
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
[]()
|
||||
|
||||
---
|
||||
|
||||
## 项目简介
|
||||
|
||||
个人记账与预算管理系统是一个基于 **React 18 + Node.js** 的全栈应用,帮助用户:
|
||||
|
||||
- **记录每一笔收支**,清晰了解资金流向
|
||||
- **设置预算额度**,有效控制消费
|
||||
- **统计报表可视化**,帮助合理规划财务
|
||||
|
||||
### 目标用户
|
||||
|
||||
- 需要管理日常收支的个人用户
|
||||
- 希望控制消费、规划预算的用户
|
||||
- 需要统计报表和数据导出的用户
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
| 模块 | 功能 | 说明 |
|
||||
|------|------|------|
|
||||
| **仪表盘** | 余额总览 | 显示所有账户总余额 |
|
||||
| | 本月收支 | 当月收入、支出、结余 |
|
||||
| | 预算使用率 | 各分类预算进度条 + 预警 |
|
||||
| **记账** | 收入/支出记录 | 支持金额、分类、日期、备注 |
|
||||
| | 多账户体系 | 支付宝/微信/银行卡等 |
|
||||
| | 记录筛选 | 按账户/类型/分类/日期筛选 |
|
||||
| | 余额联动 | 创建/更新/删除记录自动更新余额 |
|
||||
| **预算管理** | 月度预算 | 按分类设置每月支出限额 |
|
||||
| | 进度追踪 | 实时显示预算使用率 |
|
||||
| | 预警提醒 | 80% 警告 / 100% 超额 |
|
||||
| **统计报表** | 月度统计 | 总收入/支出/结余 + 分类占比饼图 |
|
||||
| | 趋势分析 | 日级收支折线图/柱状图 |
|
||||
| | 月度对比 | 本月 vs 上月环比分析 |
|
||||
| **数据导出** | Excel 导出 | 导出账单记录为 Excel 文件 |
|
||||
|
||||
---
|
||||
|
||||
## 页面预览
|
||||
|
||||
### 仪表盘
|
||||
|
||||

|
||||
|
||||
> 显示余额总览、本月收入/支出/结余、预算进度条、预警提示、最近记录
|
||||
|
||||
### 记账
|
||||
|
||||

|
||||
|
||||
> 账单列表展示、按类型筛选(全部/支出/收入)、悬浮按钮新增记录
|
||||
|
||||

|
||||
|
||||
> 新增记录弹窗:支持收入/支出切换、分类选择、金额输入、日期选择、账户关联
|
||||
|
||||
### 预算管理
|
||||
|
||||

|
||||
|
||||
> 预算列表、进度条展示使用率、超预算预警提示、未设置预算提醒
|
||||
|
||||
### 统计报表
|
||||
|
||||

|
||||
|
||||
> 图表类型切换(折线图/柱状图/饼图)、月度分类占比、日级趋势、本月 vs 上月对比
|
||||
|
||||
---
|
||||
|
||||
## 技术架构
|
||||
|
||||
### 前端
|
||||
|
||||
| 技术 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| React | 18.3 | UI 框架 |
|
||||
| TypeScript | 5.6 | 类型系统 |
|
||||
| Vite | 6.0 | 构建工具 |
|
||||
| TailwindCSS | 3.4 | 样式框架 |
|
||||
| Zustand | 5.0 | 状态管理 |
|
||||
| React Router | 7.14 | 路由管理 |
|
||||
| ECharts | 6.0 | 图表渲染 |
|
||||
| XLSX | 0.18 | Excel 导出 |
|
||||
|
||||
### 后端
|
||||
|
||||
| 技术 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| Node.js | >=18 | 运行时 |
|
||||
| Express | 4.21 | Web 框架 |
|
||||
| Prisma | 6.5 | ORM / 数据库管理 |
|
||||
| SQLite | - | 嵌入式数据库 |
|
||||
| CORS | 2.8 | 跨域处理 |
|
||||
|
||||
---
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 依赖 | 最低版本 | 推荐版本 |
|
||||
|------|---------|---------|
|
||||
| Node.js | 18.0 | 20.x LTS |
|
||||
| npm | 9.0 | 10.x |
|
||||
| Git | 2.0 | 最新版 |
|
||||
|
||||
> **Windows 用户**: 确保使用 PowerShell 或 CMD 运行命令,不支持 Git Bash 中的某些路径格式。
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd personal-finance-budget-system
|
||||
```
|
||||
|
||||
### 2. 安装后端依赖
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
# 生成 Prisma 客户端
|
||||
npm run db:generate
|
||||
|
||||
# 推送数据库结构(首次运行创建 SQLite 数据库文件)
|
||||
npm run db:push
|
||||
```
|
||||
|
||||
### 4. 启动后端服务
|
||||
|
||||
```bash
|
||||
# 开发模式(自动重启)
|
||||
npm run dev
|
||||
|
||||
# 生产模式
|
||||
npm start
|
||||
```
|
||||
|
||||
后端服务启动后默认监听 `http://localhost:3001`。首次启动会自动创建测试数据。
|
||||
|
||||
### 5. 安装前端依赖并启动
|
||||
|
||||
```bash
|
||||
cd ../frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
前端开发服务器默认运行在 `http://localhost:5173`。
|
||||
|
||||
### 6. 访问应用
|
||||
|
||||
打开浏览器访问 `http://localhost:5173`,即可看到应用界面。
|
||||
|
||||
---
|
||||
|
||||
## 数据库初始化
|
||||
|
||||
### 方式一:自动初始化(推荐)
|
||||
|
||||
后端服务首次启动时,如果数据库为空,会自动创建以下测试数据:
|
||||
|
||||
- 1 个测试用户(测试用户 / test@example.com)
|
||||
- 3 个账户(支付宝 / 微信钱包 / 招商银行)
|
||||
- 6 笔交易记录(2 收入 + 4 支出)
|
||||
- 4 笔预算(餐饮 / 交通 / 购物 / 娱乐)
|
||||
|
||||
### 方式二:手动初始化
|
||||
|
||||
启动后端后,访问以下接口手动触发初始化:
|
||||
|
||||
```
|
||||
GET http://localhost:3001/api/init-test-data
|
||||
```
|
||||
|
||||
### 方式三:使用 Seed 脚本
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
### 查看数据库
|
||||
|
||||
使用 Prisma Studio 可视化查看数据库:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run db:studio
|
||||
```
|
||||
|
||||
浏览器将自动打开 `http://localhost:5555`。
|
||||
|
||||
---
|
||||
|
||||
## 开发模式
|
||||
|
||||
### 同时启动前后端
|
||||
|
||||
**方式一:两个终端**
|
||||
|
||||
```bash
|
||||
# 终端 1 - 后端
|
||||
cd backend && npm run dev
|
||||
|
||||
# 终端 2 - 前端
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
**方式二:使用 concurrently(需安装)**
|
||||
|
||||
```bash
|
||||
npm install -g concurrently
|
||||
|
||||
# 项目根目录执行
|
||||
concurrently "cd backend && npm run dev" "cd frontend && npm run dev"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 构建部署
|
||||
|
||||
### 前端构建
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
构建产物输出到 `frontend/dist/` 目录。
|
||||
|
||||
### 生产环境部署
|
||||
|
||||
#### 方式一:静态文件 + Node 服务
|
||||
|
||||
1. 前端构建后,将 `frontend/dist/` 部署到 Nginx 或其他静态服务器
|
||||
2. 后端使用 PM2 启动:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install --production
|
||||
pm2 start src/index.js --name finance-backend
|
||||
```
|
||||
|
||||
#### 方式二:一体化部署
|
||||
|
||||
将前端构建产物放在后端 `public/` 目录下,由 Express 同时提供静态文件和 API 服务。
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
生产环境需要配置以下环境变量:
|
||||
|
||||
**后端 `.env`**:
|
||||
|
||||
```env
|
||||
DATABASE_URL="file:./prod.db"
|
||||
PORT=3001
|
||||
```
|
||||
|
||||
**前端 `.env.production`**:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://your-api-domain.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
personal-finance-budget-system/
|
||||
├── backend/ # 后端服务
|
||||
│ ├── prisma/
|
||||
│ │ ├── schema.prisma # 数据库模型定义
|
||||
│ │ ├── seed.js # 种子数据脚本
|
||||
│ │ └── dev.db # SQLite 数据库文件
|
||||
│ ├── src/
|
||||
│ │ └── index.js # 后端服务入口
|
||||
│ ├── package.json # 后端依赖
|
||||
│ └── .env # 环境变量
|
||||
├── frontend/ # 前端应用
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ │ └── layout/ # 布局组件(侧边栏/底栏)
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ │ ├── Dashboard/ # 仪表盘
|
||||
│ │ │ ├── Record/ # 记账页面
|
||||
│ │ │ ├── Budget/ # 预算管理
|
||||
│ │ │ └── Statistics/ # 统计报表
|
||||
│ │ ├── services/ # API 请求服务
|
||||
│ │ ├── stores/ # Zustand 状态管理
|
||||
│ │ ├── types/ # TypeScript 类型定义
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ ├── public/ # 静态资源
|
||||
│ ├── package.json # 前端依赖
|
||||
│ └── vite.config.js # Vite 配置
|
||||
├── docs/ # 项目文档
|
||||
│ └── API.md # API 接口文档
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 文档
|
||||
|
||||
完整的 API 接口文档请查看:[docs/API.md](docs/API.md)
|
||||
|
||||
### 快速参考
|
||||
|
||||
| 模块 | 基础路径 | 说明 |
|
||||
|------|---------|------|
|
||||
| 健康检查 | `GET /health` | 服务状态检查 |
|
||||
| 用户 | `POST /api/users` | 创建用户(临时接口) |
|
||||
| 账户 | `/api/accounts` | 账户 CRUD |
|
||||
| 记录 | `/api/records` | 交易记录 CRUD + 余额联动 |
|
||||
| 预算 | `/api/budgets` | 月度预算管理 |
|
||||
| 统计 | `/api/statistics/*` | 月度/趋势/对比统计 |
|
||||
| 仪表盘 | `/api/dashboard/summary` | 首页聚合数据 |
|
||||
|
||||
### 测试 API
|
||||
|
||||
```bash
|
||||
# 健康检查
|
||||
curl http://localhost:3001/health
|
||||
|
||||
# 获取账户列表(假设 userId=1)
|
||||
curl http://localhost:3001/api/accounts?userId=1
|
||||
|
||||
# 获取仪表盘数据
|
||||
curl http://localhost:3001/api/dashboard/summary?userId=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 已知限制(MVP 阶段)
|
||||
|
||||
当前版本为 MVP(最小可行产品),以下功能待完善:
|
||||
|
||||
| 限制 | 说明 | 计划 |
|
||||
|------|------|------|
|
||||
| **无用户认证** | 使用 `userId` 查询参数做数据隔离 | 接入 JWT 鉴权 |
|
||||
| **SQLite 数据库** | 单文件数据库,适合开发/个人使用 | 支持 PostgreSQL/MySQL |
|
||||
| **无分页** | 数据全量返回,前端分页 | 后端分页支持 |
|
||||
| **无请求限流** | 无 Rate Limit 保护 | 增加限流中间件 |
|
||||
| **CORS 全开放** | 开发环境允许所有来源 | 限制为前端域名 |
|
||||
| **无数据备份** | 手动拷贝 `dev.db` 文件 | 自动备份机制 |
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 启动时报错 "Cannot find module '@prisma/client'"
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run db:generate
|
||||
```
|
||||
|
||||
### Q: 数据库文件在哪里?
|
||||
|
||||
默认位于 `backend/prisma/dev.db`。如需重置数据库:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
rm prisma/dev.db # 删除数据库
|
||||
npm run db:push # 重新创建
|
||||
npm run db:seed # 重新填充数据
|
||||
```
|
||||
|
||||
### Q: 前端页面显示 "连接后端失败"
|
||||
|
||||
1. 确认后端服务已启动(访问 `http://localhost:3001/health`)
|
||||
2. 检查前端 `.env` 中的 `VITE_API_BASE_URL` 是否正确
|
||||
3. 修改 `.env` 后需要重启前端开发服务器
|
||||
|
||||
### Q: 日期显示不对 / 差一天
|
||||
|
||||
后端已处理时区偏移问题。如仍有异常,请检查系统时区设置是否为 UTC+8。
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
### 前端截图测试
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
node verify-pages.cjs
|
||||
```
|
||||
|
||||
测试截图将保存到 `frontend/test-screenshots/` 目录。
|
||||
|
||||
### API 测试
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
node test-api.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
|
||||
| 日期 | 版本 | 变更内容 |
|
||||
|------|------|---------|
|
||||
| 2026-04-27 | v1.0.0 | 初始版本,完成 MVP 功能开发 |
|
||||
| 2026-04-27 | v1.0.1 | 修复记录排序字段(date → createdAt) |
|
||||
| 2026-04-27 | v1.0.2 | 增加日期安全解析逻辑,修复时区偏移 |
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-04-28
|
||||
@@ -0,0 +1,11 @@
|
||||
# Environment variables declared in this file are automatically made available to Prisma.
|
||||
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
|
||||
|
||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||
|
||||
# 数据库连接字符串(SQLite 示例)
|
||||
DATABASE_URL="file:./dev.db"
|
||||
|
||||
# 后端服务端口(默认 3001)
|
||||
# PORT=3001
|
||||
Generated
+1297
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "personal-finance-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Personal Finance Budget System Backend",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:studio": "prisma studio",
|
||||
"db:seed": "node prisma/seed.js"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "node prisma/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.5.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^6.5.0"
|
||||
},
|
||||
"keywords": ["express", "prisma", "sqlite", "finance"],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// 用户表
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
email String @unique
|
||||
createdAt DateTime @default(now())
|
||||
accounts Account[]
|
||||
budgets Budget[]
|
||||
records Record[]
|
||||
}
|
||||
|
||||
// 账户表
|
||||
model Account {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
name String // 账户名称(支付宝、微信、银行卡等)
|
||||
balance Decimal @default(0) // 当前余额
|
||||
type String // 账户类型
|
||||
color String // 显示颜色
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
records Record[]
|
||||
}
|
||||
|
||||
// 交易记录表
|
||||
model Record {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
accountId Int
|
||||
type String // 'income' | 'expense'
|
||||
amount Decimal
|
||||
category String // 消费类别
|
||||
description String? // 备注
|
||||
date DateTime
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
account Account @relation(fields: [accountId], references: [id])
|
||||
}
|
||||
|
||||
// 预算表
|
||||
model Budget {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
category String // 预算类别
|
||||
amount Decimal // 预算金额
|
||||
month String // 预算月份(YYYY-MM)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Prisma Seed Script - 初始化测试数据
|
||||
* 用于恢复账务系统的测试数据
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('开始恢复测试数据...');
|
||||
|
||||
// 清空现有数据(按依赖顺序)
|
||||
await prisma.record.deleteMany();
|
||||
await prisma.budget.deleteMany();
|
||||
await prisma.account.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
|
||||
// 1. 创建测试用户
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: '测试用户',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
});
|
||||
console.log(`创建用户: ${user.name} (ID: ${user.id})`);
|
||||
|
||||
// 2. 创建账户(支付宝、微信、银行卡)
|
||||
const accounts = await Promise.all([
|
||||
prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: '支付宝',
|
||||
balance: 5000.00,
|
||||
type: 'Alipay',
|
||||
color: '#1677FF',
|
||||
},
|
||||
}),
|
||||
prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: '微信支付',
|
||||
balance: 3000.00,
|
||||
type: 'WeChat',
|
||||
color: '#07C160',
|
||||
},
|
||||
}),
|
||||
prisma.account.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name: '银行卡',
|
||||
balance: 10000.00,
|
||||
type: 'BankCard',
|
||||
color: '#722ED1',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
console.log(`创建 ${accounts.length} 个账户: ${accounts.map(a => a.name).join(', ')}`);
|
||||
|
||||
// 3. 创建交易记录(至少6条)
|
||||
const records = await Promise.all([
|
||||
// 支付宝 - 支出
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[0].id, // 支付宝
|
||||
type: 'expense',
|
||||
amount: 128.50,
|
||||
category: '餐饮',
|
||||
description: '午餐',
|
||||
date: new Date('2026-04-25'),
|
||||
},
|
||||
}),
|
||||
// 微信 - 支出
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[1].id, // 微信
|
||||
type: 'expense',
|
||||
amount: 45.00,
|
||||
category: '交通',
|
||||
description: '地铁',
|
||||
date: new Date('2026-04-25'),
|
||||
},
|
||||
}),
|
||||
// 银行卡 - 收入
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[2].id, // 银行卡
|
||||
type: 'income',
|
||||
amount: 15000.00,
|
||||
category: '工资',
|
||||
description: '月薪',
|
||||
date: new Date('2026-04-20'),
|
||||
},
|
||||
}),
|
||||
// 支付宝 - 支出
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[0].id, // 支付宝
|
||||
type: 'expense',
|
||||
amount: 299.00,
|
||||
category: '购物',
|
||||
description: '网购商品',
|
||||
date: new Date('2026-04-23'),
|
||||
},
|
||||
}),
|
||||
// 微信 - 收入
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[1].id, // 微信
|
||||
type: 'income',
|
||||
amount: 500.00,
|
||||
category: '转账',
|
||||
description: '朋友还款',
|
||||
date: new Date('2026-04-22'),
|
||||
},
|
||||
}),
|
||||
// 银行卡 - 支出
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[2].id, // 银行卡
|
||||
type: 'expense',
|
||||
amount: 2000.00,
|
||||
category: '房租',
|
||||
description: '月租',
|
||||
date: new Date('2026-04-01'),
|
||||
},
|
||||
}),
|
||||
// 支付宝 - 支出(额外)
|
||||
prisma.record.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
accountId: accounts[0].id,
|
||||
type: 'expense',
|
||||
amount: 56.80,
|
||||
category: '娱乐',
|
||||
description: '电影票',
|
||||
date: new Date('2026-04-24'),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
console.log(`创建 ${records.length} 条交易记录`);
|
||||
|
||||
// 4. 创建预算
|
||||
await prisma.budget.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
category: '餐饮',
|
||||
amount: 2000.00,
|
||||
month: '2026-04',
|
||||
},
|
||||
});
|
||||
console.log('创建预算数据');
|
||||
|
||||
console.log('\n✅ 测试数据恢复完成!');
|
||||
console.log(`- 用户: 1 个`);
|
||||
console.log(`- 账户: ${accounts.length} 个`);
|
||||
console.log(`- 交易记录: ${records.length} 条`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ 数据恢复失败:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
+1101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
# 前端环境变量配置
|
||||
|
||||
# 后端 API 基础地址(开发环境)
|
||||
VITE_API_BASE_URL=http://localhost:3001
|
||||
|
||||
# 生产环境请修改为实际的后端地址
|
||||
# VITE_API_BASE_URL=http://your-api-domain.com
|
||||
@@ -0,0 +1,19 @@
|
||||
// ESLint 配置
|
||||
// 由于Vite已经配置了TypeScript,此文件主要用于基本代码规范检查
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: ['eslint:recommended'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': 'warn',
|
||||
'no-console': 'off',
|
||||
'no-debugger': 'warn',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>个人记账与预算管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+6346
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "personal-finance-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:check": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"echarts": "^6.0.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.0",
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.0.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,28 @@
|
||||
:root {
|
||||
--primary: #0052ff;
|
||||
--primary-hover: #3761ff;
|
||||
--primary-light: rgba(0, 82, 255, 0.1);
|
||||
--success: #00c853;
|
||||
--danger: #ff3d00;
|
||||
--bg: #f5f5f5;
|
||||
--surface: #ffffff;
|
||||
--border: #e5e5e5;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #737373;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
|
||||
import Layout from './components/layout/Layout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Record from './pages/Record'
|
||||
import Budget from './pages/Budget'
|
||||
import Statistics from './pages/Statistics'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/record" element={<Record />} />
|
||||
<Route path="/budget" element={<Budget />} />
|
||||
<Route path="/statistics" element={<Statistics />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348"><circle cx="0" cy="0" r="2.05" fill="#61dafb"/><g stroke="#61dafb" stroke-width="1" fill="none"><ellipse rx="11" ry="4.2"/><ellipse rx="11" ry="4.2" transform="rotate(60)"/><ellipse rx="11" ry="4.2" transform="rotate(120)"/></g></svg>
|
||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
|
||||
const BottomTab: React.FC = () => {
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '首页', icon: HomeIcon },
|
||||
{ path: '/record', label: '记账', icon: RecordIcon },
|
||||
{ path: '/budget', label: '预算', icon: BudgetIcon },
|
||||
{ path: '/statistics', label: '统计', icon: StatisticsIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="bottom-tab-bar" role="navigation" aria-label="底部导航">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`tab-item ${isActive ? 'active' : ''}`
|
||||
}
|
||||
aria-current={location.pathname === item.path ? 'page' : undefined}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<style>{`
|
||||
:root {
|
||||
--primary: #0052ff;
|
||||
--surface: #ffffff;
|
||||
--border: #e5e5e5;
|
||||
--text-secondary: #737373;
|
||||
}
|
||||
|
||||
.bottom-tab-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.bottom-tab-bar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
function HomeIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="24" height="24">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="24" height="24">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="24" height="24">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatisticsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="24" height="24">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default BottomTab;
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import Sidebar from './Sidebar';
|
||||
import BottomTab from './BottomTab';
|
||||
import { useUiStore } from '../../stores';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 布局组件 - Layout
|
||||
* 功能:定义应用整体布局结构,包含侧边导航栏、主内容区和底部标签栏
|
||||
* 布局结构:
|
||||
* - Sidebar(左侧边栏):桌面端固定显示,可折叠(宽度 240px / 64px)
|
||||
* - main(主内容区):根据侧边栏状态动态调整左边距
|
||||
* - BottomTab(底部标签栏):移动端底部导航,固定定位
|
||||
* 响应式策略:
|
||||
* - 桌面端(≥768px):显示侧边栏,主内容区留出左边距
|
||||
* - 移动端(<768px):隐藏侧边栏,使用底部标签栏导航
|
||||
*/
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
// 从 UI 状态中获取侧边栏折叠状态,用于动态调整主内容区宽度
|
||||
const { sidebarCollapsed } = useUiStore();
|
||||
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<Sidebar />
|
||||
<main
|
||||
className="main-content"
|
||||
role="main"
|
||||
style={{
|
||||
marginLeft: sidebarCollapsed ? '64px' : '240px',
|
||||
padding: '32px',
|
||||
maxWidth: '1280px',
|
||||
transition: 'margin-left 0.3s ease'
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<BottomTab />
|
||||
|
||||
<style>{`
|
||||
.app-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.main-content {
|
||||
margin-left: 0 !important;
|
||||
padding: 20px;
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1440px) {
|
||||
.main-content {
|
||||
max-width: 1440px;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,290 @@
|
||||
import React from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { useUiStore } from '../../stores';
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const { sidebarCollapsed, toggleSidebar } = useUiStore();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '首页', icon: HomeIcon },
|
||||
{ path: '/record', label: '记账', icon: RecordIcon },
|
||||
{ path: '/budget', label: '预算', icon: BudgetIcon },
|
||||
{ path: '/statistics', label: '统计', icon: StatisticsIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}
|
||||
role="navigation"
|
||||
aria-label="侧边导航"
|
||||
>
|
||||
<div className="sidebar-logo">
|
||||
<div className="sidebar-logo-content">
|
||||
<LogoIcon />
|
||||
{!sidebarCollapsed && <span>个人记账</span>}
|
||||
</div>
|
||||
<div
|
||||
className="sidebar-toggle-wrapper"
|
||||
data-tooltip={sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||
>
|
||||
<button
|
||||
className="sidebar-toggle"
|
||||
type="button"
|
||||
aria-label="折叠/展开侧边栏"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`sidebar-nav-item ${isActive ? 'active' : ''}`
|
||||
}
|
||||
aria-current={location.pathname === item.path ? 'page' : undefined}
|
||||
>
|
||||
<item.icon />
|
||||
{!sidebarCollapsed && <span>{item.label}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<style>{`
|
||||
:root {
|
||||
--primary: #0052ff;
|
||||
--primary-hover: #3761ff;
|
||||
--primary-light: rgba(0, 82, 255, 0.1);
|
||||
--surface: #ffffff;
|
||||
--border: #e5e5e5;
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #737373;
|
||||
--radius-md: 8px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-6: 24px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
padding: var(--space-6);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
transition: width 0.3s ease, padding 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 64px;
|
||||
padding: var(--space-6) var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-6);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-logo-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.sidebar-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.sidebar-nav-item:hover {
|
||||
background: var(--primary-light);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-nav-item.active {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-nav-item {
|
||||
justify-content: center;
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-nav-item span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-toggle svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.sidebar-toggle svg {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.sidebar-toggle-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sidebar-toggle-wrapper::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: calc(100% + 12px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-toggle-wrapper::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(100% + 4px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-right-color: #1a1a1a;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.15s ease;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sidebar-toggle-wrapper:hover::after,
|
||||
.sidebar-toggle-wrapper:hover::before {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
// SVG Icons as React components
|
||||
function LogoIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="28" height="28" style={{ color: '#0052ff' }}>
|
||||
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/>
|
||||
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/>
|
||||
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatisticsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,36 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: #1a1a1a;
|
||||
background-color: #f5f5f5;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f5f5f5;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 账户服务 - Accounts API Service
|
||||
* 功能:封装账户相关的 API 调用,提供账户 CRUD 操作
|
||||
* API 依赖:
|
||||
* - GET /api/accounts - 获取账户列表
|
||||
* - GET /api/accounts/:id - 获取单个账户
|
||||
* - POST /api/accounts - 创建账户
|
||||
* - PUT /api/accounts/:id - 更新账户
|
||||
* - DELETE /api/accounts/:id - 删除账户
|
||||
*/
|
||||
import { apiClient } from './apiClient';
|
||||
import type { Account } from '../types';
|
||||
import { useUserStore } from '../stores/userStore';
|
||||
|
||||
export const accountsApi = {
|
||||
// API: GET /api/accounts - 获取当前用户的所有账户
|
||||
async getAccounts(): Promise<Account[]> {
|
||||
const userId = useUserStore.getState().userId;
|
||||
const response = await apiClient.get<Account[]>('/accounts', { userId });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: GET /api/accounts/:id - 获取指定账户详情
|
||||
async getAccount(id: number): Promise<Account> {
|
||||
const response = await apiClient.get<Account>(`/accounts/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: POST /api/accounts - 创建新账户,自动关联当前用户
|
||||
async createAccount(data: Omit<Account, 'id' | 'createdAt' | 'updatedAt'>): Promise<Account> {
|
||||
const userId = useUserStore.getState().userId;
|
||||
const response = await apiClient.post<Account>('/accounts', { ...data, userId });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: PUT /api/accounts/:id - 更新账户信息,禁止修改 userId 和时间字段
|
||||
async updateAccount(id: number, data: Partial<Omit<Account, 'id' | 'userId' | 'createdAt' | 'updatedAt'>>): Promise<Account> {
|
||||
const response = await apiClient.put<Account>(`/accounts/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: DELETE /api/accounts/:id - 删除指定账户
|
||||
async deleteAccount(id: number): Promise<void> {
|
||||
const response = await apiClient.delete<void>(`/accounts/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* API 客户端 - ApiClient
|
||||
* 功能:封装 fetch API,提供统一的 HTTP 请求处理、错误捕获和响应解析
|
||||
* 设计模式:类封装 + 泛型支持,确保类型安全
|
||||
* 使用方式:通过导出单例 apiClient 调用,避免重复实例化
|
||||
*/
|
||||
import type { ApiResponse } from '../types';
|
||||
|
||||
// 直接使用后端 URL,避免 Vite 代理问题
|
||||
// 生产环境应改为环境变量 VITE_API_BASE_URL
|
||||
const API_BASE_URL = 'http://localhost:3001/api';
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* 核心请求方法 - 处理 URL 拼接、请求配置、响应解析和错误捕获
|
||||
* @param endpoint - API 路径(如 '/accounts')
|
||||
* @param options - fetch 配置对象,支持 method、headers、body 等
|
||||
* @returns 标准化的 ApiResponse 对象
|
||||
*/
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
// 默认设置 Content-Type 为 application/json,允许调用方覆盖
|
||||
const config: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
const data = await response.json();
|
||||
|
||||
// 根据 HTTP 状态码判断请求是否成功
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Request failed');
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// 统一错误日志输出,方便调试
|
||||
console.error('API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET 请求 - 支持 URL 查询参数拼接
|
||||
* @param endpoint - API 路径
|
||||
* @param params - 查询参数对象,自动忽略 undefined/null 值
|
||||
*/
|
||||
async get<T>(endpoint: string, params?: Record<string, string | number>): Promise<ApiResponse<T>> {
|
||||
let url = endpoint;
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
url = `${endpoint}?${searchParams.toString()}`;
|
||||
}
|
||||
return this.request<T>(url, { method: 'GET' });
|
||||
}
|
||||
|
||||
// API: POST 请求 - 用于创建资源
|
||||
async post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// API: PUT 请求 - 用于全量更新资源
|
||||
async put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// API: DELETE 请求 - 用于删除资源
|
||||
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
|
||||
return this.request<T>(endpoint, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例 - 全局共享一个 ApiClient 实例
|
||||
export const apiClient = new ApiClient(API_BASE_URL);
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 预算服务 - Budgets API Service
|
||||
* 功能:封装预算相关的 API 调用,提供预算 CRUD 操作
|
||||
* API 依赖:
|
||||
* - GET /api/budgets - 获取预算列表(可按月份筛选)
|
||||
* - GET /api/budgets/:id - 获取单条预算
|
||||
* - POST /api/budgets - 创建预算
|
||||
* - PUT /api/budgets/:id - 更新预算
|
||||
* - DELETE /api/budgets/:id - 删除预算
|
||||
*/
|
||||
import { apiClient } from './apiClient';
|
||||
import type { Budget, BudgetFormData } from '../types';
|
||||
import { useUserStore } from '../stores/userStore';
|
||||
|
||||
export const budgetsApi = {
|
||||
// API: GET /api/budgets - 获取预算列表,支持按月份筛选
|
||||
async getBudgets(month?: string): Promise<Budget[]> {
|
||||
const userId = useUserStore.getState().userId;
|
||||
const params: Record<string, string | number> = { userId };
|
||||
if (month) params.month = month;
|
||||
const response = await apiClient.get<Budget[]>('/budgets', params);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: GET /api/budgets/:id - 获取指定预算详情
|
||||
async getBudget(id: number): Promise<Budget> {
|
||||
const response = await apiClient.get<Budget>(`/budgets/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: POST /api/budgets - 创建新预算,自动关联当前用户
|
||||
async createBudget(data: Omit<BudgetFormData, 'userId'>): Promise<Budget> {
|
||||
const userId = useUserStore.getState().userId;
|
||||
const response = await apiClient.post<Budget>('/budgets', { ...data, userId });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: PUT /api/budgets/:id - 更新预算,支持部分更新
|
||||
async updateBudget(id: number, data: Partial<BudgetFormData>): Promise<Budget> {
|
||||
const response = await apiClient.put<Budget>(`/budgets/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: DELETE /api/budgets/:id - 删除指定预算
|
||||
async deleteBudget(id: number): Promise<void> {
|
||||
const response = await apiClient.delete<void>(`/budgets/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
// Export all API services
|
||||
export * from './apiClient';
|
||||
export * from './accounts';
|
||||
export * from './records';
|
||||
export * from './budgets';
|
||||
export * from './statistics';
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 记录服务 - Records API Service
|
||||
* 功能:封装账单记录相关的 API 调用,提供账单 CRUD 操作
|
||||
* API 依赖:
|
||||
* - GET /api/records - 获取账单记录列表(支持多条件筛选)
|
||||
* - GET /api/records/:id - 获取单条账单记录
|
||||
* - POST /api/records - 创建账单记录
|
||||
* - PUT /api/records/:id - 更新账单记录
|
||||
* - DELETE /api/records/:id - 删除账单记录
|
||||
*/
|
||||
import { apiClient } from './apiClient';
|
||||
import type { Record, RecordFormData } from '../types';
|
||||
import { useUserStore } from '../stores/userStore';
|
||||
|
||||
export const recordsApi = {
|
||||
// API: GET /api/records - 获取账单记录列表,支持按账户、类型、分类、日期范围筛选
|
||||
async getRecords(params?: {
|
||||
accountId?: number;
|
||||
type?: 'income' | 'expense';
|
||||
category?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<Record[]> {
|
||||
const userId = useUserStore.getState().userId;
|
||||
const response = await apiClient.get<Record[]>('/records', { userId, ...params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: GET /api/records/:id - 获取指定账单记录详情
|
||||
async getRecord(id: number): Promise<Record> {
|
||||
const response = await apiClient.get<Record>(`/records/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: POST /api/records - 创建账单记录,自动关联当前用户
|
||||
async createRecord(data: Omit<RecordFormData, 'accountId'> & { accountId: number }): Promise<Record> {
|
||||
const userId = useUserStore.getState().userId;
|
||||
const response = await apiClient.post<Record>('/records', { ...data, userId });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: PUT /api/records/:id - 更新账单记录,支持部分更新
|
||||
async updateRecord(id: number, data: Partial<RecordFormData>): Promise<Record> {
|
||||
const response = await apiClient.put<Record>(`/records/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: DELETE /api/records/:id - 删除指定账单记录
|
||||
async deleteRecord(id: number): Promise<void> {
|
||||
const response = await apiClient.delete<void>(`/records/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
// Statistics API Service
|
||||
import { apiClient } from './apiClient';
|
||||
import type { DashboardSummary, MonthlyStats, TrendStat, MonthlyCompare } from '../types';
|
||||
import { useUserStore } from '../stores/userStore';
|
||||
|
||||
export const statisticsApi = {
|
||||
// API: GET /api/dashboard/summary - 获取仪表盘汇总数据(余额、本月收入/支出、预算进度)
|
||||
async getDashboardSummary(): Promise<DashboardSummary> {
|
||||
const userId = useUserStore.getState().userId;
|
||||
const response = await apiClient.get<DashboardSummary>('/dashboard/summary', { userId });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: GET /api/statistics/monthly - 获取月度分类统计数据(按支出分类聚合)
|
||||
async getMonthlyStats(month: string): Promise<MonthlyStats> {
|
||||
const userId = useUserStore.getState().userId;
|
||||
const response = await apiClient.get<MonthlyStats>('/statistics/monthly', { userId, month });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: GET /api/statistics/trend - 获取日期趋势统计(按天聚合收入/支出)
|
||||
async getTrendStats(startDate?: string, endDate?: string): Promise<TrendStat[]> {
|
||||
const userId = useUserStore.getState().userId;
|
||||
const params: Record<string, string | number> = { userId };
|
||||
if (startDate) params.startDate = startDate;
|
||||
if (endDate) params.endDate = endDate;
|
||||
const response = await apiClient.get<TrendStat[]>('/statistics/trend', params);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// API: GET /api/statistics/compare - 获取本月与上月对比数据
|
||||
async getMonthlyCompare(month: string): Promise<MonthlyCompare> {
|
||||
const userId = useUserStore.getState().userId;
|
||||
const response = await apiClient.get<MonthlyCompare>('/statistics/compare', { userId, month });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 数据状态管理 - DataStore
|
||||
* 功能:使用 Zustand 管理全局数据状态,包括账户、账单、预算、统计数据的 CRUD
|
||||
* 状态流转:每个 action 遵循 loading -> 请求 -> success/error -> loading=false 的流程
|
||||
* 级联刷新:CRUD 操作成功后自动重新拉取列表和仪表盘汇总数据,保证跨页面数据一致性
|
||||
* API 依赖:accountsApi、recordsApi、budgetsApi、statisticsApi
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { Account, Record, Budget, DashboardSummary, TrendStat, MonthlyCompare } from '../types';
|
||||
import { accountsApi, recordsApi, budgetsApi, statisticsApi } from '../services';
|
||||
|
||||
/**
|
||||
* DataState 接口 - 定义全局数据状态的结构
|
||||
*/
|
||||
interface DataState {
|
||||
// ===== 数据实体 =====
|
||||
accounts: Account[]; // 账户列表
|
||||
records: Record[]; // 账单记录列表
|
||||
budgets: Budget[]; // 预算配置列表
|
||||
dashboardSummary: DashboardSummary | null; // 仪表盘汇总数据(余额、收支、预算使用率)
|
||||
trendData: TrendStat[]; // 趋势统计数据(用于折线图)
|
||||
monthlyCompare: MonthlyCompare | null; // 月度对比数据(用于柱状对比图)
|
||||
loading: boolean; // 全局加载状态
|
||||
error: string | null; // 全局错误信息
|
||||
|
||||
// ===== Actions - 数据查询 =====
|
||||
fetchAccounts: () => Promise<void>;
|
||||
fetchRecords: (params?: {
|
||||
accountId?: number;
|
||||
type?: 'income' | 'expense';
|
||||
category?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}) => Promise<void>;
|
||||
fetchBudgets: (month?: string) => Promise<void>;
|
||||
fetchDashboardSummary: () => Promise<void>;
|
||||
fetchTrendData: (startDate: string, endDate: string) => Promise<void>;
|
||||
fetchMonthlyCompare: (month: string) => Promise<void>;
|
||||
|
||||
// ===== Actions - 数据操作 =====
|
||||
createRecord: (data: any) => Promise<void>;
|
||||
updateRecord: (id: number, data: any) => Promise<void>;
|
||||
deleteRecord: (id: number) => Promise<void>;
|
||||
createBudget: (data: any) => Promise<void>;
|
||||
updateBudget: (id: number, data: any) => Promise<void>;
|
||||
deleteBudget: (id: number) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useDataStore = create<DataState>((set, get) => ({
|
||||
accounts: [],
|
||||
records: [],
|
||||
budgets: [],
|
||||
dashboardSummary: null,
|
||||
trendData: [],
|
||||
monthlyCompare: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
// API: GET /api/accounts - 获取账户列表
|
||||
fetchAccounts: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await accountsApi.getAccounts();
|
||||
set({ accounts: data });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// API: GET /api/records - 获取账单记录列表(支持多条件筛选)
|
||||
fetchRecords: async (params) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await recordsApi.getRecords(params);
|
||||
set({ records: data });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// API: GET /api/budgets - 获取预算列表(可按月份筛选)
|
||||
fetchBudgets: async (month) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await budgetsApi.getBudgets(month);
|
||||
set({ budgets: data });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// API: GET /api/statistics/dashboard - 获取仪表盘汇总数据
|
||||
// 同时更新 accounts 状态,因为汇总数据中包含账户信息
|
||||
fetchDashboardSummary: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await statisticsApi.getDashboardSummary();
|
||||
set({ dashboardSummary: data, accounts: data.accounts });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// API: fetchTrendData - 获取日期趋势数据(用于折线图)
|
||||
fetchTrendData: async (startDate: string, endDate: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await statisticsApi.getTrendStats(startDate, endDate);
|
||||
set({ trendData: data });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// API: fetchMonthlyCompare - 获取本月与上月对比数据(用于柱状对比图)
|
||||
fetchMonthlyCompare: async (month: string) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const data = await statisticsApi.getMonthlyCompare(month);
|
||||
set({ monthlyCompare: data });
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// API: POST /api/records - 创建账单记录,成功后级联刷新列表和仪表盘
|
||||
// 级联刷新目的:确保其他页面(如 Dashboard、Budget)能立即看到最新数据
|
||||
createRecord: async (data) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await recordsApi.createRecord(data);
|
||||
await get().fetchRecords();
|
||||
await get().fetchDashboardSummary();
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// API: PUT /api/records/:id - 更新账单记录,成功后级联刷新
|
||||
updateRecord: async (id, data) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await recordsApi.updateRecord(id, data);
|
||||
await get().fetchRecords();
|
||||
await get().fetchDashboardSummary();
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// API: DELETE /api/records/:id - 删除账单记录,成功后级联刷新
|
||||
deleteRecord: async (id) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await recordsApi.deleteRecord(id);
|
||||
await get().fetchRecords();
|
||||
await get().fetchDashboardSummary();
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// API: POST /api/budgets - 创建预算,成功后级联刷新预算列表和仪表盘
|
||||
createBudget: async (data) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await budgetsApi.createBudget(data);
|
||||
await get().fetchBudgets();
|
||||
await get().fetchDashboardSummary();
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// API: PUT /api/budgets/:id - 更新预算,成功后级联刷新
|
||||
updateBudget: async (id, data) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await budgetsApi.updateBudget(id, data);
|
||||
await get().fetchBudgets();
|
||||
await get().fetchDashboardSummary();
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// API: DELETE /api/budgets/:id - 删除预算,成功后级联刷新
|
||||
deleteBudget: async (id) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
await budgetsApi.deleteBudget(id);
|
||||
await get().fetchBudgets();
|
||||
await get().fetchDashboardSummary();
|
||||
} catch (error) {
|
||||
set({ error: (error as Error).message });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
// 清除全局错误信息,用于错误状态重置
|
||||
clearError: () => set({ error: null }),
|
||||
}));
|
||||
@@ -0,0 +1,4 @@
|
||||
// Export all stores
|
||||
export * from './uiStore';
|
||||
export * from './dataStore';
|
||||
export * from './userStore';
|
||||
@@ -0,0 +1,24 @@
|
||||
// UI State Store
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface UiState {
|
||||
sidebarCollapsed: boolean;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUiStore = create<UiState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||
setSidebarCollapsed: (collapsed) =>
|
||||
set(() => ({ sidebarCollapsed: collapsed })),
|
||||
}),
|
||||
{
|
||||
name: 'ui-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 用户状态管理 - UserStore
|
||||
* 功能:集中管理当前用户 userId,替代硬编码的 USER_ID = 6
|
||||
* 持久化:使用 zustand persist 中间件存储到 localStorage,刷新页面不丢失
|
||||
* 后续:接入真实认证系统时,只需替换 userId 的获取方式
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
interface UserState {
|
||||
userId: number;
|
||||
setUserId: (id: number) => void;
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
userId: 6, // MVP 阶段默认用户 ID,后续由登录态覆盖
|
||||
setUserId: (id) => set({ userId: id }),
|
||||
}),
|
||||
{ name: 'user-storage' }
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 类型定义 - Type Definitions
|
||||
* 功能:定义个人财务系统前后端数据契约,确保类型安全
|
||||
* 使用场景:API 请求/响应、状态管理、组件 props、表单数据
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户接口 - 对应数据库 fa_user 表
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账户接口 - 对应数据库 fa_account 表
|
||||
* 用于区分不同资金账户(如微信、支付宝、银行卡、现金)
|
||||
*/
|
||||
export interface Account {
|
||||
id: number;
|
||||
userId: number; // 所属用户 ID,用于权限校验
|
||||
name: string; // 账户名称(如"微信钱包")
|
||||
type: string; // 账户类型(如"cash"、"bank"、"digital")
|
||||
color: string; // 账户图标颜色
|
||||
balance: number; // 当前余额
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单记录接口 - 对应数据库 fa_record 表
|
||||
* 核心业务实体,记录每一笔收支
|
||||
*/
|
||||
export interface Record {
|
||||
id: number;
|
||||
userId: number; // 所属用户 ID
|
||||
accountId: number; // 关联账户 ID
|
||||
type: 'income' | 'expense'; // 收支类型
|
||||
amount: number; // 金额(正数)
|
||||
category: string; // 分类名称(如"餐饮"、"工资")
|
||||
description: string; // 备注说明
|
||||
date: string; // 账单日期(用户选择的日期)
|
||||
account: Account; // 关联账户详情(JOIN 查询填充)
|
||||
createdAt: string; // 创建时间(用于排序和相对时间显示)
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预算配置接口 - 对应数据库 fa_budget 表
|
||||
* 用于设定各分类的月度预算上限
|
||||
*/
|
||||
export interface Budget {
|
||||
id: number;
|
||||
userId: number; // 所属用户 ID
|
||||
category: string; // 预算分类
|
||||
amount: number; // 预算金额上限
|
||||
month: string; // 预算月份(格式:YYYY-MM)
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预算使用状态接口 - 扩展 Budget,增加实际支出和使用率
|
||||
* 用于仪表盘和预算页面的进度展示
|
||||
*/
|
||||
export interface BudgetWithUsage extends Budget {
|
||||
spent: number; // 当月该分类实际支出总额
|
||||
percentage: number; // 预算使用百分比(spent / amount * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘汇总接口 - GET /api/statistics/dashboard 返回数据
|
||||
* 聚合数据,避免前端多次请求
|
||||
*/
|
||||
export interface DashboardSummary {
|
||||
totalBalance: number; // 所有账户总余额
|
||||
monthIncome: number; // 本月总收入
|
||||
monthExpense: number; // 本月总支出
|
||||
accounts: Account[]; // 账户列表
|
||||
budgetUsage: BudgetWithUsage[]; // 预算使用情况列表
|
||||
}
|
||||
|
||||
/**
|
||||
* 月度统计接口 - 用于月度报表页面
|
||||
*/
|
||||
export interface MonthlyStats {
|
||||
totalIncome: number;
|
||||
totalExpense: number;
|
||||
balance: number; // 本月结余(收入 - 支出)
|
||||
categoryStats: Array<{
|
||||
category: string;
|
||||
amount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 趋势统计接口 - GET /api/statistics/trend 返回数据
|
||||
* 用于折线图展示每日收支趋势
|
||||
*/
|
||||
export interface TrendStat {
|
||||
date: string; // 日期(格式:YYYY-MM-DD)
|
||||
income: number; // 当日收入
|
||||
expense: number; // 当日支出
|
||||
}
|
||||
|
||||
/**
|
||||
* 月度对比接口 - GET /api/statistics/monthly-compare 返回数据
|
||||
* 用于柱状图对比本月与上月的收支差异
|
||||
*/
|
||||
export interface MonthlyCompare {
|
||||
currentMonth: {
|
||||
label: string; // 月份标签(如"2024年1月")
|
||||
income: number;
|
||||
expense: number;
|
||||
};
|
||||
lastMonth: {
|
||||
label: string;
|
||||
income: number;
|
||||
expense: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一 API 响应接口 - 所有后端接口返回的标准格式
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean; // 请求是否成功
|
||||
data: T; // 响应数据(泛型支持)
|
||||
message?: string; // 错误信息(可选)
|
||||
}
|
||||
|
||||
/**
|
||||
* 账单表单数据接口 - 新增/编辑账单时的表单输入
|
||||
* 与 Record 的区别:不含 id、createdAt、updatedAt 等系统字段
|
||||
*/
|
||||
export interface RecordFormData {
|
||||
type: 'income' | 'expense';
|
||||
amount: number;
|
||||
category: string;
|
||||
description: string;
|
||||
date: string;
|
||||
accountId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预算表单数据接口 - 新增/编辑预算时的表单输入
|
||||
*/
|
||||
export interface BudgetFormData {
|
||||
category: string;
|
||||
amount: number;
|
||||
month: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日期分组的记录接口 - 用于按日期分组展示账单列表
|
||||
* key 为日期字符串(如 "2024-01-15"),值为该日期的记录数组
|
||||
*/
|
||||
export interface DateGroupedRecords {
|
||||
[key: string]: Record[];
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// 导出工具函数 - 将统计数据导出为 Excel 文件
|
||||
import * as XLSX from 'xlsx';
|
||||
import type { TrendStat, MonthlyCompare } from '../types';
|
||||
|
||||
/**
|
||||
* 导出趋势数据到 Excel
|
||||
* @param data 趋势数据 [{date, income, expense}]
|
||||
* @param filename 文件名(不含扩展名)
|
||||
*/
|
||||
export function exportTrendData(data: TrendStat[], filename: string): void {
|
||||
if (!data || data.length === 0) {
|
||||
throw new Error('没有可导出的趋势数据');
|
||||
}
|
||||
|
||||
// 构建工作表数据
|
||||
const wsData = [
|
||||
['个人记账 - 收支趋势报表', '', '', ''],
|
||||
[`导出时间: ${new Date().toLocaleString('zh-CN')}`, '', '', ''],
|
||||
[], // 空行
|
||||
['日期', '收入(元)', '支出(元)', '盈余(元)'],
|
||||
...data.map(d => [
|
||||
d.date,
|
||||
d.income,
|
||||
d.expense,
|
||||
d.income - d.expense
|
||||
]),
|
||||
[], // 空行
|
||||
// 汇总行
|
||||
['合计',
|
||||
data.reduce((sum, d) => sum + d.income, 0),
|
||||
data.reduce((sum, d) => sum + d.expense, 0),
|
||||
data.reduce((sum, d) => sum + (d.income - d.expense), 0)
|
||||
],
|
||||
];
|
||||
|
||||
// 创建工作表
|
||||
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||||
|
||||
// 合并单元格(标题)
|
||||
ws['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 3 } }];
|
||||
|
||||
// 设置列宽
|
||||
ws['!cols'] = [{ wch: 14 }, { wch: 14 }, { wch: 14 }, { wch: 14 }];
|
||||
|
||||
// 创建工作簿
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '收支趋势');
|
||||
|
||||
// 导出文件
|
||||
XLSX.writeFile(wb, `${filename}.xlsx`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出月度对比数据
|
||||
* @param data 月度对比数据
|
||||
* @param filename 文件名(不含扩展名)
|
||||
*/
|
||||
export function exportCompareData(data: MonthlyCompare, filename: string): void {
|
||||
if (!data) {
|
||||
throw new Error('没有可导出的月度对比数据');
|
||||
}
|
||||
|
||||
const wsData = [
|
||||
['个人记账 - 月度对比报表', '', '', '', ''],
|
||||
[`导出时间: ${new Date().toLocaleString('zh-CN')}`, '', '', '', ''],
|
||||
[], // 空行
|
||||
['项目', data.currentMonth.label, '上月环比', data.lastMonth.label, ''],
|
||||
['收入(元)', data.currentMonth.income, '', data.lastMonth.income, ''],
|
||||
['支出(元)', data.currentMonth.expense, '', data.lastMonth.expense, ''],
|
||||
['盈余(元)',
|
||||
data.currentMonth.income - data.currentMonth.expense,
|
||||
'',
|
||||
data.lastMonth.income - data.lastMonth.expense,
|
||||
''
|
||||
],
|
||||
[], // 空行
|
||||
// 收支占比分析
|
||||
['支出占比',
|
||||
data.currentMonth.income > 0
|
||||
? `${((data.currentMonth.expense / data.currentMonth.income) * 100).toFixed(1)}%`
|
||||
: '0%',
|
||||
'',
|
||||
data.lastMonth.income > 0
|
||||
? `${((data.lastMonth.expense / data.lastMonth.income) * 100).toFixed(1)}%`
|
||||
: '0%',
|
||||
''],
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||||
ws['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 4 } }];
|
||||
ws['!cols'] = [{ wch: 12 }, { wch: 14 }, { wch: 12 }, { wch: 14 }, { wch: 12 }];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '月度对比');
|
||||
|
||||
XLSX.writeFile(wb, `${filename}.xlsx`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出支出构成数据
|
||||
* @param data 分类数据 [{name, value, color}]
|
||||
* @param totalExpense 总支出
|
||||
* @param filename 文件名(不含扩展名)
|
||||
*/
|
||||
export function exportCategoryData(
|
||||
data: { name: string; value: number; color: string }[],
|
||||
totalExpense: number,
|
||||
filename: string
|
||||
): void {
|
||||
if (!data || data.length === 0) {
|
||||
throw new Error('没有可导出的支出构成数据');
|
||||
}
|
||||
|
||||
const wsData = [
|
||||
['个人记账 - 支出构成报表', '', ''],
|
||||
[`导出时间: ${new Date().toLocaleString('zh-CN')}`, '', ''],
|
||||
[`总支出: ¥${totalExpense.toLocaleString()}`, '', ''],
|
||||
[], // 空行
|
||||
['分类', '金额(元)', '占比'],
|
||||
...data.map(item => [
|
||||
item.name,
|
||||
item.value,
|
||||
totalExpense > 0 ? `${((item.value / totalExpense) * 100).toFixed(1)}%` : '0%',
|
||||
]),
|
||||
[], // 空行
|
||||
// 按金额排序的排名
|
||||
['排名', '分类', '金额(元)'],
|
||||
...data
|
||||
.sort((a, b) => b.value - a.value)
|
||||
.map((item, index) => [
|
||||
index + 1,
|
||||
item.name,
|
||||
item.value,
|
||||
]),
|
||||
];
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||||
ws['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }];
|
||||
ws['!cols'] = [{ wch: 10 }, { wch: 14 }, { wch: 10 }];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '支出构成');
|
||||
|
||||
XLSX.writeFile(wb, `${filename}.xlsx`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出全部统计数据(综合报表)
|
||||
* @param params 所有统计数据
|
||||
* @param filename 文件名(不含扩展名)
|
||||
*/
|
||||
export function exportAllStats(
|
||||
params: {
|
||||
trendData?: TrendStat[];
|
||||
monthlyCompare?: MonthlyCompare;
|
||||
categoryData?: { name: string; value: number; color: string }[];
|
||||
totalExpense?: number;
|
||||
},
|
||||
filename: string
|
||||
): void {
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// 添加趋势数据工作表
|
||||
if (params.trendData && params.trendData.length > 0) {
|
||||
const trendWsData = [
|
||||
['日期', '收入(元)', '支出(元)', '盈余(元)'],
|
||||
...params.trendData.map(d => [
|
||||
d.date,
|
||||
d.income,
|
||||
d.expense,
|
||||
d.income - d.expense
|
||||
]),
|
||||
['合计',
|
||||
params.trendData.reduce((sum, d) => sum + d.income, 0),
|
||||
params.trendData.reduce((sum, d) => sum + d.expense, 0),
|
||||
params.trendData.reduce((sum, d) => sum + (d.income - d.expense), 0)
|
||||
],
|
||||
];
|
||||
const ws = XLSX.utils.aoa_to_sheet(trendWsData);
|
||||
ws['!cols'] = [{ wch: 14 }, { wch: 14 }, { wch: 14 }, { wch: 14 }];
|
||||
XLSX.utils.book_append_sheet(wb, ws, '收支趋势');
|
||||
}
|
||||
|
||||
// 添加月度对比工作表
|
||||
if (params.monthlyCompare) {
|
||||
const { monthlyCompare } = params;
|
||||
const compareWsData = [
|
||||
['项目', monthlyCompare.currentMonth.label, monthlyCompare.lastMonth.label],
|
||||
['收入(元)', monthlyCompare.currentMonth.income, monthlyCompare.lastMonth.income],
|
||||
['支出(元)', monthlyCompare.currentMonth.expense, monthlyCompare.lastMonth.expense],
|
||||
['盈余(元)',
|
||||
monthlyCompare.currentMonth.income - monthlyCompare.currentMonth.expense,
|
||||
monthlyCompare.lastMonth.income - monthlyCompare.lastMonth.expense
|
||||
],
|
||||
];
|
||||
const ws = XLSX.utils.aoa_to_sheet(compareWsData);
|
||||
ws['!cols'] = [{ wch: 12 }, { wch: 14 }, { wch: 14 }];
|
||||
XLSX.utils.book_append_sheet(wb, ws, '月度对比');
|
||||
}
|
||||
|
||||
// 添加支出构成工作表
|
||||
if (params.categoryData && params.categoryData.length > 0) {
|
||||
const totalExpense = params.totalExpense || 0;
|
||||
const categoryWsData = [
|
||||
['分类', '金额(元)', '占比'],
|
||||
...params.categoryData.map(item => [
|
||||
item.name,
|
||||
item.value,
|
||||
totalExpense > 0 ? `${((item.value / totalExpense) * 100).toFixed(1)}%` : '0%',
|
||||
]),
|
||||
];
|
||||
const ws = XLSX.utils.aoa_to_sheet(categoryWsData);
|
||||
ws['!cols'] = [{ wch: 10 }, { wch: 14 }, { wch: 10 }];
|
||||
XLSX.utils.book_append_sheet(wb, ws, '支出构成');
|
||||
}
|
||||
|
||||
XLSX.writeFile(wb, `${filename}.xlsx`);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,68 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#0052ff',
|
||||
hover: '#3761ff',
|
||||
light: 'rgba(0, 82, 255, 0.1)',
|
||||
},
|
||||
success: {
|
||||
DEFAULT: '#00c853',
|
||||
hover: '#00b14a',
|
||||
},
|
||||
danger: {
|
||||
DEFAULT: '#ff3d00',
|
||||
hover: '#e63600',
|
||||
},
|
||||
warning: '#ffb300',
|
||||
bg: '#f5f5f5',
|
||||
surface: '#ffffff',
|
||||
border: '#e5e5e5',
|
||||
text: {
|
||||
primary: '#1a1a1a',
|
||||
secondary: '#737373',
|
||||
disabled: '#a3a3a3',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
sm: '4px',
|
||||
md: '8px',
|
||||
lg: '12px',
|
||||
xl: '16px',
|
||||
full: '9999px',
|
||||
},
|
||||
boxShadow: {
|
||||
sm: '0 2px 8px rgba(0, 0, 0, 0.06)',
|
||||
md: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
lg: '0 8px 24px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
spacing: {
|
||||
'1': '4px',
|
||||
'xs': '4px',
|
||||
'2': '8px',
|
||||
'sm': '8px',
|
||||
'3': '12px',
|
||||
'md': '16px',
|
||||
'4': '16px',
|
||||
'5': '20px',
|
||||
'6': '24px',
|
||||
'lg': '24px',
|
||||
'8': '32px',
|
||||
'10': '40px',
|
||||
'xl': '48px',
|
||||
},
|
||||
maxWidth: {
|
||||
'lg': '1280px',
|
||||
'xl': '1440px',
|
||||
'xxl': '1680px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout/bottomtab.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/pages/budget/index.tsx","./src/pages/dashboard/index.tsx","./src/pages/record/index.tsx","./src/pages/statistics/index.tsx","./src/services/accounts.ts","./src/services/apiclient.ts","./src/services/budgets.ts","./src/services/index.ts","./src/services/records.ts","./src/services/statistics.ts","./src/stores/datastore.ts","./src/stores/index.ts","./src/stores/uistore.ts","./src/types/index.ts","./src/utils/exporthelper.ts"],"version":"5.6.3"}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: function (path) { return path; },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,609 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- 页面描述:预算管理页面,展示总体预算概览和各类别预算进度 -->
|
||||
<title>预算管理 - 个人记账</title>
|
||||
<!-- 引入公共样式 -->
|
||||
<link rel="stylesheet" href="./css/style.css">
|
||||
<!-- 引入Google Fonts - Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* ========================================
|
||||
预算页面专用样式
|
||||
======================================== */
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.page-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 概览卡片 */
|
||||
.overview-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: var(--space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.progress-ring-container {
|
||||
position: relative;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-ring {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.progress-ring-bg {
|
||||
fill: none;
|
||||
stroke: var(--border);
|
||||
stroke-width: 12;
|
||||
}
|
||||
|
||||
.progress-ring-fill {
|
||||
fill: none;
|
||||
stroke: var(--primary);
|
||||
stroke-width: 12;
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 408;
|
||||
stroke-dashoffset: 147;
|
||||
}
|
||||
|
||||
.progress-ring-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-ring-percentage {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.progress-ring-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.overview-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.overview-info-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.overview-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.overview-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.overview-stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.overview-stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
.overview-stat-value.used { color: var(--danger); }
|
||||
.overview-stat-value.remaining { color: var(--success); }
|
||||
|
||||
/* 预算列表 */
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-4);
|
||||
padding-left: var(--space-3);
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.budget-list {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-item {
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.budget-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.budget-item:hover {
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.budget-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.budget-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.budget-category-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.budget-category-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.budget-amounts {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
/* Primary 按钮 - 设置预算按钮 */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 18px;
|
||||
min-height: 40px;
|
||||
background: var(--primary);
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-primary svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.budget-progress {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.budget-progress-bar {
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.budget-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.budget-progress-fill.normal { background: var(--success); }
|
||||
.budget-progress-fill.warning { background: var(--warning); }
|
||||
.budget-progress-fill.danger { background: var(--danger); }
|
||||
|
||||
.budget-item-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.budget-percentage { font-size: 12px; font-weight: 600; }
|
||||
.budget-percentage.normal { color: var(--success); }
|
||||
.budget-percentage.warning { color: var(--warning); }
|
||||
.budget-percentage.danger { color: var(--danger); }
|
||||
|
||||
.budget-remaining { font-size: 12px; color: var(--text-secondary); }
|
||||
.budget-remaining.overdue { color: var(--danger); font-weight: 500; }
|
||||
|
||||
.warning-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
padding: 4px 8px;
|
||||
background: rgba(255, 179, 0, 0.15);
|
||||
color: var(--warning);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 767px) {
|
||||
.overview-card {
|
||||
flex-direction: column;
|
||||
padding: var(--space-5);
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.progress-ring-container {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
.overview-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4);
|
||||
width: 100%;
|
||||
}
|
||||
.overview-stat-value { font-size: 20px; }
|
||||
.budget-item { padding: var(--space-4); }
|
||||
.page-title { font-size: 18px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 主布局容器 -->
|
||||
<div class="app-layout">
|
||||
<!-- 桌面端侧边栏 -->
|
||||
<aside class="sidebar" role="navigation" aria-label="侧边导航">
|
||||
<div class="sidebar-logo">
|
||||
<div class="sidebar-logo-content">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24">
|
||||
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/>
|
||||
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/>
|
||||
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z"/>
|
||||
</svg>
|
||||
<span>个人记账</span>
|
||||
</div>
|
||||
<!-- 折叠按钮 -->
|
||||
<div class="sidebar-toggle-wrapper" data-tooltip="收起侧边栏">
|
||||
<button class="sidebar-toggle" type="button" aria-label="折叠/展开侧边栏">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="index.html" class="sidebar-nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
<span>首页</span>
|
||||
</a>
|
||||
<a href="record.html" class="sidebar-nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
<span>记账</span>
|
||||
</a>
|
||||
<a href="budget.html" class="sidebar-nav-item active" aria-current="page">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<span>预算</span>
|
||||
</a>
|
||||
<a href="statistics.html" class="sidebar-nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
<span>统计</span>
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="main-content">
|
||||
<!-- 顶部操作栏 -->
|
||||
<header class="page-header">
|
||||
<div class="page-header-left">
|
||||
<button class="back-btn" aria-label="返回">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="page-title">预算管理</h1>
|
||||
</div>
|
||||
<button class="btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
<span>设置预算</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- 总体概览卡片 -->
|
||||
<section class="overview-card">
|
||||
<!-- 环形进度图 - SVG实现 -->
|
||||
<div class="progress-ring-container">
|
||||
<svg class="progress-ring" width="160" height="160">
|
||||
<circle
|
||||
class="progress-ring-bg"
|
||||
cx="80"
|
||||
cy="80"
|
||||
r="65"
|
||||
/>
|
||||
<circle
|
||||
class="progress-ring-fill"
|
||||
cx="80"
|
||||
cy="80"
|
||||
r="65"
|
||||
/>
|
||||
</svg>
|
||||
<div class="progress-ring-text">
|
||||
<div class="progress-ring-percentage">64%</div>
|
||||
<div class="progress-ring-label">已使用</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 概览信息 -->
|
||||
<div class="overview-info">
|
||||
<h2 class="overview-info-title">本月总预算</h2>
|
||||
<div class="overview-stats">
|
||||
<div class="overview-stat">
|
||||
<span class="overview-stat-label">总预算</span>
|
||||
<span class="overview-stat-value total">¥5,000</span>
|
||||
</div>
|
||||
<div class="overview-stat">
|
||||
<span class="overview-stat-label">已用</span>
|
||||
<span class="overview-stat-value used">¥3,200</span>
|
||||
</div>
|
||||
<div class="overview-stat">
|
||||
<span class="overview-stat-label">剩余</span>
|
||||
<span class="overview-stat-value remaining">¥1,800</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 类别预算列表 -->
|
||||
<h3 class="section-title">类别预算</h3>
|
||||
<div class="budget-list">
|
||||
<!-- 餐饮 - 70% 正常 -->
|
||||
<article class="budget-item">
|
||||
<div class="budget-item-header">
|
||||
<div class="budget-item-left">
|
||||
<div class="budget-category-icon" style="background: rgba(255, 179, 0, 0.15);">
|
||||
<!-- 餐饮图标 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="budget-category-name">餐饮</span>
|
||||
</div>
|
||||
<button class="btn-ghost" aria-label="编辑餐饮预算">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="budget-amounts">¥1,050 / ¥1,500</div>
|
||||
<div class="budget-progress">
|
||||
<div class="budget-progress-bar">
|
||||
<div class="budget-progress-fill normal" style="width: 70%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="budget-item-footer">
|
||||
<span class="budget-percentage normal">70%</span>
|
||||
<span class="budget-remaining">剩余 ¥450</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 交通 - 45% 正常 -->
|
||||
<article class="budget-item">
|
||||
<div class="budget-item-header">
|
||||
<div class="budget-item-left">
|
||||
<div class="budget-category-icon" style="background: rgba(0, 184, 212, 0.15);">
|
||||
<!-- 交通图标 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7h8m-8 4h8m-6 4h4M4 7h.01M4 15h16a1 1 0 001-1V8a1 1 0 00-1-1H4a1 1 0 00-1 1v6a1 1 0 001 1z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="budget-category-name">交通</span>
|
||||
</div>
|
||||
<button class="btn-ghost" aria-label="编辑交通预算">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="budget-amounts">¥225 / ¥500</div>
|
||||
<div class="budget-progress">
|
||||
<div class="budget-progress-bar">
|
||||
<div class="budget-progress-fill normal" style="width: 45%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="budget-item-footer">
|
||||
<span class="budget-percentage normal">45%</span>
|
||||
<span class="budget-remaining">剩余 ¥275</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 购物 - 25% 正常 -->
|
||||
<article class="budget-item">
|
||||
<div class="budget-item-header">
|
||||
<div class="budget-item-left">
|
||||
<div class="budget-category-icon" style="background: rgba(156, 39, 176, 0.15);">
|
||||
<!-- 购物图标 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="budget-category-name">购物</span>
|
||||
</div>
|
||||
<button class="btn-ghost" aria-label="编辑购物预算">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="budget-amounts">¥250 / ¥1,000</div>
|
||||
<div class="budget-progress">
|
||||
<div class="budget-progress-bar">
|
||||
<div class="budget-progress-fill normal" style="width: 25%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="budget-item-footer">
|
||||
<span class="budget-percentage normal">25%</span>
|
||||
<span class="budget-remaining">剩余 ¥750</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 娱乐 - 104% 超支 -->
|
||||
<article class="budget-item">
|
||||
<div class="budget-item-header">
|
||||
<div class="budget-item-left">
|
||||
<div class="budget-category-icon" style="background: rgba(233, 30, 99, 0.15);">
|
||||
<!-- 娱乐图标 -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="budget-category-name">娱乐</span>
|
||||
<span class="warning-tag">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
超支
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn-ghost" aria-label="编辑娱乐预算">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="budget-amounts">¥520 / ¥500</div>
|
||||
<div class="budget-progress">
|
||||
<div class="budget-progress-bar">
|
||||
<div class="budget-progress-fill danger" style="width: 100%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="budget-item-footer">
|
||||
<span class="budget-percentage danger">104%</span>
|
||||
<span class="budget-remaining overdue">超支 ¥20</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 移动端底部Tab导航 -->
|
||||
<nav class="bottom-tab-bar" role="navigation" aria-label="底部导航">
|
||||
<a href="index.html" class="tab-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
<span>首页</span>
|
||||
</a>
|
||||
<a href="record.html" class="tab-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
<span>记账</span>
|
||||
</a>
|
||||
<a href="budget.html" class="tab-item active" aria-current="page">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<span>预算</span>
|
||||
</a>
|
||||
<a href="statistics.html" class="tab-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
<span>统计</span>
|
||||
</a>
|
||||
</nav>
|
||||
<!-- 侧边栏折叠功能 -->
|
||||
<script src="./js/sidebar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="个人记账与预算管理系统 - 简洁、专业、可信赖的个人财务管理工具">
|
||||
<title>个人记账 - 首页</title>
|
||||
<!-- 引入公共样式 -->
|
||||
<link rel="stylesheet" href="./css/style.css">
|
||||
<!-- 引入Google Fonts - Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<!-- 主布局容器 -->
|
||||
<div class="app-layout">
|
||||
<!-- 桌面端侧边栏 -->
|
||||
<aside class="sidebar" role="navigation" aria-label="侧边导航">
|
||||
<!-- Logo 区域 -->
|
||||
<div class="sidebar-logo">
|
||||
<div class="sidebar-logo-content">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="24" height="24">
|
||||
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/>
|
||||
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/>
|
||||
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z"/>
|
||||
</svg>
|
||||
<span>个人记账</span>
|
||||
</div>
|
||||
<!-- 折叠按钮 -->
|
||||
<div class="sidebar-toggle-wrapper" data-tooltip="收起侧边栏">
|
||||
<button class="sidebar-toggle" type="button" aria-label="折叠/展开侧边栏">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="15 18 9 12 15 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 导航列表 -->
|
||||
<nav class="sidebar-nav">
|
||||
<a href="index.html" class="sidebar-nav-item active" aria-current="page">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
<span>首页</span>
|
||||
</a>
|
||||
<a href="record.html" class="sidebar-nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
<span>记账</span>
|
||||
</a>
|
||||
<a href="budget.html" class="sidebar-nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<span>预算</span>
|
||||
</a>
|
||||
<a href="statistics.html" class="sidebar-nav-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
<span>统计</span>
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<main class="main-content" role="main">
|
||||
<!-- 余额卡片 -->
|
||||
<section class="balance-card" aria-labelledby="balance-title">
|
||||
<p class="balance-label" id="balance-title">当前余额</p>
|
||||
<p class="balance-amount" aria-label="余额 ¥12,580.00">¥ 12,580.00</p>
|
||||
<div class="balance-summary">
|
||||
<div class="balance-summary-item income">
|
||||
<span class="label">本月收入</span>
|
||||
<span class="value">+8,500</span>
|
||||
</div>
|
||||
<div class="balance-summary-item expense">
|
||||
<span class="label">本月支出</span>
|
||||
<span class="value">-3,200</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 快捷操作按钮 -->
|
||||
<section class="quick-actions" aria-label="快捷操作">
|
||||
<button class="btn btn-primary" type="button" aria-label="快速记账">
|
||||
<!-- 加号图标SVG -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
<span>快速记账</span>
|
||||
</button>
|
||||
<button class="btn btn-danger" type="button" aria-label="记支出">
|
||||
<!-- 减号图标SVG -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
<span>记支出</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- 收支双卡片 -->
|
||||
<section class="stats-grid" aria-label="收支概览">
|
||||
<article class="stat-card">
|
||||
<p class="stat-label">本月收入</p>
|
||||
<p class="stat-value income" aria-label="收入 +8,500">+8,500</p>
|
||||
</article>
|
||||
<article class="stat-card">
|
||||
<p class="stat-label">本月支出</p>
|
||||
<p class="stat-value expense" aria-label="支出 -3,200">-3,200</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- 预算进度 -->
|
||||
<section class="budget-card" aria-labelledby="budget-title">
|
||||
<h2 class="section-title" id="budget-title">预算进度</h2>
|
||||
<div class="budget-list" role="list">
|
||||
<!-- 餐饮预算 - 70% 正常 -->
|
||||
<div class="budget-progress-item" role="listitem">
|
||||
<div class="budget-progress-header">
|
||||
<div class="budget-category">
|
||||
<span class="budget-category-icon" aria-hidden="true">🍜</span>
|
||||
<span class="budget-category-name">餐饮</span>
|
||||
</div>
|
||||
<span class="budget-amounts">1,050 / 1,500</span>
|
||||
</div>
|
||||
<div class="budget-progress-bar" role="progressbar" aria-valuenow="70" aria-valuemin="0" aria-valuemax="100" aria-label="餐饮预算已使用70%">
|
||||
<div class="budget-progress-fill normal" style="width: 70%;"></div>
|
||||
</div>
|
||||
<p class="budget-percentage">70%</p>
|
||||
</div>
|
||||
<!-- 交通预算 - 45% 正常 -->
|
||||
<div class="budget-progress-item" role="listitem">
|
||||
<div class="budget-progress-header">
|
||||
<div class="budget-category">
|
||||
<span class="budget-category-icon" aria-hidden="true">🚕</span>
|
||||
<span class="budget-category-name">交通</span>
|
||||
</div>
|
||||
<span class="budget-amounts">225 / 500</span>
|
||||
</div>
|
||||
<div class="budget-progress-bar" role="progressbar" aria-valuenow="45" aria-valuemin="0" aria-valuemax="100" aria-label="交通预算已使用45%">
|
||||
<div class="budget-progress-fill normal" style="width: 45%;"></div>
|
||||
</div>
|
||||
<p class="budget-percentage">45%</p>
|
||||
</div>
|
||||
<!-- 购物预算 - 25% 正常 -->
|
||||
<div class="budget-progress-item" role="listitem">
|
||||
<div class="budget-progress-header">
|
||||
<div class="budget-category">
|
||||
<span class="budget-category-icon" aria-hidden="true">🛒</span>
|
||||
<span class="budget-category-name">购物</span>
|
||||
</div>
|
||||
<span class="budget-amounts">250 / 1,000</span>
|
||||
</div>
|
||||
<div class="budget-progress-bar" role="progressbar" aria-valuenow="25" aria-valuemin="0" aria-valuemax="100" aria-label="购物预算已使用25%">
|
||||
<div class="budget-progress-fill normal" style="width: 25%;"></div>
|
||||
</div>
|
||||
<p class="budget-percentage">25%</p>
|
||||
</div>
|
||||
<!-- 娱乐预算 - 104% 超支 -->
|
||||
<div class="budget-progress-item" role="listitem">
|
||||
<div class="budget-progress-header">
|
||||
<div class="budget-category">
|
||||
<span class="budget-category-icon" aria-hidden="true">🎮</span>
|
||||
<span class="budget-category-name">娱乐</span>
|
||||
</div>
|
||||
<span class="budget-amounts">520 / 500</span>
|
||||
</div>
|
||||
<div class="budget-progress-bar" role="progressbar" aria-valuenow="104" aria-valuemin="0" aria-valuemax="100" aria-label="娱乐预算已超支104%">
|
||||
<div class="budget-progress-fill danger" style="width: 100%;"></div>
|
||||
</div>
|
||||
<p class="budget-percentage danger">
|
||||
104%
|
||||
<span class="budget-warning-icon" aria-label="超支警告">⚠️</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 最近记录 -->
|
||||
<section class="records-card" aria-labelledby="records-title">
|
||||
<h2 class="section-title" id="records-title">最近记录</h2>
|
||||
<div class="records-list" role="list">
|
||||
<!-- 星巴克记录 -->
|
||||
<article class="record-item" role="listitem">
|
||||
<div class="record-icon" aria-hidden="true">🍜</div>
|
||||
<div class="record-content">
|
||||
<p class="record-title">餐饮</p>
|
||||
<p class="record-note">星巴克</p>
|
||||
</div>
|
||||
<div class="record-meta">
|
||||
<p class="record-amount expense" aria-label="支出 -38元">-38</p>
|
||||
<p class="record-time">今天</p>
|
||||
</div>
|
||||
</article>
|
||||
<!-- 打车记录 -->
|
||||
<article class="record-item" role="listitem">
|
||||
<div class="record-icon" aria-hidden="true">🚕</div>
|
||||
<div class="record-content">
|
||||
<p class="record-title">交通</p>
|
||||
<p class="record-note">打车</p>
|
||||
</div>
|
||||
<div class="record-meta">
|
||||
<p class="record-amount expense" aria-label="支出 -25元">-25</p>
|
||||
<p class="record-time">今天</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 移动端底部Tab导航 -->
|
||||
<nav class="bottom-tab-bar" role="navigation" aria-label="底部导航">
|
||||
<a href="index.html" class="tab-item active" aria-current="page">
|
||||
<!-- 首页图标SVG -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||
</svg>
|
||||
<span>首页</span>
|
||||
</a>
|
||||
<a href="record.html" class="tab-item">
|
||||
<!-- 记账图标SVG -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
<span>记账</span>
|
||||
</a>
|
||||
<a href="budget.html" class="tab-item">
|
||||
<!-- 预算图标SVG -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
<span>预算</span>
|
||||
</a>
|
||||
<a href="statistics.html" class="tab-item">
|
||||
<!-- 统计图标SVG -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
<span>统计</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- ECharts CDN -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
||||
<!-- 侧边栏折叠功能 -->
|
||||
<script src="./js/sidebar.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
// 侧边栏折叠功能
|
||||
(function() {
|
||||
const STORAGE_KEY = 'sidebar-collapsed';
|
||||
|
||||
// 初始化
|
||||
function init() {
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const toggleBtn = document.querySelector('.sidebar-toggle');
|
||||
const toggleWrapper = document.querySelector('.sidebar-toggle-wrapper');
|
||||
|
||||
if (!sidebar || !toggleBtn) return;
|
||||
|
||||
// 从 localStorage 恢复状态
|
||||
const isCollapsed = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
if (isCollapsed) {
|
||||
sidebar.classList.add('collapsed');
|
||||
}
|
||||
|
||||
// 更新tooltip文字
|
||||
updateTooltip(sidebar, toggleWrapper);
|
||||
|
||||
// 绑定点击事件
|
||||
toggleBtn.addEventListener('click', function() {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
const collapsed = sidebar.classList.contains('collapsed');
|
||||
localStorage.setItem(STORAGE_KEY, collapsed);
|
||||
|
||||
// 更新tooltip
|
||||
updateTooltip(sidebar, toggleWrapper);
|
||||
|
||||
// 触发自定义事件
|
||||
const event = new CustomEvent('sidebarToggle', {
|
||||
detail: { collapsed: collapsed }
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
// 更新tooltip文字
|
||||
function updateTooltip(sidebar, wrapper) {
|
||||
if (!wrapper) return;
|
||||
const isCollapsed = sidebar.classList.contains('collapsed');
|
||||
wrapper.setAttribute('data-tooltip', isCollapsed ? '展开侧边栏' : '收起侧边栏');
|
||||
}
|
||||
|
||||
// DOM 加载完成后初始化
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user