feat: 智能分类建议 — 根据金额/备注/历史自动推荐分类
This commit is contained in:
@@ -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) {
|
export function getTrend(month?: string, type: string = 'expense', group_id?: number | null) {
|
||||||
return request<TrendPoint[]>({ url: '/stats/trend', data: { month, type, group_id } })
|
return request<TrendPoint[]>({ 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<CategorySuggestion>({ url: '/stats/suggest-category', data: { amount, type, note } })
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,8 +27,13 @@
|
|||||||
|
|
||||||
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat">
|
<scroll-view class="category-scroll" scroll-y :scroll-into-view="scrollToCat">
|
||||||
<view class="category-grid">
|
<view class="category-grid">
|
||||||
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" @tap="selectedCat = cat.id">
|
<view v-for="cat in currentCategories" :key="cat.id" :id="'cat-' + cat.id" class="cat-item" :class="{ suggested: suggestedCatId === cat.id && selectedCat !== cat.id }" @tap="selectedCat = cat.id">
|
||||||
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
|
<view class="cat-icon-wrap">
|
||||||
|
<CategoryIcon :label="cat.name[0]" :color="cat.color" :bg-color="cat.color + '15'" size="md" :selected="selectedCat === cat.id" />
|
||||||
|
<view v-if="suggestedCatId === cat.id && selectedCat !== cat.id" class="suggest-badge">
|
||||||
|
<text class="suggest-badge-text">推荐</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
<text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text>
|
<text class="cat-name" :class="{ active: selectedCat === cat.id }">{{ cat.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -65,6 +70,7 @@ import { onShow } from '@dcloudio/uni-app'
|
|||||||
import { useCategoryStore } from '@/stores/category'
|
import { useCategoryStore } from '@/stores/category'
|
||||||
import { useTransactionStore } from '@/stores/transaction'
|
import { useTransactionStore } from '@/stores/transaction'
|
||||||
import { waitForReady } from '@/utils/app-ready'
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
|
import { suggestCategory } from '@/api/stats'
|
||||||
import Numpad from '@/components/Numpad/Numpad.vue'
|
import Numpad from '@/components/Numpad/Numpad.vue'
|
||||||
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
|
||||||
import Icon from '@/components/Icon/Icon.vue'
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
@@ -78,6 +84,9 @@ const type = ref<'expense' | 'income'>('expense')
|
|||||||
const amountStr = ref('0')
|
const amountStr = ref('0')
|
||||||
const selectedCat = ref<number | null>(null)
|
const selectedCat = ref<number | null>(null)
|
||||||
const note = ref('')
|
const note = ref('')
|
||||||
|
const suggestedCatId = ref<number | null>(null)
|
||||||
|
const suggestConfidence = ref(0)
|
||||||
|
let suggestTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
const localToday = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||||
const selectedDate = ref(localToday)
|
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 () => {
|
onMounted(async () => {
|
||||||
await waitForReady()
|
await waitForReady()
|
||||||
|
|
||||||
@@ -370,8 +408,38 @@ function goBack() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6rpx;
|
gap: 6rpx;
|
||||||
padding: 12rpx 0;
|
padding: 12rpx 0;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
&:active { transform: scale(0.95); }
|
&: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; }
|
.cat-name { font-size: $font-sm; color: $text-sec; }
|
||||||
|
|||||||
@@ -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
|
export default router
|
||||||
|
|||||||
Reference in New Issue
Block a user