feat: 迭代三大模块 + 全系统 Bug 修复
新功能: - 账单筛选统计:多维度筛选面板 + 保存方案 + 结果统计 - 通知公告:系统公告/群组通知/个人提醒 + 通知中心页面 - 管理员后台:数据看板 + 用户管理 + 发布公告 Bug 修复: - 统计页 fetchPrevMonthData/fetchMaxSingle 添加 group_id - 群组视图添加 t.group_id 过滤,防止私人数据泄露 - 通知列表添加群组通知条件 - 通知标记已读添加错误处理 - 管理员 API 添加 affectedRows 检查 - 筛选面板金额为 0 时正确回填 - profile onShow 补充 userStore.fetchUserInfo - 全系统样式修复(overflow/box-sizing)
This commit is contained in:
@@ -11,6 +11,14 @@ async function columnExists(conn: mysql.Connection, table: string, column: strin
|
||||
return (rows as any[])[0].cnt > 0
|
||||
}
|
||||
|
||||
async function tableExists(conn: mysql.Connection, table: string): Promise<boolean> {
|
||||
const [rows] = await conn.query(
|
||||
`SELECT COUNT(*) as cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?`,
|
||||
[table]
|
||||
)
|
||||
return (rows as any[])[0].cnt > 0
|
||||
}
|
||||
|
||||
async function runMigrations(conn: mysql.Connection) {
|
||||
console.log('[DB] Checking migrations...')
|
||||
|
||||
@@ -83,6 +91,49 @@ async function runMigrations(conn: mysql.Connection) {
|
||||
await conn.query("ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date)")
|
||||
console.log('[DB] Groups migration complete')
|
||||
}
|
||||
|
||||
// users 表添加 role(管理员角色)
|
||||
const hasRole = await columnExists(conn, 'users', 'role')
|
||||
if (!hasRole) {
|
||||
console.log('[DB] Migrating: adding role to users')
|
||||
await conn.query("ALTER TABLE users ADD COLUMN role ENUM('user', 'admin') DEFAULT 'user' AFTER avatar_url")
|
||||
}
|
||||
|
||||
// 通知表
|
||||
const hasNotifications = await tableExists(conn, 'notifications')
|
||||
if (!hasNotifications) {
|
||||
console.log('[DB] Migrating: creating notifications table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT DEFAULT NULL COMMENT 'NULL=系统公告,非NULL=个人通知',
|
||||
group_id INT DEFAULT NULL COMMENT '群组通知关联',
|
||||
type ENUM('system', 'group', 'personal') NOT NULL,
|
||||
title VARCHAR(100) NOT NULL,
|
||||
content TEXT,
|
||||
is_read TINYINT(1) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_read (user_id, is_read),
|
||||
INDEX idx_type (type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
|
||||
// 保存的筛选方案表
|
||||
const hasSavedFilters = await tableExists(conn, 'saved_filters')
|
||||
if (!hasSavedFilters) {
|
||||
console.log('[DB] Migrating: creating saved_filters table')
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS saved_filters (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
filters JSON NOT NULL COMMENT '筛选条件',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function initDatabase() {
|
||||
|
||||
@@ -4,6 +4,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
session_key VARCHAR(100),
|
||||
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
|
||||
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
|
||||
role ENUM('user', 'admin') DEFAULT 'user' COMMENT '角色',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -72,3 +73,25 @@ CREATE TABLE IF NOT EXISTS group_members (
|
||||
FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT DEFAULT NULL COMMENT 'NULL=系统公告,非NULL=个人通知',
|
||||
group_id INT DEFAULT NULL COMMENT '群组通知关联',
|
||||
type ENUM('system', 'group', 'personal') NOT NULL,
|
||||
title VARCHAR(100) NOT NULL,
|
||||
content TEXT,
|
||||
is_read TINYINT(1) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_read (user_id, is_read),
|
||||
INDEX idx_type (type)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saved_filters (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
filters JSON NOT NULL COMMENT '筛选条件:{type, categories[], startDate, endDate, minAmount, maxAmount, keyword}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
@@ -15,6 +15,9 @@ import categoryRoutes from './routes/category'
|
||||
import backupRoutes from './routes/backup'
|
||||
import userRoutes from './routes/user'
|
||||
import groupRoutes from './routes/group'
|
||||
import notificationRoutes from './routes/notification'
|
||||
import filterRoutes from './routes/filter'
|
||||
import adminRoutes from './routes/admin'
|
||||
import { backupDatabase } from './utils/backup'
|
||||
|
||||
// Warn if token secret is using default fallback
|
||||
@@ -107,6 +110,9 @@ app.use('/api/categories', apiLimiter, categoryRoutes)
|
||||
app.use('/api/backup', apiLimiter, backupRoutes)
|
||||
app.use('/api/user', apiLimiter, userRoutes)
|
||||
app.use('/api/groups', apiLimiter, groupRoutes)
|
||||
app.use('/api/notifications', apiLimiter, notificationRoutes)
|
||||
app.use('/api/filters', apiLimiter, filterRoutes)
|
||||
app.use('/api/admin', apiLimiter, adminRoutes)
|
||||
|
||||
app.get('/api/health', async (_req, res) => {
|
||||
try {
|
||||
|
||||
158
server/src/routes/admin.ts
Normal file
158
server/src/routes/admin.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import pool from '../db/connection'
|
||||
import { AuthRequest } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** 管理员权限检查中间件 */
|
||||
async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||
const user = (rows as any[])[0]
|
||||
if (!user || user.role !== 'admin') {
|
||||
return res.status(403).json({ code: 40300, message: '需要管理员权限,请先登录获取管理员身份' })
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
// 所有路由都需要管理员权限
|
||||
router.use((req, res, next) => requireAdmin(req, res, next))
|
||||
|
||||
/** 数据看板 */
|
||||
router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users')
|
||||
const [txThisMonth] = await pool.query(
|
||||
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
|
||||
FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
|
||||
)
|
||||
const [txLastMonth] = await pool.query(
|
||||
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
|
||||
FROM transactions
|
||||
WHERE date >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
|
||||
AND date < DATE_FORMAT(NOW(), '%Y-%m-01')`
|
||||
)
|
||||
const [activeToday] = await pool.query(
|
||||
`SELECT COUNT(DISTINCT user_id) as count FROM transactions WHERE date = CURDATE()`
|
||||
)
|
||||
const [activeThisMonth] = await pool.query(
|
||||
`SELECT COUNT(DISTINCT user_id) as count FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
|
||||
)
|
||||
|
||||
const thisMonth = (txThisMonth as any[])[0]
|
||||
const lastMonth = (txLastMonth as any[])[0]
|
||||
let growth = 0
|
||||
if (lastMonth.total > 0) {
|
||||
growth = Math.round(((thisMonth.total - lastMonth.total) / lastMonth.total) * 100)
|
||||
} else if (thisMonth.total > 0) {
|
||||
growth = 100
|
||||
}
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
data: {
|
||||
totalUsers: (userCount as any[])[0].count,
|
||||
monthlyTxCount: thisMonth.count,
|
||||
monthlyTxAmount: thisMonth.total,
|
||||
dailyActive: (activeToday as any[])[0].count,
|
||||
monthlyActive: (activeThisMonth as any[])[0].count,
|
||||
growthPercent: growth,
|
||||
lastMonthTxCount: lastMonth.count,
|
||||
lastMonthTxAmount: lastMonth.total
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[Admin] dashboard error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 用户列表 */
|
||||
router.get('/users', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { page = '1', pageSize = '20', keyword } = req.query
|
||||
const pNum = Math.max(1, parseInt(page as string) || 1)
|
||||
const pSize = Math.min(100, Math.max(1, parseInt(pageSize as string) || 20))
|
||||
const offset = (pNum - 1) * pSize
|
||||
|
||||
let where = ''
|
||||
const params: any[] = []
|
||||
|
||||
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
||||
where = 'WHERE u.nickname LIKE ?'
|
||||
params.push(`%${keyword.trim()}%`)
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.nickname, u.avatar_url, u.role, u.created_at,
|
||||
(SELECT COUNT(*) FROM transactions WHERE user_id = u.id) as tx_count,
|
||||
(SELECT COUNT(*) FROM group_members WHERE user_id = u.id) as group_count
|
||||
FROM users u
|
||||
${where}
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pSize, offset]
|
||||
)
|
||||
|
||||
const [countResult] = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM users u ${where}`,
|
||||
params
|
||||
)
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
data: {
|
||||
list: rows,
|
||||
total: (countResult as any)[0].total,
|
||||
page: pNum,
|
||||
pageSize: pSize
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[Admin] users error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 更新用户状态(禁用/启用) */
|
||||
router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { role } = req.body
|
||||
if (!role || !['user', 'admin'].includes(role)) {
|
||||
return res.status(400).json({ code: 40001, message: '角色无效' })
|
||||
}
|
||||
// 不能修改自己的角色
|
||||
if (Number(req.params.id) === req.userId) {
|
||||
return res.status(400).json({ code: 40001, message: '不能修改自己的角色' })
|
||||
}
|
||||
|
||||
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id])
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Admin] update user status error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除用户 */
|
||||
router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// 不能删除自己
|
||||
if (Number(req.params.id) === req.userId) {
|
||||
return res.status(400).json({ code: 40001, message: '不能删除自己' })
|
||||
}
|
||||
|
||||
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [req.params.id])
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '用户不存在' })
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Admin] delete user error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -23,10 +23,14 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
|
||||
[demoOpenid, 'demo', 'demo']
|
||||
)
|
||||
const userId = (result as any).insertId
|
||||
|
||||
// 首个用户自动设为管理员
|
||||
await autoSetAdmin(userId)
|
||||
|
||||
const token = signToken(userId)
|
||||
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
|
||||
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
|
||||
const userInfo = (userRows as any[])[0]
|
||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
|
||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
|
||||
} catch (err: any) {
|
||||
console.error('[Auth] Demo login failed:', err.message)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
@@ -59,15 +63,27 @@ router.post('/login', async (req: Request, res: Response) => {
|
||||
)
|
||||
const userId = (result as any).insertId
|
||||
|
||||
// 首个用户自动设为管理员
|
||||
await autoSetAdmin(userId)
|
||||
|
||||
// HMAC signed token with expiry
|
||||
const token = signToken(userId)
|
||||
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
|
||||
const [userRows] = await pool.query('SELECT nickname, avatar_url, role FROM users WHERE id = ?', [userId])
|
||||
const userInfo = (userRows as any[])[0]
|
||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
|
||||
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '', role: userInfo?.role || 'user' } })
|
||||
} catch (err: any) {
|
||||
console.error('[Auth] Login failed:', err.message)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 首个用户自动设为管理员 */
|
||||
async function autoSetAdmin(userId: number) {
|
||||
const [adminCount] = await pool.query('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin'])
|
||||
if ((adminCount as any[])[0].count === 0) {
|
||||
await pool.query('UPDATE users SET role = ? WHERE id = ?', ['admin', userId])
|
||||
console.log(`[Auth] User ${userId} auto-promoted to admin`)
|
||||
}
|
||||
}
|
||||
|
||||
export default router
|
||||
|
||||
69
server/src/routes/filter.ts
Normal file
69
server/src/routes/filter.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Router, Response } from 'express'
|
||||
import pool from '../db/connection'
|
||||
import { AuthRequest } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** 获取保存的筛选方案 */
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, filters, created_at FROM saved_filters WHERE user_id = ? ORDER BY created_at DESC',
|
||||
[req.userId]
|
||||
)
|
||||
// 解析 JSON 字段
|
||||
const list = (rows as any[]).map(row => ({
|
||||
...row,
|
||||
filters: typeof row.filters === 'string' ? JSON.parse(row.filters) : row.filters
|
||||
}))
|
||||
res.json({ code: 0, data: list })
|
||||
} catch (err) {
|
||||
console.error('[Filter] GET error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 保存筛选方案 */
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { name, filters } = req.body
|
||||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||||
return res.status(400).json({ code: 40001, message: '方案名称不能为空' })
|
||||
}
|
||||
if (name.length > 50) {
|
||||
return res.status(400).json({ code: 40001, message: '方案名称不能超过50字' })
|
||||
}
|
||||
if (!filters || typeof filters !== 'object') {
|
||||
return res.status(400).json({ code: 40001, message: '筛选条件无效' })
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
'INSERT INTO saved_filters (user_id, name, filters) VALUES (?, ?, ?)',
|
||||
[req.userId, name.trim(), JSON.stringify(filters)]
|
||||
)
|
||||
|
||||
res.json({ code: 0, data: { id: (result as any).insertId } })
|
||||
} catch (err) {
|
||||
console.error('[Filter] POST error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 删除筛选方案 */
|
||||
router.delete('/:id', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [result] = await pool.query(
|
||||
'DELETE FROM saved_filters WHERE id = ? AND user_id = ?',
|
||||
[req.params.id, req.userId]
|
||||
)
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '方案不存在' })
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Filter] DELETE error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
129
server/src/routes/notification.ts
Normal file
129
server/src/routes/notification.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Router, Response } from 'express'
|
||||
import pool from '../db/connection'
|
||||
import { AuthRequest } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
/** 获取通知列表 */
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { type, page = '1', pageSize = '20' } = req.query
|
||||
const pNum = Math.max(1, parseInt(page as string) || 1)
|
||||
const pSize = Math.min(50, Math.max(1, parseInt(pageSize as string) || 20))
|
||||
const offset = (pNum - 1) * pSize
|
||||
|
||||
let where = 'WHERE (n.user_id = ? OR (n.type = \'system\' AND n.user_id IS NULL) OR (n.type = \'group\' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))'
|
||||
const params: any[] = [req.userId, req.userId]
|
||||
|
||||
if (type && ['system', 'group', 'personal'].includes(type as string)) {
|
||||
where += ' AND n.type = ?'
|
||||
params.push(type)
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at, n.group_id
|
||||
FROM notifications n
|
||||
${where}
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pSize, offset]
|
||||
)
|
||||
|
||||
const [countResult] = await pool.query(
|
||||
`SELECT COUNT(*) as total FROM notifications n ${where}`,
|
||||
params
|
||||
)
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
data: {
|
||||
list: rows,
|
||||
total: (countResult as any)[0].total,
|
||||
page: pNum,
|
||||
pageSize: pSize
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[Notification] GET error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 获取未读数量 */
|
||||
router.get('/unread-count', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM notifications
|
||||
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?))) AND is_read = 0`,
|
||||
[req.userId, req.userId]
|
||||
)
|
||||
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
|
||||
} catch (err) {
|
||||
console.error('[Notification] unread-count error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 标记单条已读 */
|
||||
router.put('/:id/read', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [result] = await pool.query(
|
||||
'UPDATE notifications SET is_read = 1 WHERE id = ? AND (user_id = ? OR (type = \'system\' AND user_id IS NULL) OR (type = \'group\' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))',
|
||||
[req.params.id, req.userId, req.userId]
|
||||
)
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(404).json({ code: 40400, message: '通知不存在' })
|
||||
}
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Notification] mark-read error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 全部标记已读 */
|
||||
router.put('/read-all', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
await pool.query(
|
||||
`UPDATE notifications SET is_read = 1
|
||||
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL)) AND is_read = 0`,
|
||||
[req.userId]
|
||||
)
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Notification] read-all error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
/** 发布系统公告(管理员) */
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
|
||||
const user = (userRows as any[])[0]
|
||||
if (!user || user.role !== 'admin') {
|
||||
return res.status(403).json({ code: 40300, message: '仅管理员可发布公告' })
|
||||
}
|
||||
|
||||
const { title, content } = req.body
|
||||
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||
return res.status(400).json({ code: 40001, message: '标题不能为空' })
|
||||
}
|
||||
if (title.length > 100) {
|
||||
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO notifications (type, title, content) VALUES (?, ?, ?)',
|
||||
['system', title.trim(), content || '']
|
||||
)
|
||||
|
||||
res.json({ code: 0 })
|
||||
} catch (err) {
|
||||
console.error('[Notification] POST error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -22,8 +22,8 @@ async function buildWhereClause(
|
||||
)
|
||||
if ((memberCheck as any[]).length === 0) return null
|
||||
|
||||
params.push(groupId)
|
||||
let where = `WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
|
||||
params.push(groupId, groupId)
|
||||
let where = `WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
|
||||
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
|
||||
return { where, params }
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { page, pageSize, type, startDate, endDate, sortBy, group_id } = req.query
|
||||
const { page, pageSize, type, startDate, endDate, sortBy, group_id, category_id, minAmount, maxAmount, keyword } = req.query
|
||||
const pSize = safeInt(pageSize, 20, 1, 100)
|
||||
const pNum = safeInt(page, 1, 1, 99999)
|
||||
const offset = (pNum - 1) * pSize
|
||||
@@ -62,8 +62,8 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
if ((memberCheck as any[]).length === 0) {
|
||||
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||
}
|
||||
where = 'WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
|
||||
params.push(group_id)
|
||||
where = 'WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
|
||||
params.push(group_id, group_id)
|
||||
} else {
|
||||
// 个人视图:我的所有记录(不管 group_id)
|
||||
where = 'WHERE t.user_id = ?'
|
||||
@@ -79,6 +79,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
if (endDate && DATE_REGEX.test(endDate as string)) {
|
||||
where += ' AND t.date <= ?'; params.push(endDate)
|
||||
}
|
||||
if (category_id) {
|
||||
where += ' AND t.category_id = ?'; params.push(category_id)
|
||||
}
|
||||
if (minAmount) {
|
||||
const min = parseInt(minAmount as string)
|
||||
if (!isNaN(min) && min > 0) {
|
||||
where += ' AND t.amount >= ?'; params.push(min)
|
||||
}
|
||||
}
|
||||
if (maxAmount) {
|
||||
const max = parseInt(maxAmount as string)
|
||||
if (!isNaN(max) && max > 0) {
|
||||
where += ' AND t.amount <= ?'; params.push(max)
|
||||
}
|
||||
}
|
||||
if (keyword && typeof keyword === 'string' && keyword.trim()) {
|
||||
where += ' AND t.note LIKE ?'; params.push(`%${keyword.trim()}%`)
|
||||
}
|
||||
|
||||
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
|
||||
|
||||
@@ -100,13 +118,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
params
|
||||
)
|
||||
|
||||
// 筛选后的合计金额
|
||||
const [sumResult] = await pool.query(
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as totalExpense,
|
||||
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as totalIncome
|
||||
FROM transactions t ${where}`,
|
||||
params
|
||||
)
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
data: {
|
||||
list: rows,
|
||||
total: (countResult as any)[0].total,
|
||||
page: pNum,
|
||||
pageSize: pSize
|
||||
pageSize: pSize,
|
||||
filteredExpense: (sumResult as any)[0].totalExpense,
|
||||
filteredIncome: (sumResult as any)[0].totalIncome
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
@@ -48,7 +48,7 @@ const upload = multer({
|
||||
router.get('/me', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, nickname, avatar_url, created_at FROM users WHERE id = ?',
|
||||
'SELECT id, nickname, avatar_url, role, created_at FROM users WHERE id = ?',
|
||||
[req.userId]
|
||||
)
|
||||
const user = (rows as any[])[0]
|
||||
|
||||
Reference in New Issue
Block a user