From 6908ffed0ecde2ac601713523fc7763a2fa79c12 Mon Sep 17 00:00:00 2001 From: wangxiaogang <1433729587@qq.com> Date: Mon, 8 Jun 2026 17:21:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=99=BA=E8=83=BD=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E5=BB=BA=E8=AE=AE=20=E2=80=94=20=E6=A0=B9=E6=8D=AE=E9=87=91?= =?UTF-8?q?=E9=A2=9D/=E5=A4=87=E6=B3=A8/=E5=8E=86=E5=8F=B2=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=8E=A8=E8=8D=90=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/stats.ts | 11 ++++ client/src/pages/add/index.vue | 72 +++++++++++++++++++++++++- server/src/routes/stats.ts | 95 ++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 2 deletions(-) diff --git a/client/src/api/stats.ts b/client/src/api/stats.ts index 1165250..c16fe17 100644 --- a/client/src/api/stats.ts +++ b/client/src/api/stats.ts @@ -41,3 +41,14 @@ export function getCategoryStats(month?: string, type: string = 'expense', group export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) { return request({ url: '/stats/trend', data: { month, type, group_id } }) } + +/** 智能分类建议结果 */ +export interface CategorySuggestion { + category: { id: number; name: string; color: string } | null + confidence: number +} + +/** 根据金额和备注推荐分类 */ +export function suggestCategory(amount: number, type: string, note?: string) { + return request({ url: '/stats/suggest-category', data: { amount, type, note } }) +} diff --git a/client/src/pages/add/index.vue b/client/src/pages/add/index.vue index 146729d..d78693f 100644 --- a/client/src/pages/add/index.vue +++ b/client/src/pages/add/index.vue @@ -27,8 +27,13 @@ - - + + + + + 推荐 + + {{ cat.name }} @@ -65,6 +70,7 @@ import { onShow } from '@dcloudio/uni-app' import { useCategoryStore } from '@/stores/category' import { useTransactionStore } from '@/stores/transaction' import { waitForReady } from '@/utils/app-ready' +import { suggestCategory } from '@/api/stats' import Numpad from '@/components/Numpad/Numpad.vue' import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue' import Icon from '@/components/Icon/Icon.vue' @@ -78,6 +84,9 @@ const type = ref<'expense' | 'income'>('expense') const amountStr = ref('0') const selectedCat = ref(null) const note = ref('') +const suggestedCatId = ref(null) +const suggestConfidence = ref(0) +let suggestTimer: ReturnType | null = null const now = new Date() const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` const selectedDate = ref(localToday) @@ -121,6 +130,35 @@ watch(currentCategories, (cats) => { } }) +// 智能分类推荐:监听金额和备注变化,防抖 600ms 后请求建议 +watch([amountStr, note], () => { + if (suggestTimer) clearTimeout(suggestTimer) + suggestTimer = setTimeout(async () => { + const amt = Math.round(parseFloat(amountStr.value || '0') * 100) + if (amt <= 0 || editId.value) { + suggestedCatId.value = null + suggestConfidence.value = 0 + return + } + try { + const result = await suggestCategory(amt, type.value, note.value) + if (result.category && result.confidence >= 30) { + suggestedCatId.value = result.category.id + suggestConfidence.value = result.confidence + // 置信度 >= 60 时自动选中 + if (result.confidence >= 60 && !editId.value) { + selectedCat.value = result.category.id + } + } else { + suggestedCatId.value = null + suggestConfidence.value = 0 + } + } catch { + suggestedCatId.value = null + } + }, 600) +}) + onMounted(async () => { await waitForReady() @@ -370,8 +408,38 @@ function goBack() { align-items: center; gap: 6rpx; padding: 12rpx 0; + position: relative; &:active { transform: scale(0.95); } + + &.suggested { + .cat-icon-wrap { + border: 3rpx dashed $primary; + border-radius: $radius-lg; + padding: 6rpx; + margin: -6rpx; + } + } +} + +.cat-icon-wrap { + position: relative; + transition: all $transition-normal; +} + +.suggest-badge { + position: absolute; + top: -8rpx; + right: -12rpx; + padding: 2rpx 10rpx; + background: $primary; + border-radius: $radius-xs; +} + +.suggest-badge-text { + font-size: 18rpx; + color: $surface; + font-weight: 600; } .cat-name { font-size: $font-sm; color: $text-sec; } diff --git a/server/src/routes/stats.ts b/server/src/routes/stats.ts index f1f7bf6..b2b4308 100644 --- a/server/src/routes/stats.ts +++ b/server/src/routes/stats.ts @@ -170,4 +170,99 @@ router.get('/trend', async (req: AuthRequest, res: Response) => { } }) +/** 智能分类建议:根据历史记录的金额和备注推荐分类 */ +router.get('/suggest-category', async (req: AuthRequest, res: Response) => { + try { + const userId = req.userId + const { amount, type = 'expense', note = '' } = req.query + + if (!amount || !['expense', 'income'].includes(type as string)) { + return res.status(400).json({ code: 40001, message: '参数错误' }) + } + + const amt = parseInt(amount as string) + const noteStr = (note as string).trim() + + // 1. 关键词匹配:从备注中提取关键词,查找历史记录中最常用的分类 + let suggestedCategory: any = null + let confidence = 0 + + if (noteStr) { + // 提取关键词(取前4个字符以上的词) + const keywords = noteStr.replace(/[,,。.!!??\s]+/g, ' ').split(' ').filter(w => w.length >= 1) + if (keywords.length > 0) { + const likeClauses = keywords.map(() => 't.note LIKE ?').join(' OR ') + const likeParams = keywords.map(k => `%${k}%`) + + const [rows] = await pool.query( + `SELECT t.category_id, c.name, c.color, COUNT(*) as cnt + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE t.user_id = ? AND t.type = ? AND (${likeClauses}) + GROUP BY t.category_id, c.name, c.color + ORDER BY cnt DESC + LIMIT 1`, + [userId, type, ...likeParams] + ) + const match = (rows as any[])[0] + if (match) { + suggestedCategory = { id: match.category_id, name: match.name, color: match.color } + // 置信度基于匹配次数 + confidence = Math.min(90, 40 + match.cnt * 15) + } + } + } + + // 2. 金额相近匹配(±30%) + if (!suggestedCategory) { + const minAmt = Math.floor(amt * 0.7) + const maxAmt = Math.ceil(amt * 1.3) + const [rows] = await pool.query( + `SELECT t.category_id, c.name, c.color, COUNT(*) as cnt + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE t.user_id = ? AND t.type = ? AND t.amount BETWEEN ? AND ? + GROUP BY t.category_id, c.name, c.color + ORDER BY cnt DESC + LIMIT 1`, + [userId, type, minAmt, maxAmt] + ) + const match = (rows as any[])[0] + if (match) { + suggestedCategory = { id: match.category_id, name: match.name, color: match.color } + confidence = Math.min(70, 20 + match.cnt * 10) + } + } + + // 3. 最近使用的分类(兜底) + if (!suggestedCategory) { + const [rows] = await pool.query( + `SELECT t.category_id, c.name, c.color + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE t.user_id = ? AND t.type = ? + ORDER BY t.created_at DESC + LIMIT 1`, + [userId, type] + ) + const match = (rows as any[])[0] + if (match) { + suggestedCategory = { id: match.category_id, name: match.name, color: match.color } + confidence = 20 + } + } + + res.json({ + code: 0, + data: { + category: suggestedCategory, + confidence + } + }) + } catch (err) { + console.error('[Stats] suggest-category error:', err) + res.status(500).json({ code: 50000, message: '服务器错误' }) + } +}) + export default router