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:
@@ -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() {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user