feat(t03): improve performance and UX
- add category/tag caching and optimistic updates - add stats dashboard aggregation and batched tracking - add undo delete snackbar and group read-only view mode - improve app-ready timeout handling and refresh guards
This commit is contained in:
@@ -83,10 +83,13 @@ router.get('/users', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
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
|
||||
COUNT(DISTINCT t.id) as tx_count,
|
||||
COUNT(DISTINCT gm.group_id) as group_count
|
||||
FROM users u
|
||||
LEFT JOIN transactions t ON t.user_id = u.id
|
||||
LEFT JOIN group_members gm ON gm.user_id = u.id
|
||||
${where}
|
||||
GROUP BY u.id, u.nickname, u.avatar_url, u.role, u.created_at
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, pSize, offset]
|
||||
|
||||
@@ -63,12 +63,23 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { amount, month } = req.body
|
||||
const { amount, month, group_id } = req.body
|
||||
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
|
||||
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数(单位:分)' })
|
||||
}
|
||||
const m = validateMonth(month || getCurrentMonth())
|
||||
|
||||
// 群组模式下验证成员身份
|
||||
if (group_id && group_id !== 'null') {
|
||||
const [memberCheck] = await pool.query(
|
||||
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[group_id, req.userId]
|
||||
)
|
||||
if ((memberCheck as any[]).length === 0) {
|
||||
return res.status(403).json({ code: 40300, message: '无权操作此群组' })
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO budgets (user_id, amount, month) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE amount = ?',
|
||||
[req.userId, amount, m, amount]
|
||||
|
||||
@@ -335,4 +335,130 @@ router.get('/suggest-category', async (req: AuthRequest, res: Response) => {
|
||||
}
|
||||
})
|
||||
|
||||
/** 聚合统计接口:一次请求返回 overview + category + trend */
|
||||
router.get('/dashboard', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { month, type = 'expense', period = 'month', group_id } = req.query
|
||||
|
||||
if (!VALID_TYPES.includes(type as string)) {
|
||||
return res.status(400).json({ code: 40001, message: '类型无效' })
|
||||
}
|
||||
if (!VALID_PERIODS.includes(period as string)) {
|
||||
return res.status(400).json({ code: 40001, message: 'period参数无效' })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const m = (month as string) || getCurrentMonth()
|
||||
|
||||
let startDate: string
|
||||
let endDate: string
|
||||
let groupBy: string
|
||||
let labelExpr: string
|
||||
let elapsedDays = 1
|
||||
|
||||
if (period === 'week') {
|
||||
const dayOfWeek = now.getDay() || 7
|
||||
const monday = new Date(now)
|
||||
monday.setDate(now.getDate() - dayOfWeek + 1)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
startDate = monday.toISOString().slice(0, 10)
|
||||
endDate = sunday.toISOString().slice(0, 10)
|
||||
elapsedDays = 7
|
||||
groupBy = 't.date'
|
||||
labelExpr = `CASE DAYOFWEEK(t.date)
|
||||
WHEN 1 THEN '周日'
|
||||
WHEN 2 THEN '周一'
|
||||
WHEN 3 THEN '周二'
|
||||
WHEN 4 THEN '周三'
|
||||
WHEN 5 THEN '周四'
|
||||
WHEN 6 THEN '周五'
|
||||
WHEN 7 THEN '周六'
|
||||
END`
|
||||
} else if (period === 'year') {
|
||||
const year = m.split('-')[0] || String(now.getFullYear())
|
||||
startDate = `${year}-01-01`
|
||||
endDate = `${year}-12-31`
|
||||
elapsedDays = year === String(now.getFullYear()) ? Math.max(1, Math.ceil((now.getTime() - new Date(startDate).getTime()) / 86400000) + 1) : 365
|
||||
groupBy = "DATE_FORMAT(t.date, '%Y-%m')"
|
||||
labelExpr = `CONCAT(MONTH(t.date), '月')`
|
||||
} else {
|
||||
// month(默认)
|
||||
const range = getMonthRange(m)
|
||||
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
|
||||
startDate = range.startDate
|
||||
endDate = range.endDate
|
||||
const [year, mon] = m.split('-').map(Number)
|
||||
const isCurrentMonth = year === now.getFullYear() && mon === now.getMonth() + 1
|
||||
elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
|
||||
groupBy = 't.date'
|
||||
labelExpr = `CONCAT(DAY(t.date), '日')`
|
||||
}
|
||||
|
||||
// 验证 group_id 一次即可
|
||||
const whereBase = await buildWhereClause(req.userId, group_id as string | undefined, ['t.date >= ?', 't.date <= ?'])
|
||||
if (!whereBase) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||
|
||||
const whereTyped = await buildWhereClause(req.userId, group_id as string | undefined, ['t.type = ?', 't.date >= ?', 't.date <= ?'])
|
||||
if (!whereTyped) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||||
|
||||
// 三个查询并行执行
|
||||
const [overviewRes, categoryRes, trendRes] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT
|
||||
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as expense,
|
||||
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as income,
|
||||
COUNT(*) as count,
|
||||
SUM(CASE WHEN t.type = 'expense' THEN 1 ELSE 0 END) as expenseCount,
|
||||
SUM(CASE WHEN t.type = 'income' THEN 1 ELSE 0 END) as incomeCount
|
||||
FROM transactions t ${whereBase.where}`,
|
||||
[...whereBase.params, startDate, endDate]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT c.id, COALESCE(c.name, '未分类') as name, COALESCE(c.icon, '?') as icon, COALESCE(c.color, '#BFB3B3') as color, SUM(t.amount) as amount, COUNT(t.id) as count
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
${whereTyped.where}
|
||||
GROUP BY c.id, c.name, c.icon, c.color
|
||||
ORDER BY amount DESC
|
||||
LIMIT 10`,
|
||||
[...whereTyped.params, type, startDate, endDate]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, ${labelExpr} as label, SUM(t.amount) as amount
|
||||
FROM transactions t
|
||||
${whereTyped.where}
|
||||
GROUP BY ${groupBy}
|
||||
ORDER BY t.date`,
|
||||
[...whereTyped.params, type, startDate, endDate]
|
||||
)
|
||||
])
|
||||
|
||||
const overviewRow = (overviewRes[0] as any[])[0]
|
||||
const daily = elapsedDays > 0 ? Math.round(overviewRow.expense / elapsedDays) : 0
|
||||
const dailyIncome = elapsedDays > 0 ? Math.round(overviewRow.income / elapsedDays) : 0
|
||||
|
||||
res.json({
|
||||
code: 0,
|
||||
data: {
|
||||
overview: {
|
||||
expense: overviewRow.expense,
|
||||
income: overviewRow.income,
|
||||
count: overviewRow.count,
|
||||
daily,
|
||||
dailyIncome,
|
||||
expenseCount: overviewRow.expenseCount,
|
||||
incomeCount: overviewRow.incomeCount
|
||||
},
|
||||
category: categoryRes[0],
|
||||
trend: trendRes[0],
|
||||
month: m
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[Stats] dashboard error:', err)
|
||||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -13,15 +13,21 @@ router.post('/', async (req, res) => {
|
||||
return res.status(400).json({ code: 40001, message: '无效的埋点数据' })
|
||||
}
|
||||
|
||||
// 批量插入埋点事件(限制每个事件的字段长度)
|
||||
for (const event of events.slice(0, 50)) { // 最多 50 个事件
|
||||
const eventType = typeof event.event === 'string' ? event.event.slice(0, 50) : 'unknown'
|
||||
const page = typeof event.page === 'string' ? event.page.slice(0, 200) : ''
|
||||
const data = event.data && typeof event.data === 'object' ? event.data : {}
|
||||
const validEvents = events.slice(0, 50).map(event => ({
|
||||
event: typeof event.event === 'string' ? event.event.slice(0, 50) : 'unknown',
|
||||
page: typeof event.page === 'string' ? event.page.slice(0, 200) : '',
|
||||
data: event.data && typeof event.data === 'object' ? JSON.stringify(event.data) : '{}',
|
||||
userId
|
||||
}))
|
||||
|
||||
await pool.execute(
|
||||
'INSERT INTO track_events (event, page, data, user_id) VALUES (?, ?, ?, ?)',
|
||||
[eventType, page, JSON.stringify(data), userId]
|
||||
if (validEvents.length > 0) {
|
||||
// 批量 INSERT
|
||||
const placeholders = validEvents.map(() => '(?, ?, ?, ?)').join(', ')
|
||||
const values: any[] = []
|
||||
validEvents.forEach(e => values.push(e.event, e.page, e.data, e.userId))
|
||||
await pool.query(
|
||||
`INSERT INTO track_events (event, page, data, user_id) VALUES ${placeholders}`,
|
||||
values
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user