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"