feat: 全面公告系统 + 全系统 Bug 修复

全面公告系统:
- notifications 表新增置顶/强提醒/定时发布/过期/配图/链接字段
- 后端增强:7 个接口(列表/置顶/未读数/已读/发布/编辑/删除)
- 首页强提醒公告弹窗
- 通知中心:置顶标记、配图展示、链接跳转、管理员操作
- 管理员发布表单:标题+内容+链接+配图+置顶+强提醒+定时+过期

Bug 修复:
- 401 重试队列竞态导致请求永久挂起
- 多分类筛选条件被静默丢弃
- 网络错误导致群组选择丢失
- Numpad 输入 '.' 显示异常
- transaction store 错误时清空数据
- 首页/预算页 Promise.all 部分失败阻塞关键数据
- CSS prefers-reduced-motion 类名不匹配
- 通知 read-all 遗漏群组通知
- auth.ts 错误日志缺少 stack trace
- stats 页面 v-for key 使用 index
- add 页面重复调用 fetchCategories
This commit is contained in:
wangxiaogang
2026-06-06 22:02:28 +08:00
parent 83571de723
commit a35689cdda
20 changed files with 1058 additions and 202 deletions

View File

@@ -134,6 +134,20 @@ async function runMigrations(conn: mysql.Connection) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
// 公告系统增强:扩展 notifications 表
const hasPinned = await columnExists(conn, 'notifications', 'is_pinned')
if (!hasPinned) {
console.log('[DB] Migrating: extending notifications table')
await conn.query('ALTER TABLE notifications ADD COLUMN is_pinned TINYINT(1) DEFAULT 0 AFTER is_read')
await conn.query('ALTER TABLE notifications ADD COLUMN is_urgent TINYINT(1) DEFAULT 0 AFTER is_pinned')
await conn.query('ALTER TABLE notifications ADD COLUMN publish_at TIMESTAMP NULL AFTER is_urgent')
await conn.query('ALTER TABLE notifications ADD COLUMN expire_at TIMESTAMP NULL AFTER publish_at')
await conn.query('ALTER TABLE notifications ADD COLUMN image_url VARCHAR(500) DEFAULT \'\' AFTER expire_at')
await conn.query('ALTER TABLE notifications ADD COLUMN link_url VARCHAR(500) DEFAULT \'\' AFTER image_url')
await conn.query('ALTER TABLE notifications ADD INDEX idx_pinned (is_pinned, created_at)')
await conn.query('ALTER TABLE notifications ADD INDEX idx_expire (expire_at)')
}
}
export async function initDatabase() {

View File

@@ -82,9 +82,17 @@ CREATE TABLE IF NOT EXISTS notifications (
title VARCHAR(100) NOT NULL,
content TEXT,
is_read TINYINT(1) DEFAULT 0,
is_pinned TINYINT(1) DEFAULT 0 COMMENT '是否置顶',
is_urgent TINYINT(1) DEFAULT 0 COMMENT '是否强提醒弹窗',
publish_at TIMESTAMP NULL COMMENT '定时发布时间NULL=立即发布)',
expire_at TIMESTAMP NULL COMMENT '过期时间NULL=永不过期)',
image_url VARCHAR(500) DEFAULT '' COMMENT '公告配图',
link_url VARCHAR(500) DEFAULT '' COMMENT '公告链接',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user_read (user_id, is_read),
INDEX idx_type (type)
INDEX idx_type (type),
INDEX idx_pinned (is_pinned, created_at),
INDEX idx_expire (expire_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS saved_filters (

View File

@@ -32,7 +32,7 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
const userInfo = (userRows as any[])[0]
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)
console.error('[Auth] Demo login failed:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
@@ -72,7 +72,7 @@ router.post('/login', async (req: Request, res: Response) => {
const userInfo = (userRows as any[])[0]
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)
console.error('[Auth] Login failed:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})

View File

@@ -1,9 +1,19 @@
import { Router, Response } from 'express'
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.get('/', async (req: AuthRequest, res: Response) => {
try {
@@ -12,7 +22,10 @@ router.get('/', async (req: AuthRequest, res: Response) => {
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 = ?)))'
// 基础可见条件:个人通知 + 系统公告 + 群组通知
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 = ?)))
AND (n.expire_at IS NULL OR n.expire_at > NOW())
AND (n.publish_at IS NULL OR n.publish_at <= NOW())`
const params: any[] = [req.userId, req.userId]
if (type && ['system', 'group', 'personal'].includes(type as string)) {
@@ -21,10 +34,11 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
const [rows] = await pool.query(
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at, n.group_id
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at, n.group_id,
n.is_pinned, n.is_urgent, n.publish_at, n.expire_at, n.image_url, n.link_url
FROM notifications n
${where}
ORDER BY n.created_at DESC
ORDER BY n.is_pinned DESC, n.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pSize, offset]
)
@@ -49,12 +63,37 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
})
/** 获取置顶/强提醒公告(首页弹窗用) */
router.get('/pinned', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at,
n.is_pinned, n.is_urgent, n.image_url, n.link_url
FROM notifications n
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 = ?)))
AND n.is_urgent = 1 AND n.is_read = 0
AND (n.expire_at IS NULL OR n.expire_at > NOW())
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
ORDER BY n.created_at DESC
LIMIT 5`,
[req.userId, req.userId]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('[Notification] pinned 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`,
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
AND (expire_at IS NULL OR expire_at > NOW())
AND (publish_at IS NULL OR publish_at <= NOW())`,
[req.userId, req.userId]
)
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
@@ -68,7 +107,8 @@ router.get('/unread-count', async (req: AuthRequest, res: Response) => {
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 = ?)))',
`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) {
@@ -86,8 +126,11 @@ 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]
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
AND (expire_at IS NULL OR expire_at > NOW())
AND (publish_at IS NULL OR publish_at <= NOW())`,
[req.userId, req.userId]
)
res.json({ code: 0 })
} catch (err) {
@@ -96,17 +139,11 @@ router.put('/read-all', async (req: AuthRequest, res: Response) => {
}
})
/** 发布系统公告(管理员) */
/** 发布公告(管理员/群主 */
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, type = 'system', group_id, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url } = req.body
const { title, content } = req.body
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return res.status(400).json({ code: 40001, message: '标题不能为空' })
}
@@ -114,16 +151,99 @@ router.post('/', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
}
await pool.query(
'INSERT INTO notifications (type, title, content) VALUES (?, ?, ?)',
['system', title.trim(), content || '']
// 系统公告需要管理员权限
if (type === 'system') {
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: '仅管理员可发布系统公告' })
}
}
// 群组公告需要群主权限
if (type === 'group' && group_id) {
const [groupRows] = await pool.query('SELECT created_by FROM `groups` WHERE id = ?', [group_id])
const group = (groupRows as any[])[0]
if (!group || group.created_by !== req.userId) {
return res.status(403).json({ code: 40300, message: '仅群主可发布群组公告' })
}
}
const [result] = await pool.query(
`INSERT INTO notifications (type, title, content, user_id, group_id, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
type,
title.trim(),
content || '',
type === 'personal' ? req.userId : null,
type === 'group' ? group_id : null,
is_pinned ? 1 : 0,
is_urgent ? 1 : 0,
publish_at || null,
expire_at || null,
image_url || '',
link_url || ''
]
)
res.json({ code: 0 })
res.json({ code: 0, data: { id: (result as any).insertId } })
} catch (err) {
console.error('[Notification] POST error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 编辑公告(管理员) */
router.put('/:id', requireAdmin, async (req: AuthRequest, res: Response) => {
try {
const { title, content, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url } = req.body
const updates: string[] = []
const params: any[] = []
if (title !== undefined) { updates.push('title = ?'); params.push(title.trim()) }
if (content !== undefined) { updates.push('content = ?'); params.push(content) }
if (is_pinned !== undefined) { updates.push('is_pinned = ?'); params.push(is_pinned ? 1 : 0) }
if (is_urgent !== undefined) { updates.push('is_urgent = ?'); params.push(is_urgent ? 1 : 0) }
if (publish_at !== undefined) { updates.push('publish_at = ?'); params.push(publish_at || null) }
if (expire_at !== undefined) { updates.push('expire_at = ?'); params.push(expire_at || null) }
if (image_url !== undefined) { updates.push('image_url = ?'); params.push(image_url) }
if (link_url !== undefined) { updates.push('link_url = ?'); params.push(link_url) }
if (updates.length === 0) {
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
}
params.push(req.params.id)
const [result] = await pool.query(
`UPDATE notifications SET ${updates.join(', ')} WHERE id = ?`,
params
)
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '公告不存在' })
}
res.json({ code: 0 })
} catch (err) {
console.error('[Notification] PUT error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 删除公告(管理员) */
router.delete('/:id', requireAdmin, async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query('DELETE FROM notifications 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('[Notification] DELETE error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -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, category_id, minAmount, maxAmount, keyword } = req.query
const { page, pageSize, type, startDate, endDate, sortBy, group_id, category_id, category_ids, minAmount, maxAmount, keyword } = req.query
const pSize = safeInt(pageSize, 20, 1, 100)
const pNum = safeInt(page, 1, 1, 99999)
const offset = (pNum - 1) * pSize
@@ -79,7 +79,14 @@ 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) {
// 支持单个 category_id 或多个 category_ids
if (category_ids) {
const ids = String(category_ids).split(',').map(Number).filter(n => !isNaN(n))
if (ids.length > 0) {
where += ` AND t.category_id IN (${ids.map(() => '?').join(',')})`
params.push(...ids)
}
} else if (category_id) {
where += ' AND t.category_id = ?'; params.push(category_id)
}
if (minAmount) {