diff --git a/frontend/pages/categories.js b/frontend/pages/categories.js index a2ae532..d87943f 100644 --- a/frontend/pages/categories.js +++ b/frontend/pages/categories.js @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/router'; import { isLoggedIn, getCategories, createCategory, deleteCategory } from '../lib/api'; import AppShell from '../components/AppShell'; @@ -30,15 +30,7 @@ export default function Categories() { icon: '📝', }); - useEffect(() => { - if (!isLoggedIn()) { - router.push('/login'); - return; - } - loadCategories(); - }, [router]); - - const loadCategories = async () => { + const loadCategories = useCallback(async () => { setLoading(true); try { const data = await getCategories(); @@ -49,7 +41,15 @@ export default function Categories() { } finally { setLoading(false); } - }; + }, [showToast]); + + useEffect(() => { + if (!isLoggedIn()) { + router.push('/login'); + return; + } + loadCategories(); + }, [router, loadCategories]); const openAddModal = (type) => { setCategoryType(type); diff --git a/frontend/pages/index.js b/frontend/pages/index.js index 2892e46..7e39ec3 100644 --- a/frontend/pages/index.js +++ b/frontend/pages/index.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { useRouter } from 'next/router'; import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRecord } from '../lib/api'; import AppShell from '../components/AppShell'; @@ -30,7 +30,7 @@ export default function Home() { if (categoriesError) { showToast('分类加载失败', { tone: 'error' }); } - }, [categoriesError]); + }, [categoriesError, showToast]); const [formData, setFormData] = useState({ type: 'expense', @@ -47,6 +47,7 @@ export default function Home() { return; } loadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [router, currentMonth, typeFilter]); @@ -70,7 +71,7 @@ export default function Home() { return icons; }, [categories]); - const loadData = async () => { + const loadData = useCallback(async () => { setLoading(true); try { const [recordsData, statsData] = await Promise.all([ @@ -85,7 +86,7 @@ export default function Home() { } finally { setLoading(false); } - }; + }, [currentMonth, typeFilter, showToast]); const prevMonth = () => { const newDate = new Date(currentMonth); diff --git a/frontend/pages/recurring.js b/frontend/pages/recurring.js index 288b4ed..06969a5 100644 --- a/frontend/pages/recurring.js +++ b/frontend/pages/recurring.js @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/router'; import { isLoggedIn, getRecurringBills, createRecurringBill, updateRecurringBill, deleteRecurringBill, applyRecurringBill } from '../lib/api'; import AppShell from '../components/AppShell'; @@ -20,7 +20,7 @@ export default function Recurring() { if (categoriesError) { showToast('分类加载失败', { tone: 'error' }); } - }, [categoriesError]); + }, [categoriesError, showToast]); const [bills, setBills] = useState([]); const [modalOpen, setModalOpen] = useState(false); const [editingBill, setEditingBill] = useState(null); @@ -38,15 +38,7 @@ export default function Recurring() { is_active: true, }); - useEffect(() => { - if (!isLoggedIn()) { - router.push('/login'); - return; - } - loadBills(); - }, [router]); - - const loadBills = async () => { + const loadBills = useCallback(async () => { setLoading(true); try { const data = await getRecurringBills(); @@ -57,7 +49,15 @@ export default function Recurring() { } finally { setLoading(false); } - }; + }, [showToast]); + + useEffect(() => { + if (!isLoggedIn()) { + router.push('/login'); + return; + } + loadBills(); + }, [router, loadBills]); const openAddModal = () => { setEditingBill(null); diff --git a/frontend/pages/search.js b/frontend/pages/search.js index 8eaa2a1..cf1c231 100644 --- a/frontend/pages/search.js +++ b/frontend/pages/search.js @@ -20,7 +20,7 @@ export default function Search() { if (categoriesError) { showToast('分类加载失败', { tone: 'error' }); } - }, [categoriesError]); + }, [categoriesError, showToast]); const [keyword, setKeyword] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); @@ -66,7 +66,7 @@ export default function Search() { }; doSearch(); } - }, [router.isReady, router.query.q, keyword]); + }, [router.isReady, router.query.q, keyword, showToast]); const handleSearch = async (e) => { e.preventDefault(); diff --git a/frontend/pages/stats.js b/frontend/pages/stats.js index 2d3acfd..332d1da 100644 --- a/frontend/pages/stats.js +++ b/frontend/pages/stats.js @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useRouter } from 'next/router'; import { isLoggedIn, getTrendStats, getCategoryStats } from '../lib/api'; import { @@ -39,16 +39,7 @@ export default function Stats() { const trendChartRef = useRef(null); const trendChartInstance = useRef(null); - useEffect(() => { - if (!isLoggedIn()) { - router.push('/login'); - return; - } - // 并行加载两个数据集 - Promise.all([loadTrendData(), loadCategoryData()]); - }, [router, period, currentMonth, categoryType]); - - const loadTrendData = async () => { + const loadTrendData = useCallback(async () => { setTrendLoading(true); try { const trend = await getTrendStats(period, getMonthStr(currentMonth)); @@ -59,9 +50,9 @@ export default function Stats() { } finally { setTrendLoading(false); } - }; + }, [period, currentMonth, showToast]); - const loadCategoryData = async () => { + const loadCategoryData = useCallback(async () => { try { const category = await getCategoryStats(getMonthStr(currentMonth), categoryType); setCategoryData(category); @@ -69,7 +60,16 @@ export default function Stats() { console.error(err); showToast(err.message, { tone: 'error' }); } - }; + }, [currentMonth, categoryType, showToast]); + + useEffect(() => { + if (!isLoggedIn()) { + router.push('/login'); + return; + } + // 并行加载两个数据集 + Promise.all([loadTrendData(), loadCategoryData()]); + }, [router, loadTrendData, loadCategoryData]); useEffect(() => { return () => { diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css index a8e869c..b2411fb 100644 --- a/frontend/styles/globals.css +++ b/frontend/styles/globals.css @@ -2181,11 +2181,3 @@ textarea:focus-visible { .is-spinning { color: inherit; } - -@keyframes spin { - to { transform: rotate(360deg); } -} - -.is-spinning { - animation: spin 1s linear infinite; -} diff --git a/server.js b/server.js deleted file mode 100644 index 6ce8547..0000000 --- a/server.js +++ /dev/null @@ -1,831 +0,0 @@ -const express = require('express'); -const Database = require('better-sqlite3'); -const jwt = require('jsonwebtoken'); -const bcrypt = require('bcryptjs'); -const cors = require('cors'); -const path = require('path'); - -const app = express(); -const PORT = 3501; -const JWT_SECRET = process.env.JWT_SECRET || 'accountbook_secret_key_2024'; - -// 中间件 -app.use(cors({ - origin: ['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/static'))); - -// 数据库初始化 -const db = new Database('accountbook.db'); - -// 创建表 -db.exec(` - 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 INDEX IF NOT EXISTS idx_records_user_month 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_recurring_user ON recurring_bills(user_id); - CREATE INDEX IF NOT EXISTS idx_categories_user ON categories(user_id); -`); - -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; -} - -// 认证中间件 -const authenticate = (req, res, next) => { - const token = req.headers.authorization?.split(' ')[1]; - if (!token) { - return res.status(401).json({ error: '请先登录' }); - } - try { - const decoded = jwt.verify(token, JWT_SECRET); - req.userId = decoded.userId; - next(); - } catch (err) { - return res.status(401).json({ error: '登录已过期' }); - } -}; - -// ============ 认证接口 ============ - -// 注册 -app.post('/api/auth/register', async (req, res) => { - const { username, password } = req.body; - - if (!username || !password) { - return res.status(400).json({ error: '请填写用户名和密码' }); - } - - if (username.length < 3 || username.length > 20) { - return res.status(400).json({ error: '用户名需3-20个字符' }); - } - - if (password.length < 6 || password.length > 20) { - return res.status(400).json({ error: '密码需6-20个字符' }); - } - - try { - 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 = jwt.sign({ userId: result.lastInsertRowid }, JWT_SECRET, { expiresIn: '7d' }); - res.json({ token, username }); - } catch (err) { - if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') { - return res.status(400).json({ error: '用户名已存在' }); - } - res.status(500).json({ error: '服务器错误' }); - } -}); - -// 登录 -app.post('/api/auth/login', async (req, res) => { - const { username, password } = req.body; - - if (!username || !password) { - return res.status(400).json({ error: '请填写用户名和密码' }); - } - - const stmt = db.prepare('SELECT * FROM users WHERE username = ?'); - const user = stmt.get(username); - - if (!user) { - return res.status(400).json({ error: '用户名或密码错误' }); - } - - const validPassword = await bcrypt.compare(password, user.password); - if (!validPassword) { - return res.status(400).json({ error: '用户名或密码错误' }); - } - - const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' }); - res.json({ token, username: user.username }); -}); - -// ============ 账目接口 ============ - -// 获取账目列表 -app.get('/api/records', authenticate, (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') { - if (!VALID_RECORD_TYPES.includes(type)) { - return res.status(400).json({ error: '类型参数错误' }); - } - 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); -}); - -// 新增账目 -app.post('/api/records', authenticate, (req, res) => { - const { type, amount, category, date, note } = req.body; - - const validationError = validateRecordPayload(req.body); - if (validationError) { - return res.status(400).json({ error: validationError }); - } - - 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)); - - res.json({ id: result.lastInsertRowid, message: '添加成功' }); -}); - -// 更新账目 -app.put('/api/records/:id', authenticate, (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) { - return res.status(404).json({ error: '记录不存在' }); - } - - const validationError = validateRecordPayload(req.body); - if (validationError) { - return res.status(400).json({ error: validationError }); - } - - 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); - - res.json({ message: '更新成功' }); -}); - -// 删除账目 -app.delete('/api/records/:id', authenticate, (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) { - return res.status(404).json({ error: '记录不存在' }); - } - - const stmt = db.prepare('DELETE FROM records WHERE id = ? AND user_id = ?'); - stmt.run(id, req.userId); - - res.json({ message: '删除成功' }); -}); - -// 批量删除指定分类的记录 -app.delete('/api/records/by-category/:category', authenticate, (req, res) => { - const { category } = req.params; - - if (!category || typeof category !== 'string' || !category.trim()) { - return res.status(400).json({ error: '分类不能为空' }); - } - - const stmt = db.prepare('DELETE FROM records WHERE category = ? AND user_id = ?'); - const result = stmt.run(category.trim(), req.userId); - - res.json({ message: `已删除 ${result.changes} 条记录` }); -}); - -// 获取月度统计 -app.get('/api/stats', authenticate, (req, res) => { - const { month } = req.query; - - if (!month) { - return res.status(400).json({ error: '请指定月份' }); - } - - 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) - }); -}); - -// ============ 搜索接口 ============ - -// 搜索账目 -app.get('/api/records/search', authenticate, (req, res) => { - const { q } = req.query; - - if (!q) { - return res.status(400).json({ error: '请输入搜索关键词' }); - } - - 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); -}); - -// ============ 统计接口 ============ - -// 收支趋势统计 -app.get('/api/stats/trend', authenticate, (req, res) => { - const { period = 'month', month } = req.query; - - if (!VALID_STATS_PERIODS.includes(period)) { - return res.status(400).json({ error: '统计周期错误' }); - } - - if (month && !isValidMonth(month)) { - return res.status(400).json({ error: '月份格式错误' }); - } - - 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()); -}); - -// 分类统计 -app.get('/api/stats/category', authenticate, (req, res) => { - const { month, type = 'all' } = req.query; - - if (!VALID_STATS_TYPES.includes(type)) { - return res.status(400).json({ error: '统计类型错误' }); - } - - if (month && !isValidMonth(month)) { - return res.status(400).json({ error: '月份格式错误' }); - } - - 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); -}); - -// ============ 周期性账单接口 ============ - -// 获取周期性账单 -app.get('/api/recurring', authenticate, (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); -}); - -// 新增周期性账单 -app.post('/api/recurring', authenticate, (req, res) => { - const { type, amount, category, day, note } = req.body; - - const validationError = validateRecurringPayload(req.body); - if (validationError) { - return res.status(400).json({ error: validationError }); - } - - 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)); - res.json({ id: result.lastInsertRowid, message: '添加成功' }); -}); - -// 更新周期性账单 -app.put('/api/recurring/:id', authenticate, (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) { - return res.status(404).json({ error: '账单不存在' }); - } - - const validationError = validateRecurringPayload(req.body); - if (validationError) { - return res.status(400).json({ error: validationError }); - } - - 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); - res.json({ message: '更新成功' }); -}); - -// 删除周期性账单 -app.delete('/api/recurring/:id', authenticate, (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) { - return res.status(404).json({ error: '账单不存在' }); - } - - const stmt = db.prepare('DELETE FROM recurring_bills WHERE id = ? AND user_id = ?'); - stmt.run(id, req.userId); - res.json({ message: '删除成功' }); -}); - -// 应用周期性账单 -app.post('/api/recurring/:id/apply', authenticate, (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) { - return res.status(404).json({ error: '账单不存在或已禁用' }); - } - - // 创建当前日期的账目 - 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 || ''); - res.json({ message: '应用成功' }); -}); - -// ============ 分类接口 ============ - -// 获取分类列表 -app.get('/api/categories', authenticate, (req, res) => { - const stmt = db.prepare('SELECT * FROM categories WHERE user_id = ? ORDER BY type, id'); - const customCategories = stmt.all(req.userId); - - 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: '📝' } - ] - }; - - // 合并默认分类和自定义分类 - 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); -}); - -// 新增自定义分类 -app.post('/api/categories', authenticate, (req, res) => { - const { type, name, icon } = req.body; - - const validationError = validateCategoryPayload(req.body); - if (validationError) { - return res.status(400).json({ error: validationError }); - } - - 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()); - res.json({ id: result.lastInsertRowid, message: '添加成功' }); -}); - -// 删除自定义分类 -app.delete('/api/categories/:id', authenticate, (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) { - return res.status(404).json({ error: '分类不存在或无法删除' }); - } - - const stmt = db.prepare('DELETE FROM categories WHERE id = ? AND user_id = ?'); - stmt.run(id, req.userId); - res.json({ message: '删除成功' }); -}); - -// ============ 数据导入导出接口 ============ - -// 导出数据 -app.get('/api/export', authenticate, (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); - - res.json({ - version: '1.0', - exportDate: new Date().toISOString(), - records, - recurring, - customCategories: categories - }); -}); - -// 导入数据 -app.post('/api/import', authenticate, (req, res) => { - const { records: importRecords, recurring: importRecurring, customCategories: importCategories } = req.body; - - const validationError = validateImportPayload(req.body); - if (validationError) { - return res.status(400).json({ error: validationError }); - } - - try { - 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); - } - - 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); - } - - 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); - } - - res.json({ message: '导入成功' }); - } catch (err) { - res.status(500).json({ error: '导入失败: ' + err.message }); - } -}); - -// 前端路由 - 优先处理 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) => { - // 检查是否是 Next.js 预渲染的页面 - const 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.listen(PORT, '0.0.0.0', () => { - console.log(`记账本服务已启动: http://0.0.0.0:${PORT}`); -}); diff --git a/server/__tests__/validators.test.js b/server/__tests__/validators.test.js index a4b3b19..e53363e 100644 --- a/server/__tests__/validators.test.js +++ b/server/__tests__/validators.test.js @@ -1,7 +1,6 @@ const { isNonEmptyString, isValidMonth, - isValidDateTime, validateRecordPayload, } = require('../utils/validators'); diff --git a/server/index.js b/server/index.js index a2d8ca0..a975137 100644 --- a/server/index.js +++ b/server/index.js @@ -64,7 +64,7 @@ app.get('/_next/static/:path(*)', (req, res) => { }); app.get('*', (req, res) => { - let pagePath = req.path === '/' ? '/index.html' : req.path + '.html'; + const pagePath = req.path === '/' ? '/index.html' : req.path + '.html'; const nextPagePath = path.join(__dirname, '../frontend/.next/server/pages', pagePath); res.sendFile(nextPagePath, (err) => { diff --git a/server/middleware/errorHandler.js b/server/middleware/errorHandler.js index 99821e9..abdf71c 100644 --- a/server/middleware/errorHandler.js +++ b/server/middleware/errorHandler.js @@ -8,7 +8,7 @@ class AppError extends Error { } } -function errorHandler(err, req, res, next) { +function errorHandler(err, req, res, _next) { console.error({ message: err.message, stack: err.stack, diff --git a/server/routes/search.js b/server/routes/search.js index 55b0942..6d859d2 100644 --- a/server/routes/search.js +++ b/server/routes/search.js @@ -5,6 +5,11 @@ const { asyncHandler, AppError } = require('../middleware/errorHandler'); const router = express.Router(); +// 转义 SQL LIKE 特殊字符 +function escapeLikePattern(str) { + return str.replace(/[%_\\]/g, '\\$&'); +} + // 搜索账目 router.get('/', authenticate, asyncHandler(async (req, res) => { const { q } = req.query; @@ -13,14 +18,15 @@ router.get('/', authenticate, asyncHandler(async (req, res) => { throw new AppError('请输入搜索关键词', 400, 'MISSING_QUERY'); } + const escapedQ = escapeLikePattern(q); const stmt = db.prepare(` SELECT * FROM records - WHERE user_id = ? AND (category LIKE ? OR note LIKE ?) + WHERE user_id = ? AND (category LIKE ? ESCAPE '\\' OR note LIKE ? ESCAPE '\\') ORDER BY date DESC, id DESC LIMIT 50 `); - const records = stmt.all(req.userId, `%${q}%`, `%${q}%`); + const records = stmt.all(req.userId, `%${escapedQ}%`, `%${escapedQ}%`); res.json(records); }));