1.0.0:first commit

This commit is contained in:
2026-03-26 01:23:19 +08:00
commit f8d5b11567
23562 changed files with 2853775 additions and 0 deletions

47
server/routes/auth.js Executable file
View File

@@ -0,0 +1,47 @@
const express = require('express');
const {
AUTH_USERNAME,
clearAuthCookie,
isValidCredentials,
readSession,
setAuthCookie,
} = require('../auth');
const router = express.Router();
router.get('/me', (req, res) => {
const user = readSession(req);
if (!user) {
return res.status(401).json({ error: '当前未登录' });
}
res.json({ user });
});
router.post('/login', (req, res) => {
const username = String(req.body.username ?? '').trim();
const password = String(req.body.password ?? '');
if (!username || !password) {
return res.status(400).json({ error: '请输入用户名和密码' });
}
if (!isValidCredentials(username, password)) {
return res.status(401).json({ error: '用户名或密码错误' });
}
setAuthCookie(res, AUTH_USERNAME);
res.json({
user: {
username: AUTH_USERNAME,
},
});
});
router.post('/logout', (req, res) => {
clearAuthCookie(res);
res.json({ message: '已退出登录' });
});
module.exports = router;

114
server/routes/balance.js Executable file
View File

@@ -0,0 +1,114 @@
const express = require('express');
const { getDb } = require('../db');
const {
createUserNameResolver,
normalizeAmount,
roundCurrency,
} = require('../utils/accounting');
const router = express.Router();
function getUsers(db) {
return db.all('SELECT name FROM users ORDER BY sort_order, id');
}
function buildBalancePayload(db) {
const totalIncome = db.get("SELECT COALESCE(SUM(amount), 0) AS total FROM records WHERE type = 'income'");
const totalExpense = db.get("SELECT COALESCE(SUM(amount), 0) AS total FROM records WHERE type = 'expense'");
const income = roundCurrency(totalIncome?.total);
const expense = roundCurrency(totalExpense?.total);
const userBalances = getUsers(db).map((user) => {
const balanceSetting = db.get(
'SELECT initial_balance FROM user_balance_settings WHERE user_name = ?',
user.name
);
const userIncome = db.get(
"SELECT COALESCE(SUM(amount), 0) AS total FROM records WHERE type = 'income' AND user = ?",
user.name
);
const userExpense = db.get(
"SELECT COALESCE(SUM(amount), 0) AS total FROM records WHERE type = 'expense' AND user = ?",
user.name
);
const initialBalance = roundCurrency(balanceSetting?.initial_balance);
const resolvedIncome = roundCurrency(userIncome?.total);
const resolvedExpense = roundCurrency(userExpense?.total);
return {
user: user.name,
initial_balance: initialBalance,
income: resolvedIncome,
expense: resolvedExpense,
balance: roundCurrency(initialBalance + resolvedIncome - resolvedExpense),
};
});
const totalInitialBalance = roundCurrency(
userBalances.reduce((sum, item) => sum + item.initial_balance, 0)
);
return {
initial_balance: totalInitialBalance,
total_income: income,
total_expense: expense,
current_balance: roundCurrency(totalInitialBalance + income - expense),
userBalances,
};
}
router.get('/', (req, res) => {
res.json(buildBalancePayload(getDb()));
});
router.put('/', (req, res) => {
const db = getDb();
const initialBalance = normalizeAmount(req.body.initial_balance);
if (initialBalance === null) {
return res.status(400).json({ error: '请提供有效的初始余额' });
}
db.run(
'UPDATE balance_settings SET initial_balance = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1',
initialBalance
);
res.json(buildBalancePayload(db));
});
router.put('/user/:userName', (req, res) => {
const db = getDb();
const users = getUsers(db);
const resolveUserName = createUserNameResolver(users);
const userName = resolveUserName(req.params.userName, '');
const initialBalance = normalizeAmount(req.body.initial_balance);
if (!userName || !users.some((user) => user.name === userName)) {
return res.status(404).json({ error: '用户不存在' });
}
if (initialBalance === null) {
return res.status(400).json({ error: '请提供有效的初始余额' });
}
const existing = db.get('SELECT * FROM user_balance_settings WHERE user_name = ?', userName);
if (existing) {
db.run(
'UPDATE user_balance_settings SET initial_balance = ?, updated_at = CURRENT_TIMESTAMP WHERE user_name = ?',
initialBalance,
userName
);
} else {
db.run(
'INSERT INTO user_balance_settings (user_name, initial_balance) VALUES (?, ?)',
userName,
initialBalance
);
}
const payload = buildBalancePayload(db).userBalances.find((item) => item.user === userName);
res.json(payload);
});
module.exports = router;

90
server/routes/categories.js Executable file
View File

@@ -0,0 +1,90 @@
const express = require('express');
const router = express.Router();
const { getDb } = require('../db');
// 获取所有分类
router.get('/', (req, res) => {
const db = getDb();
const { type } = req.query;
let sql = 'SELECT * FROM categories';
const params = [];
if (type) {
sql += ' WHERE type = ?';
params.push(type);
}
sql += ' ORDER BY sort_order ASC';
const categories = db.all(sql, ...params);
res.json(categories);
});
// 新增分类
router.post('/', (req, res) => {
const db = getDb();
const { name, type = 'expense', icon, sort_order = 0 } = req.body;
if (!name) {
return res.status(400).json({ error: '分类名称不能为空' });
}
try {
const result = db.run(
'INSERT INTO categories (name, type, icon, sort_order) VALUES (?, ?, ?, ?)',
name, type, icon, sort_order
);
const category = db.get('SELECT * FROM categories WHERE id = ?', result.lastInsertRowid);
res.status(201).json(category);
} catch (err) {
if (err.message.includes('UNIQUE')) {
return res.status(400).json({ error: '分类名称已存在' });
}
throw err;
}
});
// 修改分类
router.put('/:id', (req, res) => {
const db = getDb();
const { id } = req.params;
const { name, type, icon, sort_order } = req.body;
const existing = db.get('SELECT * FROM categories WHERE id = ?', parseInt(id));
if (!existing) {
return res.status(404).json({ error: '分类不存在' });
}
db.run(
'UPDATE categories SET name = ?, type = ?, icon = ?, sort_order = ? WHERE id = ?',
name || existing.name,
type || existing.type,
icon !== undefined ? icon : existing.icon,
sort_order !== undefined ? sort_order : existing.sort_order,
parseInt(id)
);
const updated = db.get('SELECT * FROM categories WHERE id = ?', parseInt(id));
res.json(updated);
});
// 删除分类
router.delete('/:id', (req, res) => {
const db = getDb();
const { id } = req.params;
const recordCount = db.get(
'SELECT COUNT(*) as count FROM records WHERE category_id = ?', parseInt(id)
);
if (recordCount.count > 0) {
return res.status(400).json({ error: '该分类下存在记录,无法删除' });
}
const result = db.run('DELETE FROM categories WHERE id = ?', parseInt(id));
if (result.changes === 0) {
return res.status(404).json({ error: '分类不存在' });
}
res.json({ message: '删除成功' });
});
module.exports = router;

191
server/routes/records.js Executable file
View File

@@ -0,0 +1,191 @@
const express = require('express');
const { getDb } = require('../db');
const { createUserNameResolver, normalizeAmount } = require('../utils/accounting');
const router = express.Router();
function getUsers(db) {
return db.all('SELECT id, name FROM users ORDER BY sort_order, id');
}
function resolveUserName(db, inputName, fallbackName = '') {
const users = getUsers(db);
const defaultName = fallbackName || users[0]?.name || '';
const resolveName = createUserNameResolver(users);
const userName = resolveName(inputName, defaultName);
if (!userName) {
return { error: '请先创建用户' };
}
const exists = users.some((user) => user.name === userName);
if (!exists) {
return { error: '用户不存在,请先在记账页创建用户' };
}
return { userName };
}
function validateRecordPayload(db, payload, existingRecord = null) {
const date = payload.date ?? existingRecord?.date;
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date ?? ''))) {
return { error: '日期格式不正确' };
}
const type = payload.type ?? existingRecord?.type;
if (!['income', 'expense'].includes(type)) {
return { error: '收支类型不正确' };
}
const categoryIdValue = payload.category_id ?? existingRecord?.category_id;
const categoryId = Number.parseInt(categoryIdValue, 10);
if (!Number.isInteger(categoryId)) {
return { error: '分类不能为空' };
}
const category = db.get('SELECT id, type FROM categories WHERE id = ?', categoryId);
if (!category) {
return { error: '分类不存在' };
}
if (category.type !== type) {
return { error: '分类和收支类型不匹配,请重新选择分类' };
}
const rawAmount = payload.amount !== undefined ? payload.amount : existingRecord?.amount;
const amount = normalizeAmount(rawAmount);
if (amount === null || amount <= 0) {
return { error: '金额必须大于 0且最多保留两位小数' };
}
const resolvedUser = resolveUserName(db, payload.user, existingRecord?.user);
if (resolvedUser.error) {
return resolvedUser;
}
return {
record: {
date,
type,
category_id: categoryId,
amount,
note: payload.note === undefined
? (existingRecord?.note ?? null)
: (String(payload.note || '').trim() || null),
user: resolvedUser.userName,
},
};
}
function getRecordWithCategory(db, id) {
return db.get(`
SELECT r.*, c.name AS category_name, c.type AS category_type, c.icon AS category_icon
FROM records r
LEFT JOIN categories c ON r.category_id = c.id
WHERE r.id = ?
`, id);
}
router.get('/', (req, res) => {
const db = getDb();
const { date, month, start, end, page, limit = 50 } = req.query;
let sql = `
SELECT r.*, c.name AS category_name, c.type AS category_type, c.icon AS category_icon
FROM records r
LEFT JOIN categories c ON r.category_id = c.id
`;
const conditions = [];
const params = [];
if (date) {
conditions.push('r.date = ?');
params.push(date);
} else if (start && end) {
conditions.push('r.date BETWEEN ? AND ?');
params.push(start, end);
} else if (month) {
conditions.push('r.date LIKE ?');
params.push(`${month}%`);
}
if (conditions.length > 0) {
sql += ` WHERE ${conditions.join(' AND ')}`;
}
sql += ' ORDER BY r.date DESC, r.created_at DESC, r.id DESC';
if (page) {
const safeLimit = Number.parseInt(limit, 10) || 50;
const offset = (Number.parseInt(page, 10) - 1) * safeLimit;
sql += ' LIMIT ? OFFSET ?';
params.push(safeLimit, offset);
}
res.json(db.all(sql, ...params));
});
router.post('/', (req, res) => {
const db = getDb();
const validated = validateRecordPayload(db, req.body);
if (validated.error) {
return res.status(400).json({ error: validated.error });
}
const { record } = validated;
const result = db.run(
'INSERT INTO records (date, type, category_id, amount, note, user) VALUES (?, ?, ?, ?, ?, ?)',
record.date,
record.type,
record.category_id,
record.amount,
record.note,
record.user
);
res.status(201).json(getRecordWithCategory(db, result.lastInsertRowid));
});
router.put('/:id', (req, res) => {
const db = getDb();
const id = Number.parseInt(req.params.id, 10);
const existingRecord = db.get('SELECT * FROM records WHERE id = ?', id);
if (!existingRecord) {
return res.status(404).json({ error: '记录不存在' });
}
const validated = validateRecordPayload(db, req.body, existingRecord);
if (validated.error) {
return res.status(400).json({ error: validated.error });
}
const { record } = validated;
db.run(`
UPDATE records
SET date = ?, type = ?, category_id = ?, amount = ?, note = ?, user = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`,
record.date,
record.type,
record.category_id,
record.amount,
record.note,
record.user,
id);
res.json(getRecordWithCategory(db, id));
});
router.delete('/:id', (req, res) => {
const db = getDb();
const id = Number.parseInt(req.params.id, 10);
const result = db.run('DELETE FROM records WHERE id = ?', id);
if (result.changes === 0) {
return res.status(404).json({ error: '记录不存在' });
}
res.json({ message: '删除成功' });
});
module.exports = router;

244
server/routes/statistics.js Executable file
View File

@@ -0,0 +1,244 @@
const express = require('express');
const { getDb } = require('../db');
const { roundCurrency } = require('../utils/accounting');
const { getWeekRange } = require('../utils/date');
const router = express.Router();
function getUsers(db) {
return db.all('SELECT name FROM users ORDER BY sort_order, id');
}
function getTotalByType(db, whereClause, params, type) {
const row = db.get(
`SELECT COALESCE(SUM(amount), 0) AS total FROM records WHERE ${whereClause} AND type = ?`,
...params,
type
);
return roundCurrency(row?.total);
}
function getUserStats(db, whereClause, params) {
const users = getUsers(db);
const result = Object.fromEntries(
users.map((user) => [user.name, { income: 0, expense: 0 }])
);
const rows = db.all(`
SELECT user, type, ROUND(SUM(amount), 2) AS total
FROM records
WHERE ${whereClause}
GROUP BY user, type
`, ...params);
rows.forEach((row) => {
if (!result[row.user] || !['income', 'expense'].includes(row.type)) {
return;
}
result[row.user][row.type] = roundCurrency(row.total);
});
return result;
}
function getCategoriesWithUsers(db, whereClause, params) {
const users = getUsers(db);
const categories = db.all(`
SELECT c.id, c.name, c.icon, ROUND(SUM(r.amount), 2) AS total
FROM records r
JOIN categories c ON r.category_id = c.id
WHERE ${whereClause} AND r.type = 'expense'
GROUP BY c.id
ORDER BY total DESC, c.sort_order ASC, c.id ASC
`, ...params);
const userRows = db.all(`
SELECT c.id AS category_id, r.user, ROUND(SUM(r.amount), 2) AS total
FROM records r
JOIN categories c ON r.category_id = c.id
WHERE ${whereClause} AND r.type = 'expense'
GROUP BY c.id, r.user
`, ...params);
const userTotals = new Map();
userRows.forEach((row) => {
if (!userTotals.has(row.category_id)) {
userTotals.set(row.category_id, new Map());
}
userTotals.get(row.category_id).set(row.user, roundCurrency(row.total));
});
return categories.map((category) => ({
name: category.name,
icon: category.icon,
total: roundCurrency(category.total),
users: users.map((user) => ({
user: user.name,
total: userTotals.get(category.id)?.get(user.name) || 0,
})),
}));
}
function getGroupedSeries(db, whereClause, params, bucketExpr, bucketAlias) {
return db.all(`
SELECT ${bucketExpr} AS ${bucketAlias}, type, user, ROUND(SUM(amount), 2) AS total
FROM records
WHERE ${whereClause}
GROUP BY ${bucketAlias}, type, user
ORDER BY ${bucketAlias}, type, user
`, ...params).map((row) => ({
...row,
total: roundCurrency(row.total),
}));
}
function buildStatsPayload(db, whereClause, params, periodPayload, seriesKey, seriesValue) {
const income = getTotalByType(db, whereClause, params, 'income');
const expense = getTotalByType(db, whereClause, params, 'expense');
return {
...periodPayload,
income,
expense,
balance: roundCurrency(income - expense),
[seriesKey]: seriesValue,
categories: getCategoriesWithUsers(db, whereClause, params),
userStats: getUserStats(db, whereClause, params),
};
}
router.get('/daily', (req, res) => {
const db = getDb();
const { date } = req.query;
if (!date) {
return res.status(400).json({ error: '请提供日期参数' });
}
const dailyData = getGroupedSeries(db, 'date = ?', [date], 'date', 'date');
res.json(buildStatsPayload(db, 'date = ?', [date], { date }, 'dailyData', dailyData));
});
router.get('/weekly', (req, res) => {
const db = getDb();
const { date } = req.query;
if (!date) {
return res.status(400).json({ error: '请提供日期参数' });
}
const range = getWeekRange(date);
if (!range) {
return res.status(400).json({ error: '日期格式不正确' });
}
const { startDate, endDate } = range;
const dailyData = getGroupedSeries(
db,
'date BETWEEN ? AND ?',
[startDate, endDate],
'date',
'date'
);
res.json(
buildStatsPayload(
db,
'date BETWEEN ? AND ?',
[startDate, endDate],
{ startDate, endDate },
'dailyData',
dailyData
)
);
});
router.get('/monthly', (req, res) => {
const db = getDb();
const { month } = req.query;
if (!/^\d{4}-\d{2}$/.test(String(month ?? ''))) {
return res.status(400).json({ error: '请提供正确的月份参数' });
}
const pattern = `${month}%`;
const dailyData = getGroupedSeries(db, 'date LIKE ?', [pattern], 'date', 'date');
res.json(
buildStatsPayload(db, 'date LIKE ?', [pattern], { month }, 'dailyData', dailyData)
);
});
router.get('/yearly', (req, res) => {
const db = getDb();
const { year } = req.query;
if (!/^\d{4}$/.test(String(year ?? ''))) {
return res.status(400).json({ error: '请提供正确的年份参数' });
}
const pattern = `${year}%`;
const monthlyData = getGroupedSeries(
db,
'date LIKE ?',
[pattern],
'substr(date, 1, 7)',
'month'
);
res.json(
buildStatsPayload(db, 'date LIKE ?', [pattern], { year }, 'monthlyData', monthlyData)
);
});
router.get('/balance', (req, res) => {
const db = getDb();
const totalIncome = getTotalByType(db, '1 = 1', [], 'income');
const totalExpense = getTotalByType(db, '1 = 1', [], 'expense');
const userBalances = getUsers(db).map((user) => {
const initialBalance = roundCurrency(
db.get('SELECT initial_balance FROM user_balance_settings WHERE user_name = ?', user.name)
?.initial_balance
);
const income = getTotalByType(db, 'user = ?', [user.name], 'income');
const expense = getTotalByType(db, 'user = ?', [user.name], 'expense');
return {
user: user.name,
initial_balance: initialBalance,
income,
expense,
balance: roundCurrency(initialBalance + income - expense),
};
});
const totalInitialBalance = roundCurrency(
userBalances.reduce((sum, item) => sum + item.initial_balance, 0)
);
res.json({
initial_balance: totalInitialBalance,
total_income: totalIncome,
total_expense: totalExpense,
current_balance: roundCurrency(totalInitialBalance + totalIncome - totalExpense),
userBalances,
});
});
router.get('/trend', (req, res) => {
const db = getDb();
const { start, end } = req.query;
if (!start || !end) {
return res.status(400).json({ error: '请提供开始和结束日期' });
}
res.json(
getGroupedSeries(db, 'date BETWEEN ? AND ?', [start, end], 'date', 'date')
);
});
module.exports = router;

109
server/routes/users.js Executable file
View File

@@ -0,0 +1,109 @@
const express = require('express');
const { getDb } = require('../db');
const { createUserNameResolver, normalizeUserKey } = require('../utils/accounting');
const router = express.Router();
function getUsers(db) {
return db.all('SELECT * FROM users ORDER BY sort_order, id');
}
function findNormalizedUserConflict(users, targetName, excludedId = null) {
const normalizedTarget = normalizeUserKey(targetName);
return users.find(
(user) => user.id !== excludedId && normalizeUserKey(user.name) === normalizedTarget
);
}
function getRelatedRecordAliases(db, userName) {
const users = getUsers(db).map((user) => ({ name: user.name }));
const resolveUserName = createUserNameResolver(users);
const aliases = db.all('SELECT DISTINCT user FROM records WHERE user IS NOT NULL AND user != ""');
return aliases
.map((row) => row.user)
.filter((name) => resolveUserName(name, '') === userName);
}
router.get('/', (req, res) => {
res.json(getUsers(getDb()));
});
router.post('/', (req, res) => {
const db = getDb();
const name = String(req.body.name ?? '').trim();
if (!name) {
return res.status(400).json({ error: '用户名为必填项' });
}
const users = getUsers(db);
const conflict = findNormalizedUserConflict(users, name);
if (conflict) {
return res.status(400).json({ error: '用户名已存在,或与现有用户名仅空格不同' });
}
const maxOrder = db.get('SELECT MAX(sort_order) AS max_order FROM users')?.max_order || 0;
const result = db.run('INSERT INTO users (name, sort_order) VALUES (?, ?)', name, maxOrder + 1);
db.run('INSERT INTO user_balance_settings (user_name, initial_balance) VALUES (?, 0)', name);
res.status(201).json(db.get('SELECT * FROM users WHERE id = ?', result.lastInsertRowid));
});
router.put('/:id', (req, res) => {
const db = getDb();
const id = Number.parseInt(req.params.id, 10);
const name = String(req.body.name ?? '').trim();
if (!name) {
return res.status(400).json({ error: '用户名为必填项' });
}
const user = db.get('SELECT * FROM users WHERE id = ?', id);
if (!user) {
return res.status(404).json({ error: '用户不存在' });
}
const users = getUsers(db);
const conflict = findNormalizedUserConflict(users, name, id);
if (conflict) {
return res.status(400).json({ error: '用户名已存在,或与现有用户名仅空格不同' });
}
const aliases = getRelatedRecordAliases(db, user.name);
db.run('UPDATE users SET name = ? WHERE id = ?', name, id);
db.run('UPDATE user_balance_settings SET user_name = ? WHERE user_name = ?', name, user.name);
aliases.forEach((alias) => {
db.run('UPDATE records SET user = ? WHERE user = ?', name, alias);
});
res.json(db.get('SELECT * FROM users WHERE id = ?', id));
});
router.delete('/:id', (req, res) => {
const db = getDb();
const id = Number.parseInt(req.params.id, 10);
const user = db.get('SELECT * FROM users WHERE id = ?', id);
if (!user) {
return res.status(404).json({ error: '用户不存在' });
}
const aliases = getRelatedRecordAliases(db, user.name);
const recordCount = aliases.reduce((total, alias) => {
const count = db.get('SELECT COUNT(*) AS count FROM records WHERE user = ?', alias)?.count || 0;
return total + count;
}, 0);
if (recordCount > 0) {
return res.status(400).json({ error: `该用户还有 ${recordCount} 条记录,无法删除` });
}
db.run('DELETE FROM users WHERE id = ?', id);
db.run('DELETE FROM user_balance_settings WHERE user_name = ?', user.name);
res.json({ message: '删除成功' });
});
module.exports = router;