diff --git a/client/src/api/recurring.ts b/client/src/api/recurring.ts new file mode 100644 index 0000000..1805f39 --- /dev/null +++ b/client/src/api/recurring.ts @@ -0,0 +1,64 @@ +import { request } from '@/utils/request' + +/** 周期模板 */ +export interface RecurringTemplate { + id: number + user_id: number + amount: number + type: 'expense' | 'income' + category_id: number + note: string + frequency: 'weekly' | 'monthly' | 'yearly' + next_date: string + end_date: string | null + is_active: number + group_id: number | null + category_name?: string + category_icon?: string + category_color?: string + created_at: string + updated_at: string +} + +/** 获取周期模板列表 */ +export function getRecurringTemplates() { + return request({ url: '/recurring' }) +} + +/** 创建周期模板 */ +export function createRecurringTemplate(data: { + amount: number + type: 'expense' | 'income' + category_id: number + note?: string + frequency: 'weekly' | 'monthly' | 'yearly' + next_date: string + end_date?: string | null + group_id?: number | null +}) { + return request<{ id: number }>({ url: '/recurring', method: 'POST', data }) +} + +/** 更新周期模板 */ +export function updateRecurringTemplate(id: number, data: { + amount?: number + type?: 'expense' | 'income' + category_id?: number + note?: string + frequency?: 'weekly' | 'monthly' | 'yearly' + next_date?: string + end_date?: string | null + is_active?: number +}) { + return request({ url: `/recurring/${id}`, method: 'PUT', data }) +} + +/** 删除周期模板 */ +export function deleteRecurringTemplate(id: number) { + return request({ url: `/recurring/${id}`, method: 'DELETE' }) +} + +/** 同步周期账单(生成到期交易) */ +export function syncRecurring() { + return request<{ generated: number }>({ url: '/recurring/sync', method: 'POST' }) +} diff --git a/client/src/pages.json b/client/src/pages.json index 810de98..c9c900b 100644 --- a/client/src/pages.json +++ b/client/src/pages.json @@ -47,6 +47,13 @@ "enablePullDownRefresh": true } }, + { + "path": "pages/recurring/index", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "周期账单" + } + }, { "path": "pages/budget/index", "style": { diff --git a/client/src/pages/admin/feedback.vue b/client/src/pages/admin/feedback.vue index 1135490..9a71f80 100644 --- a/client/src/pages/admin/feedback.vue +++ b/client/src/pages/admin/feedback.vue @@ -108,7 +108,6 @@ {{ replyAction === 'processed' ? '回复并处理' : '回复并忽略' }} - diff --git a/client/src/pages/index/index.vue b/client/src/pages/index/index.vue index 7374fcf..f40677a 100644 --- a/client/src/pages/index/index.vue +++ b/client/src/pages/index/index.vue @@ -146,6 +146,7 @@ import { useGroupStore } from '@/stores/group' import { useNotificationStore } from '@/stores/notification' import { waitForReady } from '@/utils/app-ready' import { getOverview } from '@/api/stats' +import { syncRecurring } from '@/api/recurring' import type { Transaction } from '@/api/transaction' import { getNotificationImageUrl } from '@/api/notification' import { formatAmount, formatAmountRaw } from '@/utils/format' @@ -237,7 +238,8 @@ async function loadData(silent = false, force = false) { Promise.allSettled([ userStore.fetchUserInfo(), notifStore.fetchUnreadCount(), - notifStore.fetchUrgentNotifications() + notifStore.fetchUrgentNotifications(), + syncRecurring().catch(() => {}) ]).catch(() => {}) } catch (e) { console.error('Load error:', e) diff --git a/client/src/pages/profile/index.vue b/client/src/pages/profile/index.vue index 16443d3..6a2eedc 100644 --- a/client/src/pages/profile/index.vue +++ b/client/src/pages/profile/index.vue @@ -51,6 +51,10 @@ 分类管理 + + 周期账单 + + 数据导出 @@ -301,6 +305,10 @@ function goCategoryManage() { uni.navigateTo({ url: '/pages/category-manage/index' }) } +function goRecurring() { + uni.navigateTo({ url: '/pages/recurring/index' }) +} + function goPrivacy() { uni.navigateTo({ url: '/pages/privacy/index' }) } diff --git a/client/src/pages/recurring/index.vue b/client/src/pages/recurring/index.vue new file mode 100644 index 0000000..3860f1d --- /dev/null +++ b/client/src/pages/recurring/index.vue @@ -0,0 +1,500 @@ + + + + + diff --git a/server/src/db/init.ts b/server/src/db/init.ts index f7be8ee..17e4681 100644 --- a/server/src/db/init.ts +++ b/server/src/db/init.ts @@ -243,6 +243,40 @@ async function runMigrations(conn: mysql.Connection) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) } + + // 周期账单模板表 + const hasRecurring = await tableExists(conn, 'recurring_templates') + if (!hasRecurring) { + console.log('[DB] Migrating: creating recurring_templates table') + await conn.query(` + CREATE TABLE IF NOT EXISTS recurring_templates ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + amount INT NOT NULL COMMENT '单位:分', + type ENUM('expense', 'income') NOT NULL, + category_id INT NOT NULL, + note VARCHAR(200) DEFAULT '', + frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly', + next_date DATE NOT NULL COMMENT '下次生成日期', + end_date DATE DEFAULT NULL COMMENT '截止日期,NULL=永久', + is_active TINYINT(1) DEFAULT 1, + group_id INT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user (user_id), + INDEX idx_next_date (next_date), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) + } + + // transactions 表添加 recurring_id + const hasRecurringId = await columnExists(conn, 'transactions', 'recurring_id') + if (!hasRecurringId) { + console.log('[DB] Migrating: adding recurring_id to transactions') + await conn.query("ALTER TABLE transactions ADD COLUMN recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID' AFTER group_id") + } } export async function initDatabase() { diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql index 65b585e..af39cf7 100644 --- a/server/src/db/schema.sql +++ b/server/src/db/schema.sql @@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS transactions ( note VARCHAR(200), date DATE NOT NULL, group_id INT DEFAULT NULL COMMENT '群组标签(仅标记,群组账单通过 user_id 关联 group_members 统计)', + recurring_id INT DEFAULT NULL COMMENT '关联的周期模板ID', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user_date (user_id, date), @@ -149,3 +150,23 @@ CREATE TABLE IF NOT EXISTS track_events ( INDEX idx_created (created_at), INDEX idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS recurring_templates ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + amount INT NOT NULL COMMENT '单位:分', + type ENUM('expense', 'income') NOT NULL, + category_id INT NOT NULL, + note VARCHAR(200) DEFAULT '', + frequency ENUM('weekly', 'monthly', 'yearly') NOT NULL DEFAULT 'monthly', + next_date DATE NOT NULL COMMENT '下次生成日期', + end_date DATE DEFAULT NULL COMMENT '截止日期,NULL=永久', + is_active TINYINT(1) DEFAULT 1, + group_id INT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user (user_id), + INDEX idx_next_date (next_date), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/src/index.ts b/server/src/index.ts index ff26d82..7db4793 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -23,6 +23,7 @@ import feedbackRoutes from './routes/feedback' import configRoutes from './routes/config' import trackRoutes from './routes/track' import logsRoutes from './routes/logs' +import recurringRoutes from './routes/recurring' import { backupDatabase } from './utils/backup' // Warn if token secret is using default fallback @@ -161,6 +162,7 @@ app.use('/api/feedback', apiLimiter, feedbackRoutes) app.use('/api/config', apiLimiter, configRoutes) app.use('/api/track', apiLimiter, trackRoutes) app.use('/api/logs', apiLimiter, logsRoutes) +app.use('/api/recurring', apiLimiter, recurringRoutes) app.get('/api/health', async (_req, res) => { try { diff --git a/server/src/routes/recurring.ts b/server/src/routes/recurring.ts new file mode 100644 index 0000000..e9d51f1 --- /dev/null +++ b/server/src/routes/recurring.ts @@ -0,0 +1,205 @@ +import { Router } from 'express' +import pool from '../db/connection' +import { authMiddleware } from '../middleware/auth' + +const router = Router() +router.use(authMiddleware) + +/** 获取用户的周期模板列表 */ +router.get('/', async (req, res) => { + try { + const userId = (req as any).userId + const [rows] = await pool.execute( + `SELECT rt.*, c.name as category_name, c.icon as category_icon, c.color as category_color + FROM recurring_templates rt + LEFT JOIN categories c ON rt.category_id = c.id + WHERE rt.user_id = ? + ORDER BY rt.is_active DESC, rt.next_date ASC`, + [userId] + ) + res.json({ code: 0, data: rows }) + } catch (error) { + console.error('Get recurring templates error:', error) + res.status(500).json({ code: 50000, message: '获取失败' }) + } +}) + +/** 创建周期模板 */ +router.post('/', async (req, res) => { + try { + const userId = (req as any).userId + const { amount, type, category_id, note, frequency, next_date, end_date, group_id } = req.body + + if (!amount || amount <= 0) { + return res.status(400).json({ code: 40001, message: '请输入金额' }) + } + if (!type || !['expense', 'income'].includes(type)) { + return res.status(400).json({ code: 40001, message: '请选择类型' }) + } + if (!category_id) { + return res.status(400).json({ code: 40001, message: '请选择分类' }) + } + if (!frequency || !['weekly', 'monthly', 'yearly'].includes(frequency)) { + return res.status(400).json({ code: 40001, message: '请选择周期' }) + } + if (!next_date) { + return res.status(400).json({ code: 40001, message: '请选择开始日期' }) + } + + const [result] = await pool.execute( + `INSERT INTO recurring_templates (user_id, amount, type, category_id, note, frequency, next_date, end_date, group_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [userId, amount, type, category_id, note || '', frequency, next_date, end_date || null, group_id || null] + ) + res.json({ code: 0, data: { id: (result as any).insertId } }) + } catch (error) { + console.error('Create recurring template error:', error) + res.status(500).json({ code: 50000, message: '创建失败' }) + } +}) + +/** 更新周期模板 */ +router.put('/:id', async (req, res) => { + try { + const userId = (req as any).userId + const { id } = req.params + const { amount, type, category_id, note, frequency, next_date, end_date, is_active } = req.body + + // 校验所有权 + const [rows] = await pool.execute( + 'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?', + [id, userId] + ) + if ((rows as any[]).length === 0) { + return res.status(404).json({ code: 40400, message: '模板不存在' }) + } + + const updates: string[] = [] + const params: any[] = [] + + if (amount !== undefined) { updates.push('amount = ?'); params.push(amount) } + if (type !== undefined) { updates.push('type = ?'); params.push(type) } + if (category_id !== undefined) { updates.push('category_id = ?'); params.push(category_id) } + if (note !== undefined) { updates.push('note = ?'); params.push(note) } + if (frequency !== undefined) { updates.push('frequency = ?'); params.push(frequency) } + if (next_date !== undefined) { updates.push('next_date = ?'); params.push(next_date) } + if (end_date !== undefined) { updates.push('end_date = ?'); params.push(end_date) } + if (is_active !== undefined) { updates.push('is_active = ?'); params.push(is_active) } + + if (updates.length === 0) { + return res.status(400).json({ code: 40001, message: '无更新字段' }) + } + + params.push(id, userId) + await pool.execute( + `UPDATE recurring_templates SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`, + params + ) + res.json({ code: 0 }) + } catch (error) { + console.error('Update recurring template error:', error) + res.status(500).json({ code: 50000, message: '更新失败' }) + } +}) + +/** 删除周期模板 */ +router.delete('/:id', async (req, res) => { + try { + const userId = (req as any).userId + const { id } = req.params + + const [rows] = await pool.execute( + 'SELECT id FROM recurring_templates WHERE id = ? AND user_id = ?', + [id, userId] + ) + if ((rows as any[]).length === 0) { + return res.status(404).json({ code: 40400, message: '模板不存在' }) + } + + await pool.execute( + 'DELETE FROM recurring_templates WHERE id = ? AND user_id = ?', + [id, userId] + ) + res.json({ code: 0 }) + } catch (error) { + console.error('Delete recurring template error:', error) + res.status(500).json({ code: 50000, message: '删除失败' }) + } +}) + +/** 同步:检查所有活跃模板,生成到期交易 */ +router.post('/sync', async (req, res) => { + try { + const userId = (req as any).userId + // 获取该用户所有活跃且到期(next_date <= 今天)的模板 + const [templates] = await pool.execute( + `SELECT * FROM recurring_templates + WHERE user_id = ? AND is_active = 1 AND next_date <= CURDATE() + ORDER BY next_date ASC`, + [userId] + ) + + let generated = 0 + const today = new Date() + const todayStr = today.toISOString().slice(0, 10) + + for (const t of templates as any[]) { + let next = new Date(t.next_date) + const end = t.end_date ? new Date(t.end_date) : null + + // 生成所有错过的交易 + while (next <= today) { + if (end && next > end) break + + // 检查是否已生成过(避免重复) + const [existing] = await pool.execute( + `SELECT id FROM transactions + WHERE user_id = ? AND recurring_id = ? AND date = ?`, + [userId, t.id, next.toISOString().slice(0, 10)] + ) + if ((existing as any[]).length === 0) { + await pool.execute( + `INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id, recurring_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [userId, t.amount, t.type, t.category_id, t.note, + next.toISOString().slice(0, 10), t.group_id, t.id] + ) + generated++ + } + + // 推进到下一周期 + if (t.frequency === 'weekly') { + next = new Date(next.getTime() + 7 * 86400000) + } else if (t.frequency === 'monthly') { + next = new Date(next.getFullYear(), next.getMonth() + 1, next.getDate()) + } else if (t.frequency === 'yearly') { + next = new Date(next.getFullYear() + 1, next.getMonth(), next.getDate()) + } + } + + // 更新 next_date 到下一周期 + let newNext: Date + if (t.frequency === 'weekly') { + newNext = new Date(new Date(t.next_date).getTime() + 7 * 86400000) + } else if (t.frequency === 'monthly') { + const d = new Date(t.next_date) + newNext = new Date(d.getFullYear(), d.getMonth() + 1, d.getDate()) + } else { + const d = new Date(t.next_date) + newNext = new Date(d.getFullYear() + 1, d.getMonth(), d.getDate()) + } + + await pool.execute( + 'UPDATE recurring_templates SET next_date = ? WHERE id = ?', + [newNext.toISOString().slice(0, 10), t.id] + ) + } + + res.json({ code: 0, data: { generated } }) + } catch (error) { + console.error('Sync recurring error:', error) + res.status(500).json({ code: 50000, message: '同步失败' }) + } +}) + +export default router