diff --git a/.gitignore b/.gitignore index 6d66012..dabc948 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ node_modules/ # Build output dist/ build/ +frontend/.next/ # IDE .idea/ diff --git a/docs/superpowers/plans/2026-03-18-code-optimization.md b/docs/superpowers/plans/2026-03-18-code-optimization.md new file mode 100644 index 0000000..aaab822 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-code-optimization.md @@ -0,0 +1,2238 @@ +# 记账本项目代码优化实施计划 + +> **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 ( +
+
+ +

出错了

+

抱歉,应用遇到了问题。

+ {process.env.NODE_ENV === 'development' && ( +
+                {this.state.error?.toString()}
+              
+ )} +
+ + +
+
+
+ ); + } + + 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 ( +
+ + {text && {text}} +
+ ); +} +``` + +- [ ] **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 ( + + + + + + + + + + ); +} + +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: ['/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. **日志系统**:结构化的请求和错误日志 + +所有更改都是增量式的,不影响现有功能。 diff --git a/frontend/components/RecordModal.js b/frontend/components/RecordModal.js index 87af908..f54055a 100644 --- a/frontend/components/RecordModal.js +++ b/frontend/components/RecordModal.js @@ -24,11 +24,8 @@ export default function RecordModal({ return (
e.stopPropagation()}> -
-
-

{editingRecord ? '编辑记录' : '新增记录'}

- {editingRecord ?

更新账目

: null} -
+
+

{editingRecord ? '编辑记录' : '新增记录'}

{groupedRecords[date].map((record) => { - const cat = categories[record.type]?.find((item) => item.name === record.category) || { icon: '📝' }; + const icon = categoryIconMap[`${record.type}:${record.category}`] || '📝'; return (