Compare commits
6
Commits
3975bbed73
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d10dd53884 | ||
|
|
4ad1766f10 | ||
|
|
38b56f387c | ||
|
|
6c5e7dc811 | ||
|
|
98aa39cb1c | ||
|
|
63c97ab31a |
+1
-1
@@ -20,7 +20,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
version: 'detect',
|
version: '18.2',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ node_modules/
|
|||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'accountbook',
|
||||||
|
cwd: '/opt/accountbook',
|
||||||
|
script: 'npm',
|
||||||
|
args: 'start',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: '3500',
|
||||||
|
},
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_restarts: 10,
|
||||||
|
min_uptime: '10s',
|
||||||
|
out_file: '/opt/accountbook/logs/accountbook.out.log',
|
||||||
|
error_file: '/opt/accountbook/logs/accountbook.err.log',
|
||||||
|
merge_logs: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
+49
-5
@@ -1,7 +1,49 @@
|
|||||||
const API_BASE = ''; // 使用相对路径
|
const API_BASE = ''; // 使用相对路径
|
||||||
|
|
||||||
|
function readTokenPayload(token) {
|
||||||
|
try {
|
||||||
|
const payload = token.split('.')[1];
|
||||||
|
if (!payload) return null;
|
||||||
|
|
||||||
|
const normalizedPayload = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const paddedPayload = normalizedPayload.padEnd(
|
||||||
|
normalizedPayload.length + ((4 - (normalizedPayload.length % 4)) % 4),
|
||||||
|
'='
|
||||||
|
);
|
||||||
|
|
||||||
|
return JSON.parse(atob(paddedPayload));
|
||||||
|
} catch (err) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getValidToken() {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const payload = readTokenPayload(token);
|
||||||
|
if (!payload?.exp || payload.exp * 1000 <= Date.now()) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToLogin() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.replace('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getAuthHeaders() {
|
function getAuthHeaders() {
|
||||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
const token = getValidToken();
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +66,9 @@ async function apiCall(url, options = {}) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (res.status === 401 && !url.startsWith('/api/auth/')) {
|
||||||
|
redirectToLogin();
|
||||||
|
}
|
||||||
throw new Error(data.error || '请求失败');
|
throw new Error(data.error || '请求失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +105,7 @@ export function logout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isLoggedIn() {
|
export function isLoggedIn() {
|
||||||
if (typeof window === 'undefined') return false;
|
return !!getValidToken();
|
||||||
return !!localStorage.getItem('token');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 账目
|
// 账目
|
||||||
@@ -106,7 +150,7 @@ export async function deleteRecordsByCategory(category) {
|
|||||||
|
|
||||||
// 搜索
|
// 搜索
|
||||||
export async function searchRecords(keyword) {
|
export async function searchRecords(keyword) {
|
||||||
return apiCall(`/api/records/search?q=${encodeURIComponent(keyword)}`);
|
return apiCall(`/api/search?q=${encodeURIComponent(keyword)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统计
|
// 统计
|
||||||
@@ -177,7 +221,7 @@ export async function exportData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function importData(data) {
|
export async function importData(data) {
|
||||||
return apiCall('/api/import', {
|
return apiCall('/api/export', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { getRecords, getStats, getCategories, getRecurringBills } from './api';
|
|
||||||
|
|
||||||
// 全局 SWR 配置
|
// 全局 SWR 配置
|
||||||
export const swrConfig = {
|
export const swrConfig = {
|
||||||
refreshInterval: 0, // 默认不自动刷新
|
refreshInterval: 0, // 默认不自动刷新
|
||||||
@@ -11,11 +9,3 @@ export const swrConfig = {
|
|||||||
console.error('SWR Error:', error);
|
console.error('SWR Error:', error);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 数据获取器映射
|
|
||||||
export const fetchers = {
|
|
||||||
records: (month, type) => getRecords(month, type),
|
|
||||||
stats: (month) => getStats(month),
|
|
||||||
categories: () => getCategories(),
|
|
||||||
recurring: () => getRecurringBills(),
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ export const formatMonth = (date) =>
|
|||||||
|
|
||||||
export const formatMoney = (num) => (num || 0).toFixed(2);
|
export const formatMoney = (num) => (num || 0).toFixed(2);
|
||||||
|
|
||||||
|
// Format date as 'YYYY-MM-DD'
|
||||||
|
export const formatDate = (date) =>
|
||||||
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Get current time string 'HH:MM'
|
||||||
|
export const getCurrentTimeStr = () => new Date().toTimeString().slice(0, 5);
|
||||||
|
|
||||||
// Parse date-time string 'YYYY-MM-DD HH:MM' into date and time parts
|
// Parse date-time string 'YYYY-MM-DD HH:MM' into date and time parts
|
||||||
export const parseDateTime = (dateTimeStr) => {
|
export const parseDateTime = (dateTimeStr) => {
|
||||||
if (!dateTimeStr) return { datePart: '', timePart: '00:00' };
|
if (!dateTimeStr) return { datePart: '', timePart: '00:00' };
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
devIndicators: {
|
||||||
|
buildActivity: false,
|
||||||
|
},
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3500",
|
"dev": "next dev -H 0.0.0.0 -p 3500",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3500"
|
"start": "next start -H 0.0.0.0 -p 3500"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
export default async function handler(req, res) {
|
export default async function handler(req, res) {
|
||||||
const { 0: path } = req.query;
|
const path = Array.isArray(req.query.path)
|
||||||
|
? req.query.path.join('/')
|
||||||
|
: req.query.path;
|
||||||
|
|
||||||
const backendUrl = `http://localhost:3501/${path}`;
|
if (!path) {
|
||||||
|
res.status(400).json({ error: 'Missing API path' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = { ...req.query };
|
||||||
|
delete query.path;
|
||||||
|
const searchParams = new URLSearchParams(query);
|
||||||
|
const backendUrl = `http://localhost:3501/api/${path}${searchParams.size ? `?${searchParams}` : ''}`;
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
const response = await fetch(backendUrl, {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { isLoggedIn, getCategories, createCategory, deleteCategory } from '../lib/api';
|
import { isLoggedIn, getCategories, createCategory, deleteCategory } from '../lib/api';
|
||||||
import AppShell from '../components/AppShell';
|
import AppShell from '../components/AppShell';
|
||||||
@@ -30,15 +30,7 @@ export default function Categories() {
|
|||||||
icon: '📝',
|
icon: '📝',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const loadCategories = useCallback(async () => {
|
||||||
if (!isLoggedIn()) {
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadCategories();
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const loadCategories = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getCategories();
|
const data = await getCategories();
|
||||||
@@ -49,7 +41,15 @@ export default function Categories() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadCategories();
|
||||||
|
}, [router, loadCategories]);
|
||||||
|
|
||||||
const openAddModal = (type) => {
|
const openAddModal = (type) => {
|
||||||
setCategoryType(type);
|
setCategoryType(type);
|
||||||
|
|||||||
+22
-16
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRecord } from '../lib/api';
|
import { isLoggedIn, getRecords, getStats, createRecord, updateRecord, deleteRecord } from '../lib/api';
|
||||||
import AppShell from '../components/AppShell';
|
import AppShell from '../components/AppShell';
|
||||||
@@ -7,7 +7,7 @@ import RecordModal from '../components/RecordModal';
|
|||||||
import { useToast } from '../components/ToastProvider';
|
import { useToast } from '../components/ToastProvider';
|
||||||
import { useConfirm } from '../components/ConfirmProvider';
|
import { useConfirm } from '../components/ConfirmProvider';
|
||||||
import { useCategories } from '../hooks/useCategories';
|
import { useCategories } from '../hooks/useCategories';
|
||||||
import { getMonthStr, formatMoney, formatMonth, parseDateTime, getDatePart } from '../lib/utils';
|
import { getMonthStr, formatMoney, formatMonth, parseDateTime, getDatePart, formatDate, getCurrentTimeStr } from '../lib/utils';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -30,13 +30,14 @@ export default function Home() {
|
|||||||
if (categoriesError) {
|
if (categoriesError) {
|
||||||
showToast('分类加载失败', { tone: 'error' });
|
showToast('分类加载失败', { tone: 'error' });
|
||||||
}
|
}
|
||||||
}, [categoriesError]);
|
}, [categoriesError, showToast]);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
type: 'expense',
|
type: 'expense',
|
||||||
amount: '',
|
amount: '',
|
||||||
category: '',
|
category: '',
|
||||||
date: new Date().toISOString().split('T')[0],
|
date: formatDate(new Date()),
|
||||||
time: new Date().toTimeString().slice(0, 5),
|
time: getCurrentTimeStr(),
|
||||||
note: '',
|
note: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ export default function Home() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadData();
|
loadData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [router, currentMonth, typeFilter]);
|
}, [router, currentMonth, typeFilter]);
|
||||||
|
|
||||||
|
|
||||||
@@ -53,8 +55,8 @@ export default function Home() {
|
|||||||
type: 'expense',
|
type: 'expense',
|
||||||
amount: '',
|
amount: '',
|
||||||
category: '',
|
category: '',
|
||||||
date: new Date().toISOString().split('T')[0],
|
date: formatDate(new Date()),
|
||||||
time: new Date().toTimeString().slice(0, 5),
|
time: getCurrentTimeStr(),
|
||||||
note: '',
|
note: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ export default function Home() {
|
|||||||
return icons;
|
return icons;
|
||||||
}, [categories]);
|
}, [categories]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [recordsData, statsData] = await Promise.all([
|
const [recordsData, statsData] = await Promise.all([
|
||||||
@@ -84,7 +86,7 @@ export default function Home() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [currentMonth, typeFilter, showToast]);
|
||||||
|
|
||||||
const prevMonth = () => {
|
const prevMonth = () => {
|
||||||
const newDate = new Date(currentMonth);
|
const newDate = new Date(currentMonth);
|
||||||
@@ -206,12 +208,16 @@ export default function Home() {
|
|||||||
[groupedRecords]
|
[groupedRecords]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getDayExpense = (date) => {
|
const dayExpenses = useMemo(() => {
|
||||||
const dayRecords = groupedRecords[date] || [];
|
const expenses = {};
|
||||||
return dayRecords
|
for (const date of Object.keys(groupedRecords)) {
|
||||||
.filter((record) => record.type === 'expense')
|
const dayRecords = groupedRecords[date] || [];
|
||||||
.reduce((sum, record) => sum + record.amount, 0);
|
expenses[date] = dayRecords
|
||||||
};
|
.filter((record) => record.type === 'expense')
|
||||||
|
.reduce((sum, record) => sum + record.amount, 0);
|
||||||
|
}
|
||||||
|
return expenses;
|
||||||
|
}, [groupedRecords]);
|
||||||
|
|
||||||
const homeHeaderContent = (
|
const homeHeaderContent = (
|
||||||
<div className="header-center-container">
|
<div className="header-center-container">
|
||||||
@@ -297,7 +303,7 @@ export default function Home() {
|
|||||||
<div key={date} className="date-group">
|
<div key={date} className="date-group">
|
||||||
<div className="date-label">
|
<div className="date-label">
|
||||||
<span>{new Date(date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' })}</span>
|
<span>{new Date(date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' })}</span>
|
||||||
<span className="day-expense">支出 {formatMoney(getDayExpense(date))}</span>
|
<span className="day-expense">支出 {formatMoney(dayExpenses[date] || 0)}</span>
|
||||||
</div>
|
</div>
|
||||||
{groupedRecords[date].map((record) => {
|
{groupedRecords[date].map((record) => {
|
||||||
const icon = categoryIconMap[`${record.type}:${record.category}`] || '📝';
|
const icon = categoryIconMap[`${record.type}:${record.category}`] || '📝';
|
||||||
|
|||||||
+12
-12
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { isLoggedIn, getRecurringBills, createRecurringBill, updateRecurringBill, deleteRecurringBill, applyRecurringBill } from '../lib/api';
|
import { isLoggedIn, getRecurringBills, createRecurringBill, updateRecurringBill, deleteRecurringBill, applyRecurringBill } from '../lib/api';
|
||||||
import AppShell from '../components/AppShell';
|
import AppShell from '../components/AppShell';
|
||||||
@@ -20,7 +20,7 @@ export default function Recurring() {
|
|||||||
if (categoriesError) {
|
if (categoriesError) {
|
||||||
showToast('分类加载失败', { tone: 'error' });
|
showToast('分类加载失败', { tone: 'error' });
|
||||||
}
|
}
|
||||||
}, [categoriesError]);
|
}, [categoriesError, showToast]);
|
||||||
const [bills, setBills] = useState([]);
|
const [bills, setBills] = useState([]);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editingBill, setEditingBill] = useState(null);
|
const [editingBill, setEditingBill] = useState(null);
|
||||||
@@ -38,15 +38,7 @@ export default function Recurring() {
|
|||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const loadBills = useCallback(async () => {
|
||||||
if (!isLoggedIn()) {
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadBills();
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const loadBills = async () => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getRecurringBills();
|
const data = await getRecurringBills();
|
||||||
@@ -57,7 +49,15 @@ export default function Recurring() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadBills();
|
||||||
|
}, [router, loadBills]);
|
||||||
|
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
setEditingBill(null);
|
setEditingBill(null);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { formatMoney, parseDateTime, getDatePart } from '../lib/utils';
|
|||||||
export default function Search() {
|
export default function Search() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const confirm = useConfirm();
|
const { confirm } = useConfirm();
|
||||||
const { categories, error: categoriesError } = useCategories();
|
const { categories, error: categoriesError } = useCategories();
|
||||||
|
|
||||||
// 显示分类加载错误
|
// 显示分类加载错误
|
||||||
@@ -20,7 +20,7 @@ export default function Search() {
|
|||||||
if (categoriesError) {
|
if (categoriesError) {
|
||||||
showToast('分类加载失败', { tone: 'error' });
|
showToast('分类加载失败', { tone: 'error' });
|
||||||
}
|
}
|
||||||
}, [categoriesError]);
|
}, [categoriesError, showToast]);
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [results, setResults] = useState([]);
|
const [results, setResults] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -49,7 +49,7 @@ export default function Search() {
|
|||||||
if (router.isReady && router.query.q) {
|
if (router.isReady && router.query.q) {
|
||||||
const q = decodeURIComponent(router.query.q);
|
const q = decodeURIComponent(router.query.q);
|
||||||
// Skip if already have results for this keyword (prevents double API call)
|
// Skip if already have results for this keyword (prevents double API call)
|
||||||
if (keyword === q && results.length > 0) return;
|
if (keyword === q) return;
|
||||||
setKeyword(q);
|
setKeyword(q);
|
||||||
// 自动搜索
|
// 自动搜索
|
||||||
const doSearch = async () => {
|
const doSearch = async () => {
|
||||||
@@ -66,7 +66,7 @@ export default function Search() {
|
|||||||
};
|
};
|
||||||
doSearch();
|
doSearch();
|
||||||
}
|
}
|
||||||
}, [router.isReady, router.query.q, keyword, results]);
|
}, [router.isReady, router.query.q, keyword, showToast]);
|
||||||
|
|
||||||
const handleSearch = async (e) => {
|
const handleSearch = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
+34
-20
@@ -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 { useRouter } from 'next/router';
|
||||||
import { isLoggedIn, getTrendStats, getCategoryStats } from '../lib/api';
|
import { isLoggedIn, getTrendStats, getCategoryStats } from '../lib/api';
|
||||||
import {
|
import {
|
||||||
@@ -39,23 +39,11 @@ export default function Stats() {
|
|||||||
const trendChartRef = useRef(null);
|
const trendChartRef = useRef(null);
|
||||||
const trendChartInstance = useRef(null);
|
const trendChartInstance = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const loadTrendData = useCallback(async () => {
|
||||||
if (!isLoggedIn()) {
|
|
||||||
router.push('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadTrendData();
|
|
||||||
}, [router, period, currentMonth]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoggedIn()) return;
|
|
||||||
loadCategoryData();
|
|
||||||
}, [router, currentMonth, categoryType]);
|
|
||||||
|
|
||||||
const loadTrendData = async () => {
|
|
||||||
setTrendLoading(true);
|
setTrendLoading(true);
|
||||||
try {
|
try {
|
||||||
const trend = await getTrendStats(period, getMonthStr(currentMonth));
|
// 趋势统计始终显示全部历史数据,不受月份筛选影响
|
||||||
|
const trend = await getTrendStats(period, null);
|
||||||
setTrendData(trend);
|
setTrendData(trend);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -63,9 +51,9 @@ export default function Stats() {
|
|||||||
} finally {
|
} finally {
|
||||||
setTrendLoading(false);
|
setTrendLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [period, showToast]);
|
||||||
|
|
||||||
const loadCategoryData = async () => {
|
const loadCategoryData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const category = await getCategoryStats(getMonthStr(currentMonth), categoryType);
|
const category = await getCategoryStats(getMonthStr(currentMonth), categoryType);
|
||||||
setCategoryData(category);
|
setCategoryData(category);
|
||||||
@@ -73,11 +61,31 @@ export default function Stats() {
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
showToast(err.message, { tone: 'error' });
|
showToast(err.message, { tone: 'error' });
|
||||||
}
|
}
|
||||||
};
|
}, [currentMonth, categoryType, showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
router.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 并行加载两个数据集
|
||||||
|
Promise.all([loadTrendData(), loadCategoryData()]);
|
||||||
|
}, [router, loadTrendData, loadCategoryData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// 组件卸载时清理 Chart.js 实例
|
||||||
|
if (trendChartInstance.current) {
|
||||||
|
trendChartInstance.current.destroy();
|
||||||
|
trendChartInstance.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (trendChartInstance.current) {
|
if (trendChartInstance.current) {
|
||||||
trendChartInstance.current.destroy();
|
trendChartInstance.current.destroy();
|
||||||
|
trendChartInstance.current = null;
|
||||||
}
|
}
|
||||||
if (trendChartRef.current && trendData.length > 0) {
|
if (trendChartRef.current && trendData.length > 0) {
|
||||||
const ctx = trendChartRef.current.getContext('2d');
|
const ctx = trendChartRef.current.getContext('2d');
|
||||||
@@ -134,9 +142,15 @@ export default function Stats() {
|
|||||||
setCurrentMonth(newDate);
|
setCurrentMonth(newDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 当分类数据加载后,默认选中所有分类
|
||||||
|
useEffect(() => {
|
||||||
|
if (categoryData.length > 0) {
|
||||||
|
setSelectedCategories(categoryData.map(cat => cat.category));
|
||||||
|
}
|
||||||
|
}, [categoryData]);
|
||||||
|
|
||||||
const handleCategoryTypeChange = (type) => {
|
const handleCategoryTypeChange = (type) => {
|
||||||
setCategoryType(type);
|
setCategoryType(type);
|
||||||
setSelectedCategories([]); // 切换类型时清空选中
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleCategory = (categoryName) => {
|
const toggleCategory = (categoryName) => {
|
||||||
|
|||||||
@@ -2181,11 +2181,3 @@ textarea:focus-visible {
|
|||||||
.is-spinning {
|
.is-spinning {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-spinning {
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^9.2.2",
|
"better-sqlite3": "^9.6.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^9.2.2",
|
"better-sqlite3": "^9.6.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|||||||
@@ -1,832 +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')));
|
|
||||||
app.use(express.static(path.join(__dirname, 'frontend/.next/server/pages')));
|
|
||||||
|
|
||||||
// 数据库初始化
|
|
||||||
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}`);
|
|
||||||
});
|
|
||||||
@@ -1,824 +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')));
|
|
||||||
app.use(express.static(path.join(__dirname, 'frontend/.next/server/pages')));
|
|
||||||
|
|
||||||
// 数据库初始化
|
|
||||||
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)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
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') {
|
|
||||||
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 预渲染的页面
|
|
||||||
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.listen(PORT, '0.0.0.0', () => {
|
|
||||||
console.log(`记账本服务已启动: http://0.0.0.0:${PORT}`);
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
const {
|
const {
|
||||||
isNonEmptyString,
|
isNonEmptyString,
|
||||||
isValidMonth,
|
isValidMonth,
|
||||||
isValidDateTime,
|
|
||||||
validateRecordPayload,
|
validateRecordPayload,
|
||||||
} = require('../utils/validators');
|
} = require('../utils/validators');
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -31,9 +31,6 @@ app.use(cors({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
app.use(express.json());
|
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) => {
|
app.use((req, res, next) => {
|
||||||
@@ -55,6 +52,10 @@ app.get('/api/health', (req, res) => {
|
|||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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')));
|
||||||
|
|
||||||
// 前端路由 - Next.js 静态文件
|
// 前端路由 - Next.js 静态文件
|
||||||
app.get('/_next/static/:path(*)', (req, res) => {
|
app.get('/_next/static/:path(*)', (req, res) => {
|
||||||
const filePath = path.join(__dirname, '../frontend/.next/static', req.params.path);
|
const filePath = path.join(__dirname, '../frontend/.next/static', req.params.path);
|
||||||
@@ -64,7 +65,7 @@ app.get('/_next/static/:path(*)', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('*', (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);
|
const nextPagePath = path.join(__dirname, '../frontend/.next/server/pages', pagePath);
|
||||||
|
|
||||||
res.sendFile(nextPagePath, (err) => {
|
res.sendFile(nextPagePath, (err) => {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { AppError } = require('./errorHandler');
|
const { AppError } = require('./errorHandler');
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'accountbook_secret_key_2024';
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
|
|
||||||
|
if (!JWT_SECRET && process.env.NODE_ENV === 'production') {
|
||||||
|
throw new Error('JWT_SECRET must be set in production');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenSecret = JWT_SECRET || 'accountbook_secret_key_2024';
|
||||||
|
|
||||||
function authenticate(req, res, next) {
|
function authenticate(req, res, next) {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
@@ -10,7 +16,7 @@ function authenticate(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
const decoded = jwt.verify(token, tokenSecret);
|
||||||
req.userId = decoded.userId;
|
req.userId = decoded.userId;
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -19,11 +25,11 @@ function authenticate(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function generateToken(userId) {
|
function generateToken(userId) {
|
||||||
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' });
|
return jwt.sign({ userId }, tokenSecret, { expiresIn: '7d' });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
authenticate,
|
authenticate,
|
||||||
generateToken,
|
generateToken,
|
||||||
JWT_SECRET,
|
JWT_SECRET: tokenSecret,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class AppError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorHandler(err, req, res, next) {
|
function errorHandler(err, req, res, _next) {
|
||||||
console.error({
|
console.error({
|
||||||
message: err.message,
|
message: err.message,
|
||||||
stack: err.stack,
|
stack: err.stack,
|
||||||
|
|||||||
@@ -48,7 +48,10 @@ router.post('/', authenticate, asyncHandler(async (req, res) => {
|
|||||||
const result = stmt.run(req.userId, type, amount, category.trim(), date, normalizeNote(note));
|
const result = stmt.run(req.userId, type, amount, category.trim(), date, normalizeNote(note));
|
||||||
console.log(`Record created: user=${req.userId}, id=${result.lastInsertRowid}`);
|
console.log(`Record created: user=${req.userId}, id=${result.lastInsertRowid}`);
|
||||||
|
|
||||||
res.json({ id: result.lastInsertRowid, message: '添加成功' });
|
const createdRecord = db.prepare('SELECT * FROM records WHERE id = ? AND user_id = ?')
|
||||||
|
.get(result.lastInsertRowid, req.userId);
|
||||||
|
|
||||||
|
res.json(createdRecord);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 更新账目
|
// 更新账目
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ const { asyncHandler, AppError } = require('../middleware/errorHandler');
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// 转义 SQL LIKE 特殊字符
|
||||||
|
function escapeLikePattern(str) {
|
||||||
|
return str.replace(/[%_\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
// 搜索账目
|
// 搜索账目
|
||||||
router.get('/', authenticate, asyncHandler(async (req, res) => {
|
router.get('/', authenticate, asyncHandler(async (req, res) => {
|
||||||
const { q } = req.query;
|
const { q } = req.query;
|
||||||
@@ -13,14 +18,15 @@ router.get('/', authenticate, asyncHandler(async (req, res) => {
|
|||||||
throw new AppError('请输入搜索关键词', 400, 'MISSING_QUERY');
|
throw new AppError('请输入搜索关键词', 400, 'MISSING_QUERY');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const escapedQ = escapeLikePattern(q);
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
SELECT * FROM records
|
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
|
ORDER BY date DESC, id DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const records = stmt.all(req.userId, `%${q}%`, `%${q}%`);
|
const records = stmt.all(req.userId, `%${escapedQ}%`, `%${escapedQ}%`);
|
||||||
res.json(records);
|
res.json(records);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user