2239 lines
55 KiB
Markdown
2239 lines
55 KiB
Markdown
# 记账本项目代码优化实施计划
|
||||
|
|
|
|||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task.
|
|||
|
|
|
|||
|
|
**Goal:** 重构和优化记账本项目的代码结构、数据库、错误处理、前端性能和开发体验
|
|||
|
|
|
|||
|
|
**Architecture:** 将单体 server.js 拆分为模块化结构,添加数据库索引优化,统一错误处理,引入前端数据缓存,完善开发工具链
|
|||
|
|
|
|||
|
|
**Tech Stack:** Node.js, Express, SQLite, Next.js 14, React, SWR, Winston
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 文件结构变更
|
|||
|
|
|
|||
|
|
### 后端重构 (server/)
|
|||
|
|
```
|
|||
|
|
server/
|
|||
|
|
├── index.js # 入口文件(原 server.js 简化版)
|
|||
|
|
├── config/
|
|||
|
|
│ └── database.js # 数据库配置和连接
|
|||
|
|
├── middleware/
|
|||
|
|
│ ├── errorHandler.js # 统一错误处理
|
|||
|
|
│ └── validator.js # 请求验证中间件
|
|||
|
|
├── routes/
|
|||
|
|
│ ├── auth.js # 认证路由
|
|||
|
|
│ ├── records.js # 账目路由
|
|||
|
|
│ ├── stats.js # 统计路由
|
|||
|
|
│ ├── recurring.js # 周期性账单路由
|
|||
|
|
│ ├── categories.js # 分类路由
|
|||
|
|
│ └── export.js # 导入导出路由
|
|||
|
|
├── utils/
|
|||
|
|
│ ├── validators.js # 验证函数
|
|||
|
|
│ └── logger.js # 日志工具
|
|||
|
|
└── db/
|
|||
|
|
├── migrations/
|
|||
|
|
│ ├── 001_initial.sql
|
|||
|
|
│ └── 002_add_indexes.sql
|
|||
|
|
└── migrate.js # 迁移脚本
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 前端优化
|
|||
|
|
- `frontend/lib/swr-config.js` - SWR 全局配置
|
|||
|
|
- `frontend/hooks/useRecords.js` - 数据获取 Hook
|
|||
|
|
- `frontend/hooks/useStats.js` - 统计数据 Hook
|
|||
|
|
- `frontend/components/ErrorBoundary.js` - 错误边界
|
|||
|
|
|
|||
|
|
### 开发工具
|
|||
|
|
- `.eslintrc.js` - ESLint 配置
|
|||
|
|
- `.prettierrc` - Prettier 配置
|
|||
|
|
- `jest.config.js` - 测试配置
|
|||
|
|
- `package.json` 脚本更新
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 1: 后端模块化重构
|
|||
|
|
|
|||
|
|
### Task 1: 创建数据库配置模块
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `server/config/database.js`
|
|||
|
|
- Create: `server/db/migrations/001_initial.sql`
|
|||
|
|
- Create: `server/db/migrations/002_add_indexes.sql`
|
|||
|
|
- Create: `server/db/migrate.js`
|
|||
|
|
|
|||
|
|
**步骤:**
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建数据库配置模块**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/config/database.js
|
|||
|
|
const Database = require('better-sqlite3');
|
|||
|
|
const path = require('path');
|
|||
|
|
|
|||
|
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../../accountbook.db');
|
|||
|
|
|
|||
|
|
const db = new Database(DB_PATH);
|
|||
|
|
db.pragma('journal_mode = WAL');
|
|||
|
|
|
|||
|
|
module.exports = db;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建初始迁移文件**
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- server/db/migrations/001_initial.sql
|
|||
|
|
CREATE TABLE IF NOT EXISTS users (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
username TEXT UNIQUE NOT NULL,
|
|||
|
|
password TEXT NOT NULL,
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE TABLE IF NOT EXISTS records (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
user_id INTEGER NOT NULL,
|
|||
|
|
type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
|
|||
|
|
amount REAL NOT NULL,
|
|||
|
|
category TEXT NOT NULL,
|
|||
|
|
date DATETIME NOT NULL,
|
|||
|
|
note TEXT,
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE TABLE IF NOT EXISTS recurring_bills (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
user_id INTEGER NOT NULL,
|
|||
|
|
type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
|
|||
|
|
amount REAL NOT NULL,
|
|||
|
|
category TEXT NOT NULL,
|
|||
|
|
day INTEGER NOT NULL,
|
|||
|
|
note TEXT,
|
|||
|
|
is_active INTEGER DEFAULT 1,
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE TABLE IF NOT EXISTS categories (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
user_id INTEGER NOT NULL,
|
|||
|
|
type TEXT NOT NULL CHECK(type IN ('income', 'expense')),
|
|||
|
|
name TEXT NOT NULL,
|
|||
|
|
icon TEXT NOT NULL,
|
|||
|
|
is_default INTEGER DEFAULT 0,
|
|||
|
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|||
|
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
CREATE TABLE IF NOT EXISTS migrations (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|||
|
|
name TEXT NOT NULL,
|
|||
|
|
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建索引迁移文件**
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- server/db/migrations/002_add_indexes.sql
|
|||
|
|
-- 优化查询性能
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_records_user_date ON records(user_id, date);
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_records_user_category ON records(user_id, category);
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_records_user_type ON records(user_id, type);
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_bills(user_id);
|
|||
|
|
CREATE INDEX IF NOT EXISTS idx_categories_user_type ON categories(user_id, type);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 创建迁移脚本**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/db/migrate.js
|
|||
|
|
const fs = require('fs');
|
|||
|
|
const path = require('path');
|
|||
|
|
const db = require('../config/database');
|
|||
|
|
|
|||
|
|
const migrationsDir = path.join(__dirname, 'migrations');
|
|||
|
|
|
|||
|
|
function getExecutedMigrations() {
|
|||
|
|
return db.prepare('SELECT name FROM migrations').all().map(m => m.name);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function runMigration(filename) {
|
|||
|
|
const filepath = path.join(migrationsDir, filename);
|
|||
|
|
const sql = fs.readFileSync(filepath, 'utf-8');
|
|||
|
|
|
|||
|
|
console.log(`Running migration: ${filename}`);
|
|||
|
|
db.exec(sql);
|
|||
|
|
db.prepare('INSERT INTO migrations (name) VALUES (?)').run(filename);
|
|||
|
|
console.log(`Completed: ${filename}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function migrate() {
|
|||
|
|
const executed = getExecutedMigrations();
|
|||
|
|
const files = fs.readdirSync(migrationsDir)
|
|||
|
|
.filter(f => f.endsWith('.sql'))
|
|||
|
|
.filter(f => !executed.includes(f))
|
|||
|
|
.sort();
|
|||
|
|
|
|||
|
|
for (const file of files) {
|
|||
|
|
runMigration(file);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
console.log('All migrations completed.');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
migrate();
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add server/
|
|||
|
|
git commit -m "feat: add database config and migration system"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 2: 创建工具函数模块
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `server/utils/validators.js`
|
|||
|
|
- Create: `server/utils/logger.js`
|
|||
|
|
- Create: `server/middleware/errorHandler.js`
|
|||
|
|
|
|||
|
|
**步骤:**
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建验证函数模块**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/utils/validators.js
|
|||
|
|
const VALID_RECORD_TYPES = ['income', 'expense'];
|
|||
|
|
const VALID_STATS_PERIODS = ['week', 'month', 'year'];
|
|||
|
|
const VALID_STATS_TYPES = ['income', 'expense', 'all'];
|
|||
|
|
const MONTH_PATTERN = /^\d{4}-\d{2}$/;
|
|||
|
|
const DATE_TIME_PATTERN = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2})?$/;
|
|||
|
|
|
|||
|
|
function isNonEmptyString(value) {
|
|||
|
|
return typeof value === 'string' && value.trim().length > 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isValidMonth(value) {
|
|||
|
|
return typeof value === 'string' && MONTH_PATTERN.test(value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isValidDateTime(value) {
|
|||
|
|
return typeof value === 'string' && DATE_TIME_PATTERN.test(value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizeNote(value) {
|
|||
|
|
return value === undefined || value === null ? '' : value;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function validateRecordPayload(payload) {
|
|||
|
|
if (!payload || typeof payload !== 'object') {
|
|||
|
|
return '请求数据格式错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { type, amount, category, date, note } = payload;
|
|||
|
|
|
|||
|
|
if (!VALID_RECORD_TYPES.includes(type)) {
|
|||
|
|
return '账目类型错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!Number.isFinite(amount) || amount <= 0) {
|
|||
|
|
return '金额需大于0';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isNonEmptyString(category)) {
|
|||
|
|
return '请填写分类';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isValidDateTime(date)) {
|
|||
|
|
return '日期格式错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (note !== undefined && note !== null && typeof note !== 'string') {
|
|||
|
|
return '备注格式错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function validateRecurringPayload(payload) {
|
|||
|
|
if (!payload || typeof payload !== 'object') {
|
|||
|
|
return '请求数据格式错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { type, amount, category, day, note, is_active } = payload;
|
|||
|
|
|
|||
|
|
if (!VALID_RECORD_TYPES.includes(type)) {
|
|||
|
|
return '账单类型错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!Number.isFinite(amount) || amount <= 0) {
|
|||
|
|
return '金额需大于0';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isNonEmptyString(category)) {
|
|||
|
|
return '请填写分类';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!Number.isInteger(day) || day < 1 || day > 28) {
|
|||
|
|
return '日期需在1到28之间';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (note !== undefined && note !== null && typeof note !== 'string') {
|
|||
|
|
return '备注格式错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (is_active !== undefined && ![0, 1, true, false].includes(is_active)) {
|
|||
|
|
return '启用状态错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function validateCategoryPayload(payload) {
|
|||
|
|
if (!payload || typeof payload !== 'object') {
|
|||
|
|
return '请求数据格式错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { type, name, icon } = payload;
|
|||
|
|
|
|||
|
|
if (!VALID_RECORD_TYPES.includes(type)) {
|
|||
|
|
return '分类类型错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isNonEmptyString(name)) {
|
|||
|
|
return '请填写分类名称';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isNonEmptyString(icon)) {
|
|||
|
|
return '请选择分类图标';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function validateImportPayload(payload) {
|
|||
|
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|||
|
|
return '导入数据格式错误';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const { records, recurring, customCategories } = payload;
|
|||
|
|
|
|||
|
|
if (records !== undefined) {
|
|||
|
|
if (!Array.isArray(records)) {
|
|||
|
|
return 'records 必须是数组';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const record of records) {
|
|||
|
|
const error = validateRecordPayload(record);
|
|||
|
|
if (error) {
|
|||
|
|
return `records 数据错误: ${error}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (recurring !== undefined) {
|
|||
|
|
if (!Array.isArray(recurring)) {
|
|||
|
|
return 'recurring 必须是数组';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const bill of recurring) {
|
|||
|
|
const error = validateRecurringPayload(bill);
|
|||
|
|
if (error) {
|
|||
|
|
return `recurring 数据错误: ${error}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (customCategories !== undefined) {
|
|||
|
|
if (!Array.isArray(customCategories)) {
|
|||
|
|
return 'customCategories 必须是数组';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const category of customCategories) {
|
|||
|
|
const error = validateCategoryPayload(category);
|
|||
|
|
if (error) {
|
|||
|
|
return `customCategories 数据错误: ${error}`;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
isNonEmptyString,
|
|||
|
|
isValidMonth,
|
|||
|
|
isValidDateTime,
|
|||
|
|
normalizeNote,
|
|||
|
|
validateRecordPayload,
|
|||
|
|
validateRecurringPayload,
|
|||
|
|
validateCategoryPayload,
|
|||
|
|
validateImportPayload,
|
|||
|
|
VALID_RECORD_TYPES,
|
|||
|
|
VALID_STATS_PERIODS,
|
|||
|
|
VALID_STATS_TYPES,
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建日志工具**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/utils/logger.js
|
|||
|
|
const winston = require('winston');
|
|||
|
|
|
|||
|
|
const logger = winston.createLogger({
|
|||
|
|
level: process.env.LOG_LEVEL || 'info',
|
|||
|
|
format: winston.format.combine(
|
|||
|
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|||
|
|
winston.format.errors({ stack: true }),
|
|||
|
|
winston.format.json()
|
|||
|
|
),
|
|||
|
|
defaultMeta: { service: 'accountbook' },
|
|||
|
|
transports: [
|
|||
|
|
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
|||
|
|
new winston.transports.File({ filename: 'logs/combined.log' }),
|
|||
|
|
],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (process.env.NODE_ENV !== 'production') {
|
|||
|
|
logger.add(new winston.transports.Console({
|
|||
|
|
format: winston.format.combine(
|
|||
|
|
winston.format.colorize(),
|
|||
|
|
winston.format.simple()
|
|||
|
|
)
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = logger;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建统一错误处理中间件**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/middleware/errorHandler.js
|
|||
|
|
const logger = require('../utils/logger');
|
|||
|
|
|
|||
|
|
class AppError extends Error {
|
|||
|
|
constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') {
|
|||
|
|
super(message);
|
|||
|
|
this.statusCode = statusCode;
|
|||
|
|
this.code = code;
|
|||
|
|
this.isOperational = true;
|
|||
|
|
Error.captureStackTrace(this, this.constructor);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function errorHandler(err, req, res, next) {
|
|||
|
|
logger.error({
|
|||
|
|
message: err.message,
|
|||
|
|
stack: err.stack,
|
|||
|
|
url: req.url,
|
|||
|
|
method: req.method,
|
|||
|
|
userId: req.userId,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (err instanceof AppError && err.isOperational) {
|
|||
|
|
return res.status(err.statusCode).json({
|
|||
|
|
error: err.message,
|
|||
|
|
code: err.code,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 数据库唯一性冲突
|
|||
|
|
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
|
|||
|
|
return res.status(400).json({
|
|||
|
|
error: '数据已存在',
|
|||
|
|
code: 'DUPLICATE_ENTRY',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 其他数据库错误
|
|||
|
|
if (err.code && err.code.startsWith('SQLITE_')) {
|
|||
|
|
return res.status(500).json({
|
|||
|
|
error: '数据库错误',
|
|||
|
|
code: 'DATABASE_ERROR',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 默认错误响应
|
|||
|
|
res.status(500).json({
|
|||
|
|
error: process.env.NODE_ENV === 'production'
|
|||
|
|
? '服务器内部错误'
|
|||
|
|
: err.message,
|
|||
|
|
code: 'INTERNAL_ERROR',
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function asyncHandler(fn) {
|
|||
|
|
return (req, res, next) => {
|
|||
|
|
Promise.resolve(fn(req, res, next)).catch(next);
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
AppError,
|
|||
|
|
errorHandler,
|
|||
|
|
asyncHandler,
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 创建认证中间件**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/middleware/auth.js
|
|||
|
|
const jwt = require('jsonwebtoken');
|
|||
|
|
const { AppError } = require('./errorHandler');
|
|||
|
|
|
|||
|
|
const JWT_SECRET = process.env.JWT_SECRET || 'accountbook_secret_key_2024';
|
|||
|
|
|
|||
|
|
function authenticate(req, res, next) {
|
|||
|
|
const token = req.headers.authorization?.split(' ')[1];
|
|||
|
|
if (!token) {
|
|||
|
|
throw new AppError('请先登录', 401, 'UNAUTHORIZED');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const decoded = jwt.verify(token, JWT_SECRET);
|
|||
|
|
req.userId = decoded.userId;
|
|||
|
|
next();
|
|||
|
|
} catch (err) {
|
|||
|
|
throw new AppError('登录已过期', 401, 'TOKEN_EXPIRED');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function generateToken(userId) {
|
|||
|
|
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
authenticate,
|
|||
|
|
generateToken,
|
|||
|
|
JWT_SECRET,
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add server/
|
|||
|
|
git commit -m "feat: add utils, logger and error handling middleware"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 3: 创建路由模块
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `server/routes/auth.js`
|
|||
|
|
- Create: `server/routes/records.js`
|
|||
|
|
- Create: `server/routes/stats.js`
|
|||
|
|
|
|||
|
|
**步骤:**
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建认证路由**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/routes/auth.js
|
|||
|
|
const express = require('express');
|
|||
|
|
const bcrypt = require('bcryptjs');
|
|||
|
|
const db = require('../config/database');
|
|||
|
|
const { generateToken } = require('../middleware/auth');
|
|||
|
|
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
|||
|
|
const logger = require('../utils/logger');
|
|||
|
|
|
|||
|
|
const router = express.Router();
|
|||
|
|
|
|||
|
|
// 注册
|
|||
|
|
router.post('/register', asyncHandler(async (req, res) => {
|
|||
|
|
const { username, password } = req.body;
|
|||
|
|
|
|||
|
|
if (!username || !password) {
|
|||
|
|
throw new AppError('请填写用户名和密码', 400, 'MISSING_FIELDS');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (username.length < 3 || username.length > 20) {
|
|||
|
|
throw new AppError('用户名需3-20个字符', 400, 'INVALID_USERNAME');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (password.length < 6 || password.length > 20) {
|
|||
|
|
throw new AppError('密码需6-20个字符', 400, 'INVALID_PASSWORD');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hashedPassword = await bcrypt.hash(password, 10);
|
|||
|
|
const stmt = db.prepare('INSERT INTO users (username, password) VALUES (?, ?)');
|
|||
|
|
const result = stmt.run(username, hashedPassword);
|
|||
|
|
|
|||
|
|
const token = generateToken(result.lastInsertRowid);
|
|||
|
|
logger.info(`User registered: ${username}`);
|
|||
|
|
|
|||
|
|
res.json({ token, username });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 登录
|
|||
|
|
router.post('/login', asyncHandler(async (req, res) => {
|
|||
|
|
const { username, password } = req.body;
|
|||
|
|
|
|||
|
|
if (!username || !password) {
|
|||
|
|
throw new AppError('请填写用户名和密码', 400, 'MISSING_FIELDS');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare('SELECT * FROM users WHERE username = ?');
|
|||
|
|
const user = stmt.get(username);
|
|||
|
|
|
|||
|
|
if (!user) {
|
|||
|
|
throw new AppError('用户名或密码错误', 400, 'INVALID_CREDENTIALS');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const validPassword = await bcrypt.compare(password, user.password);
|
|||
|
|
if (!validPassword) {
|
|||
|
|
throw new AppError('用户名或密码错误', 400, 'INVALID_CREDENTIALS');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const token = generateToken(user.id);
|
|||
|
|
logger.info(`User logged in: ${username}`);
|
|||
|
|
|
|||
|
|
res.json({ token, username: user.username });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
module.exports = router;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建账目路由**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/routes/records.js
|
|||
|
|
const express = require('express');
|
|||
|
|
const db = require('../config/database');
|
|||
|
|
const { authenticate } = require('../middleware/auth');
|
|||
|
|
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
|||
|
|
const { validateRecordPayload, normalizeNote } = require('../utils/validators');
|
|||
|
|
const logger = require('../utils/logger');
|
|||
|
|
|
|||
|
|
const router = express.Router();
|
|||
|
|
|
|||
|
|
// 获取账目列表
|
|||
|
|
router.get('/', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { month, type } = req.query;
|
|||
|
|
|
|||
|
|
let query = 'SELECT * FROM records WHERE user_id = ?';
|
|||
|
|
const params = [req.userId];
|
|||
|
|
|
|||
|
|
if (month) {
|
|||
|
|
query += ' AND date LIKE ?';
|
|||
|
|
params.push(`${month}%`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (type && type !== 'all') {
|
|||
|
|
query += ' AND type = ?';
|
|||
|
|
params.push(type);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
query += ' ORDER BY date DESC, id DESC';
|
|||
|
|
|
|||
|
|
const stmt = db.prepare(query);
|
|||
|
|
const records = stmt.all(...params);
|
|||
|
|
|
|||
|
|
res.json(records);
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 新增账目
|
|||
|
|
router.post('/', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { type, amount, category, date, note } = req.body;
|
|||
|
|
|
|||
|
|
const validationError = validateRecordPayload(req.body);
|
|||
|
|
if (validationError) {
|
|||
|
|
throw new AppError(validationError, 400, 'VALIDATION_ERROR');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare(`
|
|||
|
|
INSERT INTO records (user_id, type, amount, category, date, note)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
const result = stmt.run(req.userId, type, amount, category.trim(), date, normalizeNote(note));
|
|||
|
|
logger.info(`Record created: user=${req.userId}, id=${result.lastInsertRowid}`);
|
|||
|
|
|
|||
|
|
res.json({ id: result.lastInsertRowid, message: '添加成功' });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 更新账目
|
|||
|
|
router.put('/:id', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { id } = req.params;
|
|||
|
|
const { type, amount, category, date, note } = req.body;
|
|||
|
|
|
|||
|
|
const checkStmt = db.prepare('SELECT * FROM records WHERE id = ? AND user_id = ?');
|
|||
|
|
const record = checkStmt.get(id, req.userId);
|
|||
|
|
|
|||
|
|
if (!record) {
|
|||
|
|
throw new AppError('记录不存在', 404, 'NOT_FOUND');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const validationError = validateRecordPayload(req.body);
|
|||
|
|
if (validationError) {
|
|||
|
|
throw new AppError(validationError, 400, 'VALIDATION_ERROR');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare(`
|
|||
|
|
UPDATE records SET type = ?, amount = ?, category = ?, date = ?, note = ?
|
|||
|
|
WHERE id = ? AND user_id = ?
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
stmt.run(type, amount, category.trim(), date, normalizeNote(note), id, req.userId);
|
|||
|
|
logger.info(`Record updated: user=${req.userId}, id=${id}`);
|
|||
|
|
|
|||
|
|
res.json({ message: '更新成功' });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 删除账目
|
|||
|
|
router.delete('/:id', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { id } = req.params;
|
|||
|
|
|
|||
|
|
const checkStmt = db.prepare('SELECT * FROM records WHERE id = ? AND user_id = ?');
|
|||
|
|
const record = checkStmt.get(id, req.userId);
|
|||
|
|
|
|||
|
|
if (!record) {
|
|||
|
|
throw new AppError('记录不存在', 404, 'NOT_FOUND');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?');
|
|||
|
|
stmt.run(id, req.userId);
|
|||
|
|
logger.info(`Record deleted: user=${req.userId}, id=${id}`);
|
|||
|
|
|
|||
|
|
res.json({ message: '删除成功' });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 批量删除指定分类的记录
|
|||
|
|
router.delete('/by-category/:category', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { category } = req.params;
|
|||
|
|
|
|||
|
|
if (!category || typeof category !== 'string' || !category.trim()) {
|
|||
|
|
throw new AppError('分类不能为空', 400, 'VALIDATION_ERROR');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare('DELETE FROM records WHERE category = ? AND user_id = ?');
|
|||
|
|
const result = stmt.run(category.trim(), req.userId);
|
|||
|
|
|
|||
|
|
logger.info(`Records deleted by category: user=${req.userId}, category=${category}, count=${result.changes}`);
|
|||
|
|
|
|||
|
|
res.json({ message: `已删除 ${result.changes} 条记录` });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
module.exports = router;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建统计路由**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/routes/stats.js
|
|||
|
|
const express = require('express');
|
|||
|
|
const db = require('../config/database');
|
|||
|
|
const { authenticate } = require('../middleware/auth');
|
|||
|
|
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
|||
|
|
const { isValidMonth, VALID_STATS_PERIODS, VALID_STATS_TYPES } = require('../utils/validators');
|
|||
|
|
|
|||
|
|
const router = express.Router();
|
|||
|
|
|
|||
|
|
// 获取月度统计
|
|||
|
|
router.get('/', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { month } = req.query;
|
|||
|
|
|
|||
|
|
if (!month) {
|
|||
|
|
throw new AppError('请指定月份', 400, 'MISSING_MONTH');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare(`
|
|||
|
|
SELECT
|
|||
|
|
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as totalIncome,
|
|||
|
|
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as totalExpense
|
|||
|
|
FROM records
|
|||
|
|
WHERE user_id = ? AND date LIKE ?
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
const result = stmt.get(req.userId, `${month}%`);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
income: result.totalIncome || 0,
|
|||
|
|
expense: result.totalExpense || 0,
|
|||
|
|
balance: (result.totalIncome || 0) - (result.totalExpense || 0),
|
|||
|
|
});
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 收支趋势统计
|
|||
|
|
router.get('/trend', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { period = 'month', month } = req.query;
|
|||
|
|
|
|||
|
|
if (!VALID_STATS_PERIODS.includes(period)) {
|
|||
|
|
throw new AppError('统计周期错误', 400, 'INVALID_PERIOD');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (month && !isValidMonth(month)) {
|
|||
|
|
throw new AppError('月份格式错误', 400, 'INVALID_MONTH');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let dateGroup;
|
|||
|
|
if (period === 'year') {
|
|||
|
|
dateGroup = "strftime('%Y', date)";
|
|||
|
|
} else if (period === 'week') {
|
|||
|
|
dateGroup = "strftime('%Y-%W', date)";
|
|||
|
|
} else {
|
|||
|
|
dateGroup = "strftime('%Y-%m', date)";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let query = `
|
|||
|
|
SELECT
|
|||
|
|
${dateGroup} as period,
|
|||
|
|
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as income,
|
|||
|
|
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as expense
|
|||
|
|
FROM records
|
|||
|
|
WHERE user_id = ?
|
|||
|
|
`;
|
|||
|
|
const params = [req.userId];
|
|||
|
|
|
|||
|
|
if (month) {
|
|||
|
|
query += ' AND date LIKE ?';
|
|||
|
|
params.push(`${month}%`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
query += `
|
|||
|
|
GROUP BY ${dateGroup}
|
|||
|
|
ORDER BY period DESC
|
|||
|
|
LIMIT 12
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
const stmt = db.prepare(query);
|
|||
|
|
const result = stmt.all(...params);
|
|||
|
|
res.json(result.reverse());
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 分类统计
|
|||
|
|
router.get('/category', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { month, type = 'all' } = req.query;
|
|||
|
|
|
|||
|
|
if (!VALID_STATS_TYPES.includes(type)) {
|
|||
|
|
throw new AppError('统计类型错误', 400, 'INVALID_TYPE');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (month && !isValidMonth(month)) {
|
|||
|
|
throw new AppError('月份格式错误', 400, 'INVALID_MONTH');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let query = `
|
|||
|
|
SELECT
|
|||
|
|
category,
|
|||
|
|
SUM(amount) as total,
|
|||
|
|
type
|
|||
|
|
FROM records
|
|||
|
|
WHERE user_id = ?
|
|||
|
|
`;
|
|||
|
|
const params = [req.userId];
|
|||
|
|
|
|||
|
|
if (month) {
|
|||
|
|
query += ' AND date LIKE ?';
|
|||
|
|
params.push(`${month}%`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (type !== 'all') {
|
|||
|
|
query += ' AND type = ?';
|
|||
|
|
params.push(type);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
query += `
|
|||
|
|
GROUP BY category, type
|
|||
|
|
ORDER BY total DESC
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
const stmt = db.prepare(query);
|
|||
|
|
const result = stmt.all(...params);
|
|||
|
|
res.json(result);
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
module.exports = router;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add server/routes/
|
|||
|
|
git commit -m "feat: add auth, records, and stats routes"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 4: 创建剩余路由模块
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `server/routes/recurring.js`
|
|||
|
|
- Create: `server/routes/categories.js`
|
|||
|
|
- Create: `server/routes/export.js`
|
|||
|
|
- Create: `server/routes/search.js`
|
|||
|
|
|
|||
|
|
**步骤:**
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建周期性账单路由**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/routes/recurring.js
|
|||
|
|
const express = require('express');
|
|||
|
|
const db = require('../config/database');
|
|||
|
|
const { authenticate } = require('../middleware/auth');
|
|||
|
|
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
|||
|
|
const { validateRecurringPayload, normalizeNote } = require('../utils/validators');
|
|||
|
|
const logger = require('../utils/logger');
|
|||
|
|
|
|||
|
|
const router = express.Router();
|
|||
|
|
|
|||
|
|
// 获取周期性账单
|
|||
|
|
router.get('/', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const stmt = db.prepare('SELECT * FROM recurring_bills WHERE user_id = ? ORDER BY day ASC');
|
|||
|
|
const bills = stmt.all(req.userId);
|
|||
|
|
res.json(bills);
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 新增周期性账单
|
|||
|
|
router.post('/', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { type, amount, category, day, note } = req.body;
|
|||
|
|
|
|||
|
|
const validationError = validateRecurringPayload(req.body);
|
|||
|
|
if (validationError) {
|
|||
|
|
throw new AppError(validationError, 400, 'VALIDATION_ERROR');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare(`
|
|||
|
|
INSERT INTO recurring_bills (user_id, type, amount, category, day, note)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
const result = stmt.run(req.userId, type, amount, category.trim(), day, normalizeNote(note));
|
|||
|
|
logger.info(`Recurring bill created: user=${req.userId}, id=${result.lastInsertRowid}`);
|
|||
|
|
|
|||
|
|
res.json({ id: result.lastInsertRowid, message: '添加成功' });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 更新周期性账单
|
|||
|
|
router.put('/:id', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { id } = req.params;
|
|||
|
|
const { type, amount, category, day, note, is_active } = req.body;
|
|||
|
|
|
|||
|
|
const checkStmt = db.prepare('SELECT * FROM recurring_bills WHERE id = ? AND user_id = ?');
|
|||
|
|
const bill = checkStmt.get(id, req.userId);
|
|||
|
|
|
|||
|
|
if (!bill) {
|
|||
|
|
throw new AppError('账单不存在', 404, 'NOT_FOUND');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const validationError = validateRecurringPayload(req.body);
|
|||
|
|
if (validationError) {
|
|||
|
|
throw new AppError(validationError, 400, 'VALIDATION_ERROR');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare(`
|
|||
|
|
UPDATE recurring_bills SET type = ?, amount = ?, category = ?, day = ?, note = ?, is_active = ?
|
|||
|
|
WHERE id = ? AND user_id = ?
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
stmt.run(type, amount, category.trim(), day, normalizeNote(note), is_active !== undefined ? is_active : 1, id, req.userId);
|
|||
|
|
logger.info(`Recurring bill updated: user=${req.userId}, id=${id}`);
|
|||
|
|
|
|||
|
|
res.json({ message: '更新成功' });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 删除周期性账单
|
|||
|
|
router.delete('/:id', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { id } = req.params;
|
|||
|
|
|
|||
|
|
const checkStmt = db.prepare('SELECT * FROM recurring_bills WHERE id = ? AND user_id = ?');
|
|||
|
|
const bill = checkStmt.get(id, req.userId);
|
|||
|
|
|
|||
|
|
if (!bill) {
|
|||
|
|
throw new AppError('账单不存在', 404, 'NOT_FOUND');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare('DELETE FROM recurring_bills WHERE id = ? AND user_id = ?');
|
|||
|
|
stmt.run(id, req.userId);
|
|||
|
|
logger.info(`Recurring bill deleted: user=${req.userId}, id=${id}`);
|
|||
|
|
|
|||
|
|
res.json({ message: '删除成功' });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 应用周期性账单
|
|||
|
|
router.post('/:id/apply', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { id } = req.params;
|
|||
|
|
|
|||
|
|
const checkStmt = db.prepare('SELECT * FROM recurring_bills WHERE id = ? AND user_id = ? AND is_active = 1');
|
|||
|
|
const bill = checkStmt.get(id, req.userId);
|
|||
|
|
|
|||
|
|
if (!bill) {
|
|||
|
|
throw new AppError('账单不存在或已禁用', 404, 'NOT_FOUND');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 创建当前日期的账目
|
|||
|
|
const today = new Date();
|
|||
|
|
const dateStr = today.toISOString().split('T')[0] + ' ' + today.toTimeString().slice(0, 5);
|
|||
|
|
|
|||
|
|
const insertStmt = db.prepare(`
|
|||
|
|
INSERT INTO records (user_id, type, amount, category, date, note)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
insertStmt.run(req.userId, bill.type, bill.amount, bill.category, dateStr, bill.note || '');
|
|||
|
|
logger.info(`Recurring bill applied: user=${req.userId}, billId=${id}`);
|
|||
|
|
|
|||
|
|
res.json({ message: '应用成功' });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
module.exports = router;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建分类路由**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/routes/categories.js
|
|||
|
|
const express = require('express');
|
|||
|
|
const db = require('../config/database');
|
|||
|
|
const { authenticate } = require('../middleware/auth');
|
|||
|
|
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
|||
|
|
const { validateCategoryPayload } = require('../utils/validators');
|
|||
|
|
const logger = require('../utils/logger');
|
|||
|
|
|
|||
|
|
const router = express.Router();
|
|||
|
|
|
|||
|
|
const defaultCategories = {
|
|||
|
|
expense: [
|
|||
|
|
{ name: '餐饮', icon: '🍜' },
|
|||
|
|
{ name: '买菜', icon: '🥬' },
|
|||
|
|
{ name: '交通', icon: '🚗' },
|
|||
|
|
{ name: '购物', icon: '🛒' },
|
|||
|
|
{ name: '娱乐', icon: '🎮' },
|
|||
|
|
{ name: '房租', icon: '🏠' },
|
|||
|
|
{ name: '房贷', icon: '🏦' },
|
|||
|
|
{ name: '水电', icon: '💧' },
|
|||
|
|
{ name: '衣服', icon: '👔' },
|
|||
|
|
{ name: '通讯', icon: '📱' },
|
|||
|
|
{ name: '日用品', icon: '🧴' },
|
|||
|
|
{ name: '美妆', icon: '💄' },
|
|||
|
|
{ name: '医疗', icon: '🏥' },
|
|||
|
|
{ name: '养娃', icon: '👶' },
|
|||
|
|
{ name: '家电', icon: '📺' },
|
|||
|
|
{ name: '家具', icon: '🛋️' },
|
|||
|
|
{ name: '装修', icon: '🔨' },
|
|||
|
|
{ name: '宠物', icon: '🐕' },
|
|||
|
|
{ name: '其他', icon: '📝' },
|
|||
|
|
],
|
|||
|
|
income: [
|
|||
|
|
{ name: '工资', icon: '💰' },
|
|||
|
|
{ name: '奖金', icon: '🎁' },
|
|||
|
|
{ name: '理财', icon: '📈' },
|
|||
|
|
{ name: '红包', icon: '🧧' },
|
|||
|
|
{ name: '其他', icon: '📝' },
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取分类列表
|
|||
|
|
router.get('/', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const stmt = db.prepare('SELECT * FROM categories WHERE user_id = ? ORDER BY type, id');
|
|||
|
|
const customCategories = stmt.all(req.userId);
|
|||
|
|
|
|||
|
|
// 合并默认分类和自定义分类
|
|||
|
|
const categories = {
|
|||
|
|
expense: [
|
|||
|
|
...defaultCategories.expense.map((c) => ({ ...c, is_default: true })),
|
|||
|
|
...customCategories.filter((c) => c.type === 'expense'),
|
|||
|
|
],
|
|||
|
|
income: [
|
|||
|
|
...defaultCategories.income.map((c) => ({ ...c, is_default: true })),
|
|||
|
|
...customCategories.filter((c) => c.type === 'income'),
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
res.json(categories);
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 新增自定义分类
|
|||
|
|
router.post('/', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { type, name, icon } = req.body;
|
|||
|
|
|
|||
|
|
const validationError = validateCategoryPayload(req.body);
|
|||
|
|
if (validationError) {
|
|||
|
|
throw new AppError(validationError, 400, 'VALIDATION_ERROR');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare(`
|
|||
|
|
INSERT INTO categories (user_id, type, name, icon, is_default)
|
|||
|
|
VALUES (?, ?, ?, ?, 0)
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
const result = stmt.run(req.userId, type, name.trim(), icon.trim());
|
|||
|
|
logger.info(`Category created: user=${req.userId}, id=${result.lastInsertRowid}`);
|
|||
|
|
|
|||
|
|
res.json({ id: result.lastInsertRowid, message: '添加成功' });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 删除自定义分类
|
|||
|
|
router.delete('/:id', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { id } = req.params;
|
|||
|
|
|
|||
|
|
const checkStmt = db.prepare('SELECT * FROM categories WHERE id = ? AND user_id = ? AND is_default = 0');
|
|||
|
|
const category = checkStmt.get(id, req.userId);
|
|||
|
|
|
|||
|
|
if (!category) {
|
|||
|
|
throw new AppError('分类不存在或无法删除', 404, 'NOT_FOUND');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?');
|
|||
|
|
stmt.run(id, req.userId);
|
|||
|
|
logger.info(`Category deleted: user=${req.userId}, id=${id}`);
|
|||
|
|
|
|||
|
|
res.json({ message: '删除成功' });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
module.exports = router;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建导入导出和搜索路由**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/routes/export.js
|
|||
|
|
const express = require('express');
|
|||
|
|
const db = require('../config/database');
|
|||
|
|
const { authenticate } = require('../middleware/auth');
|
|||
|
|
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
|||
|
|
const { validateImportPayload, normalizeNote } = require('../utils/validators');
|
|||
|
|
const logger = require('../utils/logger');
|
|||
|
|
|
|||
|
|
const router = express.Router();
|
|||
|
|
|
|||
|
|
// 导出数据
|
|||
|
|
router.get('/', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const recordsStmt = db.prepare('SELECT * FROM records WHERE user_id = ? ORDER BY date DESC');
|
|||
|
|
const records = recordsStmt.all(req.userId);
|
|||
|
|
|
|||
|
|
const recurringStmt = db.prepare('SELECT * FROM recurring_bills WHERE user_id = ?');
|
|||
|
|
const recurring = recurringStmt.all(req.userId);
|
|||
|
|
|
|||
|
|
const categoriesStmt = db.prepare('SELECT * FROM categories WHERE user_id = ?');
|
|||
|
|
const categories = categoriesStmt.all(req.userId);
|
|||
|
|
|
|||
|
|
logger.info(`Data exported: user=${req.userId}`);
|
|||
|
|
|
|||
|
|
res.json({
|
|||
|
|
version: '1.0',
|
|||
|
|
exportDate: new Date().toISOString(),
|
|||
|
|
records,
|
|||
|
|
recurring,
|
|||
|
|
customCategories: categories,
|
|||
|
|
});
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 导入数据
|
|||
|
|
router.post('/', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { records: importRecords, recurring: importRecurring, customCategories: importCategories } = req.body;
|
|||
|
|
|
|||
|
|
const validationError = validateImportPayload(req.body);
|
|||
|
|
if (validationError) {
|
|||
|
|
throw new AppError(validationError, 400, 'VALIDATION_ERROR');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const importCount = { records: 0, recurring: 0, categories: 0 };
|
|||
|
|
|
|||
|
|
if (importRecords && Array.isArray(importRecords)) {
|
|||
|
|
const insertRecord = db.prepare(`
|
|||
|
|
INSERT INTO records (user_id, type, amount, category, date, note, created_at)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
const insertMany = db.transaction((records) => {
|
|||
|
|
for (const r of records) {
|
|||
|
|
insertRecord.run(
|
|||
|
|
req.userId,
|
|||
|
|
r.type,
|
|||
|
|
r.amount,
|
|||
|
|
r.category.trim(),
|
|||
|
|
r.date,
|
|||
|
|
normalizeNote(r.note),
|
|||
|
|
r.created_at || new Date().toISOString()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
insertMany(importRecords);
|
|||
|
|
importCount.records = importRecords.length;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (importRecurring && Array.isArray(importRecurring)) {
|
|||
|
|
const insertRecurring = db.prepare(`
|
|||
|
|
INSERT INTO recurring_bills (user_id, type, amount, category, day, note, is_active, created_at)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
const insertRecurringMany = db.transaction((bills) => {
|
|||
|
|
for (const b of bills) {
|
|||
|
|
insertRecurring.run(
|
|||
|
|
req.userId,
|
|||
|
|
b.type,
|
|||
|
|
b.amount,
|
|||
|
|
b.category.trim(),
|
|||
|
|
b.day,
|
|||
|
|
normalizeNote(b.note),
|
|||
|
|
b.is_active !== undefined ? b.is_active : 1,
|
|||
|
|
b.created_at || new Date().toISOString()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
insertRecurringMany(importRecurring);
|
|||
|
|
importCount.recurring = importRecurring.length;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (importCategories && Array.isArray(importCategories)) {
|
|||
|
|
const insertCategory = db.prepare(`
|
|||
|
|
INSERT INTO categories (user_id, type, name, icon, is_default, created_at)
|
|||
|
|
VALUES (?, ?, ?, ?, 0, ?)
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
const insertCategoryMany = db.transaction((cats) => {
|
|||
|
|
for (const c of cats) {
|
|||
|
|
insertCategory.run(
|
|||
|
|
req.userId,
|
|||
|
|
c.type,
|
|||
|
|
c.name.trim(),
|
|||
|
|
c.icon.trim(),
|
|||
|
|
c.created_at || new Date().toISOString()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
insertCategoryMany(importCategories);
|
|||
|
|
importCount.categories = importCategories.length;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
logger.info(`Data imported: user=${req.userId}, counts=${JSON.stringify(importCount)}`);
|
|||
|
|
|
|||
|
|
res.json({ message: '导入成功', count: importCount });
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
module.exports = router;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/routes/search.js
|
|||
|
|
const express = require('express');
|
|||
|
|
const db = require('../config/database');
|
|||
|
|
const { authenticate } = require('../middleware/auth');
|
|||
|
|
const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
|||
|
|
|
|||
|
|
const router = express.Router();
|
|||
|
|
|
|||
|
|
// 搜索账目
|
|||
|
|
router.get('/', authenticate, asyncHandler(async (req, res) => {
|
|||
|
|
const { q } = req.query;
|
|||
|
|
|
|||
|
|
if (!q) {
|
|||
|
|
throw new AppError('请输入搜索关键词', 400, 'MISSING_QUERY');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stmt = db.prepare(`
|
|||
|
|
SELECT * FROM records
|
|||
|
|
WHERE user_id = ? AND (category LIKE ? OR note LIKE ?)
|
|||
|
|
ORDER BY date DESC, id DESC
|
|||
|
|
LIMIT 50
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
const records = stmt.all(req.userId, `%${q}%`, `%${q}%`);
|
|||
|
|
res.json(records);
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
module.exports = router;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add server/routes/
|
|||
|
|
git commit -m "feat: add recurring, categories, export and search routes"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 5: 创建新的入口文件
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `server/index.js`
|
|||
|
|
- Modify: `package.json` (scripts)
|
|||
|
|
|
|||
|
|
**步骤:**
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建新的入口文件**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/index.js
|
|||
|
|
const express = require('express');
|
|||
|
|
const cors = require('cors');
|
|||
|
|
const path = require('path');
|
|||
|
|
const fs = require('fs');
|
|||
|
|
|
|||
|
|
// 确保日志目录存在
|
|||
|
|
const logsDir = path.join(__dirname, '../logs');
|
|||
|
|
if (!fs.existsSync(logsDir)) {
|
|||
|
|
fs.mkdirSync(logsDir, { recursive: true });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 加载环境变量
|
|||
|
|
require('dotenv').config();
|
|||
|
|
|
|||
|
|
const { errorHandler } = require('./middleware/errorHandler');
|
|||
|
|
const logger = require('./utils/logger');
|
|||
|
|
|
|||
|
|
// 运行数据库迁移
|
|||
|
|
require('./db/migrate');
|
|||
|
|
|
|||
|
|
const app = express();
|
|||
|
|
const PORT = process.env.PORT || 3501;
|
|||
|
|
|
|||
|
|
// 中间件
|
|||
|
|
app.use(cors({
|
|||
|
|
origin: process.env.NODE_ENV === 'production'
|
|||
|
|
? process.env.ALLOWED_ORIGINS?.split(',') || []
|
|||
|
|
: ['http://localhost:3500', 'http://127.0.0.1:3500'],
|
|||
|
|
credentials: true,
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
app.use(express.json());
|
|||
|
|
app.use(express.static(path.join(__dirname, '../public')));
|
|||
|
|
app.use(express.static(path.join(__dirname, '../frontend/.next')));
|
|||
|
|
app.use(express.static(path.join(__dirname, '../frontend/.next/server/pages')));
|
|||
|
|
|
|||
|
|
// 请求日志
|
|||
|
|
app.use((req, res, next) => {
|
|||
|
|
logger.info(`${req.method} ${req.url}`);
|
|||
|
|
next();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 路由
|
|||
|
|
app.use('/api/auth', require('./routes/auth'));
|
|||
|
|
app.use('/api/records', require('./routes/records'));
|
|||
|
|
app.use('/api/stats', require('./routes/stats'));
|
|||
|
|
app.use('/api/recurring', require('./routes/recurring'));
|
|||
|
|
app.use('/api/categories', require('./routes/categories'));
|
|||
|
|
app.use('/api/export', require('./routes/export'));
|
|||
|
|
app.use('/api/search', require('./routes/search'));
|
|||
|
|
|
|||
|
|
// 健康检查
|
|||
|
|
app.get('/api/health', (req, res) => {
|
|||
|
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 前端路由 - Next.js 静态文件
|
|||
|
|
app.get('/_next/static/:path(*)', (req, res) => {
|
|||
|
|
const filePath = path.join(__dirname, '../frontend/.next/static', req.params.path);
|
|||
|
|
res.sendFile(filePath, (err) => {
|
|||
|
|
if (err) res.status(404).send('Not found');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.get('*', (req, res) => {
|
|||
|
|
let pagePath = req.path === '/' ? '/index.html' : req.path + '.html';
|
|||
|
|
const nextPagePath = path.join(__dirname, '../frontend/.next/server/pages', pagePath);
|
|||
|
|
|
|||
|
|
res.sendFile(nextPagePath, (err) => {
|
|||
|
|
if (err) {
|
|||
|
|
res.sendFile(path.join(__dirname, '../frontend/.next/server/pages/index.html'));
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 错误处理
|
|||
|
|
app.use(errorHandler);
|
|||
|
|
|
|||
|
|
// 启动服务器
|
|||
|
|
app.listen(PORT, '0.0.0.0', () => {
|
|||
|
|
logger.info(`记账本服务已启动: http://0.0.0.0:${PORT}`);
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 更新 package.json**
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"name": "accountbook",
|
|||
|
|
"version": "1.0.0",
|
|||
|
|
"description": "简易记账本",
|
|||
|
|
"main": "server/index.js",
|
|||
|
|
"scripts": {
|
|||
|
|
"start": "node server/index.js",
|
|||
|
|
"dev": "concurrently \"npm run server:dev\" \"npm run client:dev\"",
|
|||
|
|
"server:dev": "node server/index.js",
|
|||
|
|
"client:dev": "cd frontend && npm run dev",
|
|||
|
|
"build": "cd frontend && npm run build",
|
|||
|
|
"migrate": "node server/db/migrate.js",
|
|||
|
|
"test": "jest",
|
|||
|
|
"lint": "eslint server/ frontend/",
|
|||
|
|
"lint:fix": "eslint server/ frontend/ --fix"
|
|||
|
|
},
|
|||
|
|
"dependencies": {
|
|||
|
|
"express": "^4.18.2",
|
|||
|
|
"better-sqlite3": "^9.2.2",
|
|||
|
|
"jsonwebtoken": "^9.0.2",
|
|||
|
|
"bcryptjs": "^2.4.3",
|
|||
|
|
"cors": "^2.8.5",
|
|||
|
|
"winston": "^3.11.0",
|
|||
|
|
"dotenv": "^16.3.1"
|
|||
|
|
},
|
|||
|
|
"devDependencies": {
|
|||
|
|
"concurrently": "^8.2.2",
|
|||
|
|
"eslint": "^8.55.0",
|
|||
|
|
"jest": "^29.7.0",
|
|||
|
|
"nodemon": "^3.0.2"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建 .env.example**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
# 服务器配置
|
|||
|
|
PORT=3501
|
|||
|
|
NODE_ENV=development
|
|||
|
|
|
|||
|
|
# JWT 密钥(生产环境必须修改)
|
|||
|
|
JWT_SECRET=your-secret-key-here
|
|||
|
|
|
|||
|
|
# 数据库路径
|
|||
|
|
DB_PATH=./accountbook.db
|
|||
|
|
|
|||
|
|
# 日志级别
|
|||
|
|
LOG_LEVEL=info
|
|||
|
|
|
|||
|
|
# 生产环境允许的域名(逗号分隔)
|
|||
|
|
ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add server/index.js package.json .env.example
|
|||
|
|
git commit -m "feat: add new server entry point and update package.json"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 2: 前端性能优化
|
|||
|
|
|
|||
|
|
### Task 6: 添加 SWR 数据获取
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/lib/swr-config.js`
|
|||
|
|
- Create: `frontend/hooks/useRecords.js`
|
|||
|
|
- Create: `frontend/hooks/useStats.js`
|
|||
|
|
- Modify: `frontend/package.json`
|
|||
|
|
|
|||
|
|
**步骤:**
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 安装依赖**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /opt/accountbook/frontend
|
|||
|
|
npm install swr
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建 SWR 配置**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// frontend/lib/swr-config.js
|
|||
|
|
import { getRecords, getStats, getCategories, getRecurringBills } from './api';
|
|||
|
|
|
|||
|
|
// 全局 SWR 配置
|
|||
|
|
export const swrConfig = {
|
|||
|
|
refreshInterval: 0, // 默认不自动刷新
|
|||
|
|
revalidateOnFocus: true,
|
|||
|
|
revalidateOnReconnect: true,
|
|||
|
|
dedupingInterval: 2000,
|
|||
|
|
errorRetryCount: 3,
|
|||
|
|
onError: (error) => {
|
|||
|
|
console.error('SWR Error:', error);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 数据获取器映射
|
|||
|
|
export const fetchers = {
|
|||
|
|
records: (month, type) => getRecords(month, type),
|
|||
|
|
stats: (month) => getStats(month),
|
|||
|
|
categories: () => getCategories(),
|
|||
|
|
recurring: () => getRecurringBills(),
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建 useRecords Hook**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// frontend/hooks/useRecords.js
|
|||
|
|
import useSWR from 'swr';
|
|||
|
|
import { getRecords, createRecord, updateRecord, deleteRecord } from '../lib/api';
|
|||
|
|
|
|||
|
|
export function useRecords(month, type = 'all') {
|
|||
|
|
const key = month ? ['records', month, type] : null;
|
|||
|
|
|
|||
|
|
const { data, error, isLoading, mutate } = useSWR(
|
|||
|
|
key,
|
|||
|
|
() => getRecords(month, type),
|
|||
|
|
{
|
|||
|
|
revalidateOnFocus: false,
|
|||
|
|
dedupingInterval: 5000,
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 乐观更新:添加记录
|
|||
|
|
const addRecord = async (recordData) => {
|
|||
|
|
const newRecord = await createRecord(recordData);
|
|||
|
|
mutate(
|
|||
|
|
(current) => (current ? [...current, newRecord] : [newRecord]),
|
|||
|
|
{ revalidate: false }
|
|||
|
|
);
|
|||
|
|
return newRecord;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 乐观更新:更新记录
|
|||
|
|
const editRecord = async (id, recordData) => {
|
|||
|
|
await updateRecord(id, recordData);
|
|||
|
|
mutate(
|
|||
|
|
(current) =>
|
|||
|
|
current?.map((r) => (r.id === id ? { ...r, ...recordData } : r)),
|
|||
|
|
{ revalidate: false }
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 乐观更新:删除记录
|
|||
|
|
const removeRecord = async (id) => {
|
|||
|
|
await deleteRecord(id);
|
|||
|
|
mutate(
|
|||
|
|
(current) => current?.filter((r) => r.id !== id),
|
|||
|
|
{ revalidate: false }
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
records: data || [],
|
|||
|
|
isLoading,
|
|||
|
|
error,
|
|||
|
|
mutate,
|
|||
|
|
addRecord,
|
|||
|
|
editRecord,
|
|||
|
|
removeRecord,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 创建 useStats Hook**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// frontend/hooks/useStats.js
|
|||
|
|
import useSWR from 'swr';
|
|||
|
|
import { getStats, getTrendStats, getCategoryStats } from '../lib/api';
|
|||
|
|
|
|||
|
|
export function useStats(month) {
|
|||
|
|
const key = month ? ['stats', month] : null;
|
|||
|
|
|
|||
|
|
const { data, error, isLoading, mutate } = useSWR(
|
|||
|
|
key,
|
|||
|
|
() => getStats(month),
|
|||
|
|
{
|
|||
|
|
revalidateOnFocus: false,
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
stats: data || { income: 0, expense: 0, balance: 0 },
|
|||
|
|
isLoading,
|
|||
|
|
error,
|
|||
|
|
mutate,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function useTrendStats(period = 'month', month) {
|
|||
|
|
const key = ['trend', period, month];
|
|||
|
|
|
|||
|
|
const { data, error, isLoading } = useSWR(
|
|||
|
|
key,
|
|||
|
|
() => getTrendStats(period, month),
|
|||
|
|
{
|
|||
|
|
revalidateOnFocus: false,
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
trend: data || [],
|
|||
|
|
isLoading,
|
|||
|
|
error,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function useCategoryStats(month, type = 'all') {
|
|||
|
|
const key = ['categoryStats', month, type];
|
|||
|
|
|
|||
|
|
const { data, error, isLoading } = useSWR(
|
|||
|
|
key,
|
|||
|
|
() => getCategoryStats(month, type),
|
|||
|
|
{
|
|||
|
|
revalidateOnFocus: false,
|
|||
|
|
}
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
categories: data || [],
|
|||
|
|
isLoading,
|
|||
|
|
error,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add frontend/
|
|||
|
|
git commit -m "feat: add SWR for data fetching with optimistic updates"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 7: 添加错误边界和加载状态
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `frontend/components/ErrorBoundary.js`
|
|||
|
|
- Create: `frontend/components/LoadingSpinner.js`
|
|||
|
|
- Modify: `frontend/pages/_app.js`
|
|||
|
|
|
|||
|
|
**步骤:**
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 创建错误边界组件**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// frontend/components/ErrorBoundary.js
|
|||
|
|
import React from 'react';
|
|||
|
|
import { Icon } from './icons';
|
|||
|
|
|
|||
|
|
class ErrorBoundary extends React.Component {
|
|||
|
|
constructor(props) {
|
|||
|
|
super(props);
|
|||
|
|
this.state = { hasError: false, error: null };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static getDerivedStateFromError(error) {
|
|||
|
|
return { hasError: true, error };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
componentDidCatch(error, errorInfo) {
|
|||
|
|
console.error('ErrorBoundary caught error:', error, errorInfo);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
handleReload = () => {
|
|||
|
|
window.location.reload();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
handleGoHome = () => {
|
|||
|
|
window.location.href = '/';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
render() {
|
|||
|
|
if (this.state.hasError) {
|
|||
|
|
return (
|
|||
|
|
<div className="error-boundary">
|
|||
|
|
<div className="error-boundary__content">
|
|||
|
|
<Icon name="info" size={48} />
|
|||
|
|
<h2>出错了</h2>
|
|||
|
|
<p>抱歉,应用遇到了问题。</p>
|
|||
|
|
{process.env.NODE_ENV === 'development' && (
|
|||
|
|
<pre className="error-boundary__details">
|
|||
|
|
{this.state.error?.toString()}
|
|||
|
|
</pre>
|
|||
|
|
)}
|
|||
|
|
<div className="error-boundary__actions">
|
|||
|
|
<button onClick={this.handleGoHome} className="btn btn-secondary">
|
|||
|
|
返回首页
|
|||
|
|
</button>
|
|||
|
|
<button onClick={this.handleReload} className="btn btn-primary">
|
|||
|
|
重新加载
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return this.props.children;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default ErrorBoundary;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建加载动画组件**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// frontend/components/LoadingSpinner.js
|
|||
|
|
import { Icon } from './icons';
|
|||
|
|
|
|||
|
|
export default function LoadingSpinner({ size = 24, text = '加载中...' }) {
|
|||
|
|
return (
|
|||
|
|
<div className="loading-spinner">
|
|||
|
|
<Icon name="spinner" size={size} className="is-spinning" />
|
|||
|
|
{text && <span>{text}</span>}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 更新 _app.js**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// frontend/pages/_app.js
|
|||
|
|
import { SWRConfig } from 'swr';
|
|||
|
|
import ErrorBoundary from '../components/ErrorBoundary';
|
|||
|
|
import ToastProvider from '../components/ToastProvider';
|
|||
|
|
import ConfirmProvider from '../components/ConfirmProvider';
|
|||
|
|
import { swrConfig } from '../lib/swr-config';
|
|||
|
|
import '../styles/globals.css';
|
|||
|
|
|
|||
|
|
function MyApp({ Component, pageProps }) {
|
|||
|
|
return (
|
|||
|
|
<ErrorBoundary>
|
|||
|
|
<SWRConfig value={swrConfig}>
|
|||
|
|
<ToastProvider>
|
|||
|
|
<ConfirmProvider>
|
|||
|
|
<Component {...pageProps} />
|
|||
|
|
</ConfirmProvider>
|
|||
|
|
</ToastProvider>
|
|||
|
|
</SWRConfig>
|
|||
|
|
</ErrorBoundary>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default MyApp;
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 添加 CSS 样式**
|
|||
|
|
|
|||
|
|
在 `frontend/styles/globals.css` 末尾添加:
|
|||
|
|
|
|||
|
|
```css
|
|||
|
|
/* Error Boundary */
|
|||
|
|
.error-boundary {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
min-height: 100vh;
|
|||
|
|
padding: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.error-boundary__content {
|
|||
|
|
text-align: center;
|
|||
|
|
max-width: 400px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.error-boundary__content h2 {
|
|||
|
|
margin: 16px 0 8px;
|
|||
|
|
color: #1e293b;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.error-boundary__content p {
|
|||
|
|
color: #64748b;
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.error-boundary__details {
|
|||
|
|
background: #f1f5f9;
|
|||
|
|
padding: 12px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: #64748b;
|
|||
|
|
margin-bottom: 24px;
|
|||
|
|
overflow-x: auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.error-boundary__actions {
|
|||
|
|
display: flex;
|
|||
|
|
gap: 12px;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Loading Spinner */
|
|||
|
|
.loading-spinner {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 20px;
|
|||
|
|
color: #64748b;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes spin {
|
|||
|
|
to { transform: rotate(360deg); }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.is-spinning {
|
|||
|
|
animation: spin 1s linear infinite;
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add frontend/
|
|||
|
|
git commit -m "feat: add error boundary and loading spinner components"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 3: 开发工具配置
|
|||
|
|
|
|||
|
|
### Task 8: 配置 ESLint 和 Prettier
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `.eslintrc.js`
|
|||
|
|
- Create: `.prettierrc`
|
|||
|
|
- Create: `.eslintignore`
|
|||
|
|
- Modify: `package.json`
|
|||
|
|
|
|||
|
|
**步骤:**
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 安装依赖**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /opt/accountbook
|
|||
|
|
npm install --save-dev eslint prettier eslint-config-prettier eslint-plugin-node
|
|||
|
|
|
|||
|
|
cd /opt/accountbook/frontend
|
|||
|
|
npm install --save-dev eslint prettier eslint-config-prettier eslint-plugin-react eslint-plugin-react-hooks
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建 ESLint 配置(后端)**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// .eslintrc.js (根目录)
|
|||
|
|
module.exports = {
|
|||
|
|
root: true,
|
|||
|
|
env: {
|
|||
|
|
node: true,
|
|||
|
|
es2021: true,
|
|||
|
|
jest: true,
|
|||
|
|
},
|
|||
|
|
extends: ['eslint:recommended', 'prettier'],
|
|||
|
|
plugins: ['node'],
|
|||
|
|
parserOptions: {
|
|||
|
|
ecmaVersion: 'latest',
|
|||
|
|
sourceType: 'module',
|
|||
|
|
},
|
|||
|
|
rules: {
|
|||
|
|
'no-console': 'off',
|
|||
|
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|||
|
|
'prefer-const': 'error',
|
|||
|
|
'no-var': 'error',
|
|||
|
|
'object-shorthand': 'error',
|
|||
|
|
'prefer-template': 'error',
|
|||
|
|
},
|
|||
|
|
overrides: [
|
|||
|
|
{
|
|||
|
|
files: ['server/**/*.js'],
|
|||
|
|
rules: {
|
|||
|
|
'no-console': 'off',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建 ESLint 配置(前端)**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// frontend/.eslintrc.js
|
|||
|
|
module.exports = {
|
|||
|
|
root: true,
|
|||
|
|
env: {
|
|||
|
|
browser: true,
|
|||
|
|
es2021: true,
|
|||
|
|
node: true,
|
|||
|
|
},
|
|||
|
|
extends: [
|
|||
|
|
'eslint:recommended',
|
|||
|
|
'plugin:react/recommended',
|
|||
|
|
'plugin:react-hooks/recommended',
|
|||
|
|
'prettier',
|
|||
|
|
],
|
|||
|
|
plugins: ['react', 'react-hooks'],
|
|||
|
|
parserOptions: {
|
|||
|
|
ecmaFeatures: {
|
|||
|
|
jsx: true,
|
|||
|
|
},
|
|||
|
|
ecmaVersion: 'latest',
|
|||
|
|
sourceType: 'module',
|
|||
|
|
},
|
|||
|
|
settings: {
|
|||
|
|
react: {
|
|||
|
|
version: 'detect',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
rules: {
|
|||
|
|
'react/react-in-jsx-scope': 'off',
|
|||
|
|
'react/prop-types': 'off',
|
|||
|
|
'react-hooks/exhaustive-deps': 'warn',
|
|||
|
|
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|||
|
|
'prefer-const': 'error',
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 创建 Prettier 配置**
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
// .prettierrc
|
|||
|
|
{
|
|||
|
|
"semi": true,
|
|||
|
|
"singleQuote": true,
|
|||
|
|
"tabWidth": 2,
|
|||
|
|
"trailingComma": "es5",
|
|||
|
|
"printWidth": 100,
|
|||
|
|
"bracketSpacing": true,
|
|||
|
|
"arrowParens": "always"
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 创建忽略文件**
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
# .eslintignore
|
|||
|
|
node_modules/
|
|||
|
|
frontend/node_modules/
|
|||
|
|
frontend/.next/
|
|||
|
|
logs/
|
|||
|
|
*.log
|
|||
|
|
accountbook.db
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 6: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add .eslintrc.js .prettierrc .eslintignore
|
|||
|
|
ngit commit -m "chore: add ESLint and Prettier configuration"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 9: 配置 Jest 测试
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Create: `jest.config.js`
|
|||
|
|
- Create: `server/__tests__/validators.test.js`
|
|||
|
|
- Create: `server/__tests__/api.test.js`
|
|||
|
|
|
|||
|
|
**步骤:**
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 安装测试依赖**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /opt/accountbook
|
|||
|
|
npm install --save-dev jest supertest @types/jest
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 创建 Jest 配置**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// jest.config.js
|
|||
|
|
module.exports = {
|
|||
|
|
testEnvironment: 'node',
|
|||
|
|
roots: ['<rootDir>/server'],
|
|||
|
|
testMatch: ['**/__tests__/**/*.test.js'],
|
|||
|
|
collectCoverageFrom: [
|
|||
|
|
'server/**/*.js',
|
|||
|
|
'!server/**/__tests__/**',
|
|||
|
|
'!server/db/migrate.js',
|
|||
|
|
],
|
|||
|
|
coverageDirectory: 'coverage',
|
|||
|
|
coverageReporters: ['text', 'lcov', 'html'],
|
|||
|
|
verbose: true,
|
|||
|
|
};
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建验证器测试**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/__tests__/validators.test.js
|
|||
|
|
const {
|
|||
|
|
isNonEmptyString,
|
|||
|
|
isValidMonth,
|
|||
|
|
isValidDateTime,
|
|||
|
|
validateRecordPayload,
|
|||
|
|
validateRecurringPayload,
|
|||
|
|
validateCategoryPayload,
|
|||
|
|
} = require('../utils/validators');
|
|||
|
|
|
|||
|
|
describe('Validators', () => {
|
|||
|
|
describe('isNonEmptyString', () => {
|
|||
|
|
test('returns true for non-empty string', () => {
|
|||
|
|
expect(isNonEmptyString('hello')).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('returns false for empty string', () => {
|
|||
|
|
expect(isNonEmptyString('')).toBe(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('returns false for non-string', () => {
|
|||
|
|
expect(isNonEmptyString(123)).toBe(false);
|
|||
|
|
expect(isNonEmptyString(null)).toBe(false);
|
|||
|
|
expect(isNonEmptyString(undefined)).toBe(false);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('isValidMonth', () => {
|
|||
|
|
test('returns true for valid month format', () => {
|
|||
|
|
expect(isValidMonth('2024-01')).toBe(true);
|
|||
|
|
expect(isValidMonth('2024-12')).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('returns false for invalid month format', () => {
|
|||
|
|
expect(isValidMonth('2024-1')).toBe(false);
|
|||
|
|
expect(isValidMonth('01-2024')).toBe(false);
|
|||
|
|
expect(isValidMonth('invalid')).toBe(false);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('isValidDateTime', () => {
|
|||
|
|
test('returns true for valid date', () => {
|
|||
|
|
expect(isValidDateTime('2024-01-15')).toBe(true);
|
|||
|
|
expect(isValidDateTime('2024-01-15 10:30')).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('returns false for invalid date', () => {
|
|||
|
|
expect(isValidDateTime('invalid')).toBe(false);
|
|||
|
|
expect(isValidDateTime('15-01-2024')).toBe(false);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe('validateRecordPayload', () => {
|
|||
|
|
test('returns null for valid payload', () => {
|
|||
|
|
const payload = {
|
|||
|
|
type: 'expense',
|
|||
|
|
amount: 100,
|
|||
|
|
category: '餐饮',
|
|||
|
|
date: '2024-01-15',
|
|||
|
|
note: '',
|
|||
|
|
};
|
|||
|
|
expect(validateRecordPayload(payload)).toBeNull();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('returns error for invalid type', () => {
|
|||
|
|
const payload = {
|
|||
|
|
type: 'invalid',
|
|||
|
|
amount: 100,
|
|||
|
|
category: '餐饮',
|
|||
|
|
date: '2024-01-15',
|
|||
|
|
};
|
|||
|
|
expect(validateRecordPayload(payload)).toBe('账目类型错误');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('returns error for negative amount', () => {
|
|||
|
|
const payload = {
|
|||
|
|
type: 'expense',
|
|||
|
|
amount: -100,
|
|||
|
|
category: '餐饮',
|
|||
|
|
date: '2024-01-15',
|
|||
|
|
};
|
|||
|
|
expect(validateRecordPayload(payload)).toBe('金额需大于0');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
test('returns error for empty category', () => {
|
|||
|
|
const payload = {
|
|||
|
|
type: 'expense',
|
|||
|
|
amount: 100,
|
|||
|
|
category: '',
|
|||
|
|
date: '2024-01-15',
|
|||
|
|
};
|
|||
|
|
expect(validateRecordPayload(payload)).toBe('请填写分类');
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 创建 API 集成测试**
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// server/__tests__/api.test.js
|
|||
|
|
const request = require('supertest');
|
|||
|
|
const express = require('express');
|
|||
|
|
|
|||
|
|
// 模拟数据库和认证
|
|||
|
|
describe('API Integration Tests', () => {
|
|||
|
|
test('placeholder test', () => {
|
|||
|
|
expect(true).toBe(true);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 5: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add jest.config.js server/__tests__/
|
|||
|
|
git commit -m "test: add Jest configuration and initial tests"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
### Task 10: 更新首页使用新的 Hooks
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Modify: `frontend/pages/index.js`
|
|||
|
|
|
|||
|
|
**步骤:**
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 更新首页使用 SWR**
|
|||
|
|
|
|||
|
|
替换原有的数据获取逻辑:
|
|||
|
|
|
|||
|
|
```javascript
|
|||
|
|
// 修改导入部分
|
|||
|
|
import { useRecords } from '../hooks/useRecords';
|
|||
|
|
import { useStats } from '../hooks/useStats';
|
|||
|
|
import LoadingSpinner from '../components/LoadingSpinner';
|
|||
|
|
|
|||
|
|
// 修改组件逻辑
|
|||
|
|
export default function Home() {
|
|||
|
|
// ... 其他状态
|
|||
|
|
const { records, isLoading: recordsLoading, addRecord, editRecord, removeRecord } = useRecords(
|
|||
|
|
getMonthStr(currentMonth),
|
|||
|
|
typeFilter
|
|||
|
|
);
|
|||
|
|
const { stats, isLoading: statsLoading } = useStats(getMonthStr(currentMonth));
|
|||
|
|
|
|||
|
|
// 移除原有的 loadData, setRecords, setStats 等逻辑
|
|||
|
|
// 使用 addRecord, editRecord, removeRecord 替代原有的 API 调用
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
由于代码量较大,这里提供关键修改点:
|
|||
|
|
|
|||
|
|
1. 使用 `useRecords` 和 `useStats` 替代手动 fetch
|
|||
|
|
2. 使用乐观更新替代重新加载
|
|||
|
|
3. 添加 `LoadingSpinner` 组件
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add frontend/pages/index.js
|
|||
|
|
git commit -m "refactor: update index page to use SWR hooks"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Phase 4: 清理和文档
|
|||
|
|
|
|||
|
|
### Task 11: 备份旧文件并更新文档
|
|||
|
|
|
|||
|
|
**Files:**
|
|||
|
|
- Move: `server.js` → `server.js.backup`
|
|||
|
|
- Modify: `SPEC.md`
|
|||
|
|
- Create: `CHANGELOG.md`
|
|||
|
|
|
|||
|
|
**步骤:**
|
|||
|
|
|
|||
|
|
- [ ] **Step 1: 备份旧服务器文件**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd /opt/accountbook
|
|||
|
|
mv server.js server.js.backup
|
|||
|
|
git add server.js.backup
|
|||
|
|
git commit -m "chore: backup old server.js"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 2: 更新 SPEC.md**
|
|||
|
|
|
|||
|
|
在 SPEC.md 中添加新的后端结构说明:
|
|||
|
|
|
|||
|
|
```markdown
|
|||
|
|
## 9. 项目结构
|
|||
|
|
|
|||
|
|
### 后端目录结构
|
|||
|
|
```
|
|||
|
|
server/
|
|||
|
|
├── index.js # 入口文件
|
|||
|
|
├── config/
|
|||
|
|
│ └── database.js # 数据库配置
|
|||
|
|
├── middleware/
|
|||
|
|
│ ├── auth.js # 认证中间件
|
|||
|
|
│ ├── errorHandler.js # 错误处理
|
|||
|
|
│ └── validator.js # 验证中间件
|
|||
|
|
├── routes/
|
|||
|
|
│ ├── auth.js # 认证路由
|
|||
|
|
│ ├── records.js # 账目路由
|
|||
|
|
│ ├── stats.js # 统计路由
|
|||
|
|
│ ├── recurring.js # 周期性账单
|
|||
|
|
│ ├── categories.js # 分类路由
|
|||
|
|
│ ├── export.js # 导入导出
|
|||
|
|
│ └── search.js # 搜索路由
|
|||
|
|
├── utils/
|
|||
|
|
│ ├── validators.js # 验证函数
|
|||
|
|
│ └── logger.js # 日志工具
|
|||
|
|
└── db/
|
|||
|
|
├── migrations/ # 数据库迁移
|
|||
|
|
└── migrate.js # 迁移脚本
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 新增功能
|
|||
|
|
- 数据库迁移系统
|
|||
|
|
- 结构化日志 (Winston)
|
|||
|
|
- 统一错误处理
|
|||
|
|
- API 请求验证
|
|||
|
|
- 数据库索引优化
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 3: 创建 CHANGELOG.md**
|
|||
|
|
|
|||
|
|
```markdown
|
|||
|
|
# 更新日志
|
|||
|
|
|
|||
|
|
## [1.1.0] - 2026-03-18
|
|||
|
|
|
|||
|
|
### 新增
|
|||
|
|
- 后端模块化重构,拆分单体 server.js
|
|||
|
|
- 数据库迁移系统
|
|||
|
|
- Winston 结构化日志
|
|||
|
|
- 统一错误处理机制
|
|||
|
|
- 数据库索引优化
|
|||
|
|
- SWR 数据获取和缓存
|
|||
|
|
- 乐观更新支持
|
|||
|
|
- 错误边界组件
|
|||
|
|
- ESLint + Prettier 配置
|
|||
|
|
- Jest 测试框架
|
|||
|
|
|
|||
|
|
### 优化
|
|||
|
|
- API 错误响应格式统一
|
|||
|
|
- 前端数据获取性能优化
|
|||
|
|
- 代码质量和一致性检查
|
|||
|
|
|
|||
|
|
### 开发体验
|
|||
|
|
- 新增 `npm run dev` 同时启动前后端
|
|||
|
|
- 新增 `npm run migrate` 数据库迁移
|
|||
|
|
- 新增 `npm run lint` 代码检查
|
|||
|
|
- 新增 `npm test` 运行测试
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
- [ ] **Step 4: 最终提交**
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
git add SPEC.md CHANGELOG.md
|
|||
|
|
git commit -m "docs: update SPEC.md and add CHANGELOG"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 总结
|
|||
|
|
|
|||
|
|
完成以上任务后,项目将获得:
|
|||
|
|
|
|||
|
|
1. **模块化后端**:清晰的代码结构,易于维护
|
|||
|
|
2. **数据库优化**:索引和迁移系统
|
|||
|
|
3. **错误处理**:统一和可预测的错误响应
|
|||
|
|
4. **前端性能**:SWR 缓存和乐观更新
|
|||
|
|
5. **开发工具**:ESLint, Prettier, Jest
|
|||
|
|
6. **日志系统**:结构化的请求和错误日志
|
|||
|
|
|
|||
|
|
所有更改都是增量式的,不影响现有功能。
|