fix: 全面修复 22 项问题

严重 Bug:
- NaN 金额校验绕过(add 页面 parseFloat 空字符串)
- 4 个页面补充 waitForReady(add/budget/category-manage/group-manage)
- 导出 Loading 遮罩不消失(profile exportData 缺 hideLoading)
- Store 吞掉请求错误不 re-throw(transaction/stats/budget/user)
- 分类排序 SQL 拼接改参数化查询(category PUT /sort)
- 备份接口添加管理员权限检查(backup requireAdmin)

中等问题:
- 群组加入竞态条件改为 INSERT IGNORE
- 统计页切月/切Tab添加 loadSeq 防竞态
- stats store 补全 loading 状态 + Overview 初始值补全 7 字段
- 保存成功后返回不再弹「放弃修改」确认框
- 群组管理创建/加入添加 saving 双重提交保护
- 预算页添加 onShow 刷新
- 服务端输入校验(name/color/invite_code/微信code)

UI/样式:
- 触摸目标: bell/nav-back/nav-save→88rpx, avatar-badge→64rpx, action-btn→80rpx
- :active 反馈: bell/profile-card/identity-card/id-manage/arrow/action-btn/group-btn/cat-info
- 内联样式改动态绑定(4处 spacer 使用 capsuleRight)
- FAB 文字+改用 Icon 组件
- prefers-reduced-motion 动画降级(Skeleton/profile/budget/add)
- 胶囊适配(add/category-manage/group-manage)
- CategoryIcon !important 改为 iconStyle 计算属性

文档:
- CLAUDE.md 补充 SaveSuccess/ChartWrapper 组件 + category/budget/index API
This commit is contained in:
2026-06-04 17:06:02 +08:00
parent 21f4d81959
commit e110cd5c5d
25 changed files with 314 additions and 88 deletions

View File

@@ -36,8 +36,8 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
router.post('/login', async (req: Request, res: Response) => {
try {
const { code } = req.body
if (!code) {
return res.status(400).json({ code: 40001, message: '缺少code参数' })
if (!code || typeof code !== 'string' || code.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(code)) {
return res.status(400).json({ code: 40001, message: 'code参数无效' })
}
// 调用微信 code2session 接口

View File

@@ -4,8 +4,23 @@ import { backupDatabase, getBackupList } from '../utils/backup'
const router = Router()
// 管理员用户 ID 列表(逗号分隔),未配置时默认仅 ID=1
const ADMIN_IDS = process.env.ADMIN_USER_IDS
? process.env.ADMIN_USER_IDS.split(',').map(Number)
: [1]
/** 检查是否为管理员,非管理员返回 403 */
function requireAdmin(req: AuthRequest, res: Response): boolean {
if (!req.userId || !ADMIN_IDS.includes(req.userId)) {
res.status(403).json({ code: 40300, message: '无权限' })
return false
}
return true
}
// 手动触发备份
router.post('/', async (_req: AuthRequest, res: Response) => {
router.post('/', async (req: AuthRequest, res: Response) => {
if (!requireAdmin(req, res)) return
try {
const filepath = await backupDatabase()
res.json({ code: 0, data: { filepath } })
@@ -16,7 +31,8 @@ router.post('/', async (_req: AuthRequest, res: Response) => {
})
// 获取备份列表
router.get('/', async (_req: AuthRequest, res: Response) => {
router.get('/', async (req: AuthRequest, res: Response) => {
if (!requireAdmin(req, res)) return
try {
const list = getBackupList()
res.json({ code: 0, data: list })

View File

@@ -24,6 +24,12 @@ router.post('/', async (req: AuthRequest, res: Response) => {
if (!name || !icon || !color || !type) {
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
}
if (typeof name !== 'string' || name.length > 50) {
return res.status(400).json({ code: 40001, message: '分类名称不能超过50字' })
}
if (typeof color !== 'string' || color.length > 20) {
return res.status(400).json({ code: 40001, message: '颜色值无效' })
}
if (!['expense', 'income'].includes(type)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
@@ -45,6 +51,12 @@ router.put('/:id', async (req: AuthRequest, res: Response) => {
if (!name || !color) {
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
}
if (typeof name !== 'string' || name.length > 50) {
return res.status(400).json({ code: 40001, message: '分类名称不能超过50字' })
}
if (typeof color !== 'string' || color.length > 20) {
return res.status(400).json({ code: 40001, message: '颜色值无效' })
}
const [result] = await pool.query(
'UPDATE categories SET name = ?, color = ? WHERE id = ? AND is_custom = 1 AND user_id = ?',
[name, color, req.params.id, req.userId]
@@ -121,16 +133,21 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
router.put('/sort', async (req: AuthRequest, res: Response) => {
try {
const { ids } = req.body // [id1, id2, id3, ...]
if (!Array.isArray(ids) || ids.length === 0) {
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 校验每个元素必须是正整数
if (!ids.every((id: any) => Number.isInteger(id) && id > 0)) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 构建 CASE WHEN 语句批量更新
const cases = ids.map((id: number, index: number) => `WHEN ${Number(id)} THEN ${index}`).join(' ')
const idList = ids.map((id: number) => Number(id)).join(',')
// 使用参数化查询构建 CASE WHEN 语句
const whenClauses = ids.map(() => `WHEN ? THEN ?`).join(' ')
const whenParams: number[] = ids.flatMap((id: number, index: number) => [id, index])
const placeholders = ids.map(() => '?').join(',')
await pool.query(
`UPDATE categories SET sort_order = CASE id ${cases} END WHERE id IN (${idList}) AND (user_id = 0 OR user_id = ?)`,
[req.userId]
`UPDATE categories SET sort_order = CASE id ${whenClauses} END WHERE id IN (${placeholders}) AND (user_id = 0 OR user_id = ?)`,
[...whenParams, ...ids, req.userId]
)
res.json({ code: 0 })
} catch (err) {

View File

@@ -129,8 +129,8 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
router.post('/join', async (req: AuthRequest, res: Response) => {
try {
const { invite_code } = req.body
if (!invite_code || typeof invite_code !== 'string') {
return res.status(400).json({ code: 40001, message: '邀请码不能为空' })
if (!invite_code || typeof invite_code !== 'string' || invite_code.length > 10) {
return res.status(400).json({ code: 40001, message: '邀请码无效' })
}
const [groupRows] = await pool.query(
@@ -142,19 +142,14 @@ router.post('/join', async (req: AuthRequest, res: Response) => {
return res.status(404).json({ code: 40400, message: '邀请码无效' })
}
// 检查是否已是成员
const [existing] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[group.id, req.userId]
)
if ((existing as any[]).length > 0) {
return res.status(400).json({ code: 40002, message: '你已经是该群组成员' })
}
await pool.query(
'INSERT INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
// 使用 INSERT IGNORE 避免并发加入时的竞态条件
const [result] = await pool.query(
'INSERT IGNORE INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
[group.id, req.userId, 'member']
)
if ((result as any).affectedRows === 0) {
return res.status(400).json({ code: 40002, message: '你已经是该群组成员' })
}
res.json({ code: 0, data: { group_id: group.id, name: group.name } })
} catch (err) {

View File

@@ -18,7 +18,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
const [rows] = await pool.query(
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
c.name as category_name, c.icon as category_icon, c.color as category_color,
u.nickname as creator_nickname
u.nickname as creator_nickname, u.avatar_url as creator_avatar
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN users u ON t.user_id = u.id
@@ -85,7 +85,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
const [rows] = await pool.query(
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
c.name as category_name, c.icon as category_icon, c.color as category_color,
u.nickname as creator_nickname
u.nickname as creator_nickname, u.avatar_url as creator_avatar
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN users u ON t.user_id = u.id