From 36baa9a2b5becfcd5643e22ef9d5324ae52621e5 Mon Sep 17 00:00:00 2001 From: wangxiaogang <1433729587@qq.com> Date: Mon, 1 Jun 2026 17:01:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v1.5=20=E5=AE=89=E5=85=A8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=E4=B8=8E=E4=BD=93=E9=AA=8C=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端添加 express-rate-limit 限流(登录 10次/分钟,API 200次/分钟) - H5 演示登录:自动获取 demo token,401 时自动重试 - 分类删除前查询关联记录数,提示用户影响范围 - 分类添加支持 8 色选择器,替代随机颜色 - 首页 overview card 改为日均+收入+笔数,避免重复显示支出 - 请求模块 401 处理优化:H5 自动刷新 token 并重试原请求 --- client/src/App.vue | 19 +++++ client/src/pages/category-manage/index.vue | 90 +++++++++++++++++----- client/src/pages/index/index.vue | 11 ++- client/src/utils/request.ts | 34 +++++--- server/package-lock.json | 28 +++++++ server/package.json | 1 + server/src/index.ts | 30 ++++++-- server/src/routes/auth.ts | 17 ++++ server/src/routes/category.ts | 14 ++++ server/yarn.lock | 14 +++- 10 files changed, 219 insertions(+), 39 deletions(-) diff --git a/client/src/App.vue b/client/src/App.vue index 5882e89..b512a29 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -24,6 +24,25 @@ onLaunch(() => { } }) // #endif + + // #ifdef H5 + const token = uni.getStorageSync('xc:token') + if (!token) { + // 同步等待 token,避免页面首次请求因无 token 而 401 + request<{ token: string; userId: number }>({ + url: '/auth/demo-login', + method: 'POST' + }).then((data) => { + uni.setStorageSync('xc:token', data.token) + uni.setStorageSync('xc:userId', data.userId) + console.log('[Auth] H5 demo login success') + }).catch((e) => { + console.error('[Auth] H5 demo login failed:', e) + }) + } else { + console.log('[Auth] H5 token exists') + } + // #endif }) diff --git a/client/src/pages/category-manage/index.vue b/client/src/pages/category-manage/index.vue index de166ec..db8d2f4 100644 --- a/client/src/pages/category-manage/index.vue +++ b/client/src/pages/category-manage/index.vue @@ -33,8 +33,17 @@ 添加自定义分类 - 添加 + + + + 添加 @@ -49,6 +58,8 @@ import { statusBarHeight } from '@/utils/system' const catStore = useCategoryStore() const tabType = ref<'expense' | 'income'>('expense') const newName = ref('') +const colorOptions = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4'] +const selectedColor = ref(colorOptions[0]) const currentCategories = computed(() => catStore.getByType(tabType.value)) @@ -58,14 +69,11 @@ async function onAdd() { const name = newName.value.trim() if (!name) return - const colors = ['#FF8C69', '#7BC67E', '#5B9BD5', '#FFD700', '#FF69B4', '#8B5CF6', '#F97316', '#06B6D4'] - const color = colors[Math.floor(Math.random() * colors.length)] - try { await catStore.addCategory({ name, icon: name[0], - color, + color: selectedColor.value, type: tabType.value }) newName.value = '' @@ -76,20 +84,44 @@ async function onAdd() { } async function onDelete(cat: any) { - uni.showModal({ - title: '删除分类', - content: `确定删除「${cat.name}」?该分类下的记录将变为"未分类"。`, - success: async (res) => { - if (res.confirm) { - try { - await catStore.deleteCategory(cat.id) - uni.showToast({ title: '已删除', icon: 'success' }) - } catch { - uni.showToast({ title: '删除失败', icon: 'none' }) + try { + // 查询关联记录数 + const { request: req } = await import('@/utils/request') + const { count } = await req<{ count: number }>({ url: `/categories/${cat.id}/transaction-count` }) + const tip = count > 0 + ? `该分类下有 ${count} 条记录,删除后会变为"未分类"。` + : '删除后不可恢复。' + uni.showModal({ + title: '删除分类', + content: `确定删除「${cat.name}」?${tip}`, + success: async (res) => { + if (res.confirm) { + try { + await catStore.deleteCategory(cat.id) + uni.showToast({ title: '已删除', icon: 'success' }) + } catch { + uni.showToast({ title: '删除失败', icon: 'none' }) + } } } - } - }) + }) + } catch { + // 查询失败时仍允许删除 + uni.showModal({ + title: '删除分类', + content: `确定删除「${cat.name}」?`, + success: async (res) => { + if (res.confirm) { + try { + await catStore.deleteCategory(cat.id) + uni.showToast({ title: '已删除', icon: 'success' }) + } catch { + uni.showToast({ title: '删除失败', icon: 'none' }) + } + } + } + }) + } } @@ -198,6 +230,7 @@ async function onDelete(cat: any) { .add-form { display: flex; gap: 16rpx; + margin-bottom: 24rpx; } .add-input { @@ -209,8 +242,29 @@ async function onDelete(cat: any) { font-size: 28rpx; } +.color-picker { + display: flex; + gap: 20rpx; + margin-bottom: 24rpx; + flex-wrap: wrap; +} + +.color-dot { + width: 56rpx; + height: 56rpx; + border-radius: 50%; + border: 4rpx solid transparent; + transition: all 0.2s; + + &.selected { + border-color: #2D1B1B; + transform: scale(1.15); + } + &:active { opacity: 0.7; } +} + .add-btn { - width: 160rpx; + width: 100%; height: 88rpx; background: linear-gradient(135deg, #FF8C69, #E67355); border-radius: 20rpx; diff --git a/client/src/pages/index/index.vue b/client/src/pages/index/index.vue index aa4730e..2b17426 100644 --- a/client/src/pages/index/index.vue +++ b/client/src/pages/index/index.vue @@ -23,15 +23,20 @@ + + 日均 + {{ formatAmount(overview.daily) }} + + 收入 +{{ formatAmount(overview.income) }} - 支出 - -{{ formatAmount(overview.expense) }} - + 笔数 + {{ overview.count }}笔 + diff --git a/client/src/utils/request.ts b/client/src/utils/request.ts index bab91ff..3406882 100644 --- a/client/src/utils/request.ts +++ b/client/src/utils/request.ts @@ -33,19 +33,29 @@ export function request(options: RequestOptions): Promise { uni.removeStorageSync('xc:userId') if (!isRedirectingToLogin) { isRedirectingToLogin = true - uni.showToast({ title: '请重新登录', icon: 'none' }) - setTimeout(() => { - // #ifdef H5 - window.location.reload() - // #endif - // #ifdef MP-WEIXIN - // 重新触发 wx.login - reLogin() - // #endif - isRedirectingToLogin = false - }, 1500) + // H5 环境自动获取 demo token 并重试 + if (typeof window !== 'undefined') { + request<{ token: string; userId: number }>({ + url: '/auth/demo-login', + method: 'POST' + }).then((data) => { + uni.setStorageSync('xc:token', data.token) + uni.setStorageSync('xc:userId', data.userId) + // 重试原请求 + request(options).then(resolve).catch(reject) + }).catch(() => { + uni.showToast({ title: '登录失败,请刷新页面', icon: 'none' }) + reject({ code: 40100, message: '未登录' }) + }).finally(() => { isRedirectingToLogin = false }) + } else { + // 小程序环境重新触发 wx.login + uni.showToast({ title: '请重新登录', icon: 'none' }) + setTimeout(() => { reLogin(); isRedirectingToLogin = false }, 1500) + reject({ code: 40100, message: '未登录' }) + } + } else { + reject({ code: 40100, message: '未登录' }) } - reject({ code: 40100, message: '未登录' }) return } diff --git a/server/package-lock.json b/server/package-lock.json index 31e7105..eb9c075 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -11,6 +11,7 @@ "cors": "^2.8.5", "dotenv": "^17.4.2", "express": "^4.18.2", + "express-rate-limit": "^8.5.2", "mysql2": "^3.9.0" }, "devDependencies": { @@ -1042,6 +1043,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1298,6 +1317,15 @@ "node": ">= 0.10" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", diff --git a/server/package.json b/server/package.json index de1595d..3afae0e 100644 --- a/server/package.json +++ b/server/package.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "dotenv": "^17.4.2", "express": "^4.18.2", + "express-rate-limit": "^8.5.2", "mysql2": "^3.9.0" }, "devDependencies": { diff --git a/server/src/index.ts b/server/src/index.ts index c6868f1..79cd52d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,6 +1,7 @@ import 'dotenv/config' import express from 'express' import cors from 'cors' +import rateLimit from 'express-rate-limit' import { authMiddleware } from './middleware/auth' import { initDatabase } from './db/init' import pool from './db/connection' @@ -32,13 +33,32 @@ app.use(cors({ credentials: true, })) app.use(express.json({ limit: '10kb' })) + +// 限流:登录接口 — 每分钟最多 10 次 +const authLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 10, + message: { code: 42900, message: '请求过于频繁,请稍后再试' }, + standardHeaders: true, + legacyHeaders: false, +}) + +// 限流:通用 API — 每分钟最多 200 次 +const apiLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 200, + message: { code: 42900, message: '请求过于频繁,请稍后再试' }, + standardHeaders: true, + legacyHeaders: false, +}) + app.use(authMiddleware) -app.use('/api/auth', authRoutes) -app.use('/api/transactions', transactionRoutes) -app.use('/api/stats', statsRoutes) -app.use('/api/budget', budgetRoutes) -app.use('/api/categories', categoryRoutes) +app.use('/api/auth', authLimiter, authRoutes) +app.use('/api/transactions', apiLimiter, transactionRoutes) +app.use('/api/stats', apiLimiter, statsRoutes) +app.use('/api/budget', apiLimiter, budgetRoutes) +app.use('/api/categories', apiLimiter, categoryRoutes) app.get('/api/health', async (_req, res) => { try { diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 83a6ac0..8d56ab9 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -14,6 +14,23 @@ export function signToken(userId: number): string { return Buffer.from(`${payload}:${signature}`).toString('base64') } +// H5 演示登录 — 创建/复用演示用户 +router.post('/demo-login', async (_req: Request, res: Response) => { + try { + const demoOpenid = 'h5_demo_user' + const [result] = await pool.query( + 'INSERT INTO users (openid, session_key) VALUES (?, ?) ON DUPLICATE KEY UPDATE session_key = ?, id = LAST_INSERT_ID(id)', + [demoOpenid, 'demo', 'demo'] + ) + const userId = (result as any).insertId + const token = signToken(userId) + res.json({ code: 0, data: { token, userId } }) + } catch (err: any) { + console.error('[Auth] Demo login failed:', err.message) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + router.post('/login', async (req: Request, res: Response) => { try { const { code } = req.body diff --git a/server/src/routes/category.ts b/server/src/routes/category.ts index 87221cc..806ce15 100644 --- a/server/src/routes/category.ts +++ b/server/src/routes/category.ts @@ -38,6 +38,20 @@ router.post('/', async (req: AuthRequest, res: Response) => { } }) +// 查询分类关联的记录数 +router.get('/:id/transaction-count', async (req: AuthRequest, res: Response) => { + try { + const [rows] = await pool.query( + 'SELECT COUNT(*) as count FROM transactions WHERE category_id = ? AND user_id = ?', + [req.params.id, req.userId] + ) + res.json({ code: 0, data: { count: (rows as any[])[0].count } }) + } catch (err) { + console.error('[Category] count error:', err) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + router.delete('/:id', async (req: AuthRequest, res: Response) => { try { const [result] = await pool.query( diff --git a/server/yarn.lock b/server/yarn.lock index e43b0aa..c315397 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -351,7 +351,14 @@ execa@^1.0.0: signal-exit "^3.0.0" strip-eof "^1.0.0" -express@^4.18.2: +express-rate-limit@^8.5.2: + version "8.5.2" + resolved "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz" + integrity sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A== + dependencies: + ip-address "^10.2.0" + +express@^4.18.2, "express@>= 4.11": version "4.22.2" resolved "https://registry.npmjs.org/express/-/express-4.22.2.tgz" integrity sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q== @@ -538,6 +545,11 @@ interpret@^1.0.0: resolved "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz" integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== +ip-address@^10.2.0: + version "10.2.0" + resolved "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz" + integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== + ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz"