feat: 收支记录页支持基于勾选账单 AI 生成规则

在收支记录页批量操作区新增「AI 生成规则」入口,可基于勾选的账单调用 AI 归纳分类与标签规则,在弹窗中预览、勾选创建,并可选择同时将这些规则应用到所选账单,复用既有规则生成能力并补充本地匹配应用接口。

变更内容:
- 后端新增 `POST /api/ai/rule/apply-transactions`(`AiRuleService.applyToTransactions`):按传入规则对指定交易做本地匹配,命中则覆盖分类、追加标签,必要时新建分类与标签,复用 `AiSuggestionService.apply` 落库。
- 新增 DTO `AiRuleTransactionApplyRequest`,复用 `AiRuleApplyRequest.Item` 结构承载规则定义。
- 前端 `api/ai.ts` 抽出 `AiRuleApplyItem` 类型并新增 `applyRulesToTransactions`。
- 新增组件 `AiRuleSuggestModal.vue`:展示规则建议、勾选创建,并提供「同时应用到当前勾选的 N 条账单」开关。
- `TransactionList.vue` 批量操作区新增「AI 生成规则」按钮(受 AI 启用状态控制),勾选账单后一步生成并预览规则。
- `locales/messages.ts` 补充相关中文、英文、日文文案。

说明:
- `apply-transactions` 为纯本地匹配、不调用大模型,不外发任何敏感数据;`suggest` 仍遵循白名单只发送类型、备注、渠道与分类标签名称。
This commit is contained in:
2026-10-13 20:53:00 +08:00
parent bfd314a38e
commit 3b7b59ff6f
8 changed files with 488 additions and 18 deletions
+21 -15
View File
@@ -185,25 +185,31 @@ export function suggestRulesByAi(transactionIds: number[]) {
return request.post('/ai/rule/suggest', { transactionIds }, { timeout: 120000 })
}
/** AI 规则创建/应用的单条规则载荷 */
export interface AiRuleApplyItem {
name: string
enabled?: boolean
type?: string | null
keyword?: string | null
minAmount?: number | null
maxAmount?: number | null
categoryId?: number | null
newCategoryName?: string | null
newCategoryIcon?: string | null
tagIds?: number[]
newTags?: AiNewTag[]
}
/** 批量创建 AI 生成的规则,必要时新建分类与标签 */
export function applyAiRules(
rules: {
name: string
enabled?: boolean
type?: string | null
keyword?: string | null
minAmount?: number | null
maxAmount?: number | null
categoryId?: number | null
newCategoryName?: string | null
newCategoryIcon?: string | null
tagIds?: number[]
newTags?: AiNewTag[]
}[],
) {
export function applyAiRules(rules: AiRuleApplyItem[]) {
return request.post('/ai/rule/apply', { rules })
}
/** 将规则按类型/关键词/金额匹配并应用到指定交易,命中则覆盖分类、追加标签 */
export function applyRulesToTransactions(transactionIds: number[], rules: AiRuleApplyItem[]) {
return request.post('/ai/rule/apply-transactions', { transactionIds, rules })
}
/** 一键应用建议:覆盖分类并追加标签,标记为新建的分类/标签会先创建再关联 */
export function applyAiSuggestions(
suggestions: {
@@ -0,0 +1,161 @@
<script setup lang="ts">
// AI 规则建议弹窗:展示基于勾选账单生成的规则建议,勾选后创建规则,并可选同时应用到勾选账单
import { ref, computed, watch, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton, NModal, NSpace, NDataTable, NTag, NSwitch, NText, NAlert } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import type { AiRuleSuggestion } from '../api/ai'
/** 规则建议行:附加本地 key 供表格选择使用 */
type AiRuleRow = AiRuleSuggestion & { key: number }
const props = defineProps<{
show: boolean
suggestions: AiRuleSuggestion[]
targetCount: number
applying: boolean
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'submit', payload: { rules: AiRuleSuggestion[]; applyToSelection: boolean }): void
}>()
const { t } = useI18n()
const showModel = computed({
get: () => props.show,
set: (value: boolean) => emit('update:show', value),
})
// 规则建议行(带本地 key)
const rows = computed<AiRuleRow[]>(() => props.suggestions.map((rule, index) => ({ ...rule, key: index })))
// 已勾选规则行 key
const checkedKeys = ref<number[]>([])
// 是否同时把规则应用到当前勾选的账单
const applyToSelection = ref(false)
// 打开时重置:默认勾选非重复项,应用开关默认关闭
watch(
() => props.show,
(visible) => {
if (!visible) return
checkedKeys.value = rows.value.filter((row) => !row.duplicate).map((row) => row.key)
applyToSelection.value = false
},
)
/** 规则建议表格列(只读展示) */
const columns = computed<DataTableColumns<AiRuleRow>>(() => [
{ type: 'selection', disabled: (row) => !!row.duplicate },
{
title: t('common.name'),
key: 'name',
minWidth: 160,
render: (row) =>
h(
NSpace,
{ size: 4, align: 'center', wrap: false },
{
default: () => [
h('span', null, row.name),
row.duplicate
? h(NTag, { size: 'small', type: 'warning' }, { default: () => t('ai.rule.duplicate') })
: null,
],
},
),
},
{
title: t('rule.keyword'),
key: 'keyword',
width: 140,
render: (row) => row.keyword || '—',
},
{
title: t('common.type'),
key: 'type',
width: 80,
render: (row) => (row.type ? (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) : '—'),
},
{
title: t('rule.targetCategory'),
key: 'category',
minWidth: 130,
render: (row) => {
if (row.categoryName) return row.categoryName
if (row.newCategoryName) return `${t('ai.rule.newPrefix')}${row.newCategoryName}`
return '—'
},
},
{
title: t('rule.targetTags'),
key: 'tags',
minWidth: 150,
render: (row) => {
const names = [
...(row.tagNames || []),
...(row.newTags || []).map((tag) => `${t('ai.rule.newPrefix')}${tag.name}`),
]
return names.length > 0 ? names.join('、') : '—'
},
},
{
title: t('ai.rule.reason'),
key: 'reason',
minWidth: 160,
ellipsis: { tooltip: true },
},
])
// 已勾选规则(重复项不可选)
const selectedRules = computed(() => rows.value.filter((row) => !row.duplicate && checkedKeys.value.includes(row.key)))
/** 去除本地 key,还原为规则建议对象 */
function stripKey(row: AiRuleRow): AiRuleSuggestion {
const { key, ...rest } = row
void key
return rest
}
/** 提交:传出勾选规则与是否同时应用到账单 */
function handleSubmit() {
if (selectedRules.value.length === 0) return
emit('submit', {
rules: selectedRules.value.map(stripKey),
applyToSelection: applyToSelection.value,
})
}
</script>
<template>
<NModal v-model:show="showModel" preset="card" :title="t('ai.rule.title')" style="width: min(1100px, 94vw)">
<NSpace vertical :size="12">
<NText depth="3" style="font-size: 12px">{{ t('ai.rule.hint') }}</NText>
<NDataTable
v-model:checked-row-keys="checkedKeys"
:columns="columns"
:data="rows"
:max-height="420"
:scroll-x="900"
:row-key="(row: AiRuleRow) => row.key"
size="small"
/>
<NAlert v-if="applyToSelection" type="warning" :show-icon="true">
{{ t('ai.rule.applyToSelectionNote') }}
</NAlert>
<NSpace align="center" justify="space-between" :wrap="true">
<NSpace align="center" :size="8">
<NSwitch v-model:value="applyToSelection" />
<span>{{ t('ai.rule.applyToSelection', { count: targetCount }) }}</span>
</NSpace>
<NSpace justify="end">
<NButton @click="showModel = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="applying" :disabled="selectedRules.length === 0" @click="handleSubmit">
{{ t('ai.rule.apply') }}
</NButton>
</NSpace>
</NSpace>
</NSpace>
</NModal>
</template>
+20
View File
@@ -1440,6 +1440,26 @@ export default {
en: 'All generated rules already exist',
ja: '生成されたルールはすべて既存です',
},
selectFirst: {
zh: '请先勾选要用于生成规则的账单',
en: 'Please select the records to derive rules from',
ja: 'ルール生成に使う記録を先に選択してください',
},
applyToSelection: {
zh: '同时应用到当前勾选的 {count} 条账单',
en: 'Also apply to the {count} selected records',
ja: '選択中の {count} 件の記録にも適用する',
},
applyToSelectionNote: {
zh: '命中规则时会覆盖这些账单的分类并追加标签(不改动备注与金额)',
en: 'Matching rules overwrite the category and append tags of these records (remark and amount unchanged)',
ja: 'ルールが一致した場合、これらの記録のカテゴリを上書きしタグを追加します(備考と金額は変更しません)',
},
appliedAndApplied: {
zh: '已创建 {rules} 条规则,并应用到 {transactions} 条账单',
en: 'Created {rules} rule(s) and applied to {transactions} record(s)',
ja: '{rules} 件のルールを作成し、{transactions} 件の記録に適用しました',
},
},
category: {
label: { zh: 'AI 生成分类', en: 'AI Category', ja: 'AI 生成カテゴリ' },
+96 -2
View File
@@ -39,9 +39,19 @@ import {
getChannels,
batchDeleteTransactions,
} from '../api/transaction'
import { suggestByAi, suggestOneByAi, queryByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi } from '../api/ai'
import {
suggestByAi,
suggestOneByAi,
queryByAi,
parseCategoryByAi,
parseTagByAi,
parseChannelByAi,
suggestRulesByAi,
applyAiRules,
applyRulesToTransactions,
} from '../api/ai'
import { createChannel } from '../api/channel'
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
import type { AiSuggestion, AiQueryResult, AiRecordResult, AiRuleSuggestion, AiRuleApplyItem } from '../api/ai'
import { getExcludedDisplayStyle, getShowHiddenData } from '../api/setting'
import { getCategoryList, createCategory } from '../api/category'
import { getTagList, createTag } from '../api/tag'
@@ -59,6 +69,7 @@ import { aiEnabled, ensureAiEnabledLoaded } from '../composables/useAiEnabled'
import EmojiPicker from '../components/EmojiPicker.vue'
import AiRecordModal from '../components/AiRecordModal.vue'
import AiSuggestModal from '../components/AiSuggestModal.vue'
import AiRuleSuggestModal from '../components/AiRuleSuggestModal.vue'
import TransactionBatchModal from '../components/TransactionBatchModal.vue'
import BillImportModal from './BillImportModal.vue'
import TransactionDetailModal from '../components/TransactionDetailModal.vue'
@@ -220,6 +231,10 @@ const newTagColorSwatches = [
'#000000',
]
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
const aiRuleSuggestions = ref<AiRuleSuggestion[]>([]) // AI 规则建议列表
const aiRuleGenerating = ref(false) // 规则生成中
const aiRuleApplying = ref(false) // 规则创建/应用中
const showAiRuleModal = ref(false) // AI 生成规则弹窗显示状态
const aiQuerying = ref(false) // AI 查询解析状态
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
const showAiQueryModal = ref(false) // AI 智能查询弹窗显示状态
@@ -1021,6 +1036,72 @@ async function handleAiSuggest() {
}
}
/** 基于勾选的账单生成规则建议 */
async function handleAiRuleGenerate() {
const ids = checkedTransactionIds.value
if (ids.length === 0) {
message.warning(t('ai.rule.selectFirst'))
return
}
aiRuleGenerating.value = true
try {
const res: any = await suggestRulesByAi(ids)
const data = res.data || {}
aiRuleSuggestions.value = data.rules || []
if (aiRuleSuggestions.value.length === 0) {
message.warning(t('ai.noSuggestion'))
return
}
showAiRuleModal.value = true
} catch (e: any) {
message.error(e.message || t('ai.rule.failed'))
} finally {
aiRuleGenerating.value = false
}
}
/** 将规则建议映射为创建/应用载荷 */
function toRulePayload(rule: AiRuleSuggestion): AiRuleApplyItem {
return {
name: rule.name,
enabled: true,
type: rule.type,
keyword: rule.keyword,
minAmount: rule.minAmount,
maxAmount: rule.maxAmount,
categoryId: rule.categoryId,
newCategoryName: rule.newCategoryName,
newCategoryIcon: rule.newCategoryIcon,
tagIds: rule.tagIds,
newTags: rule.newTags,
}
}
/** 创建勾选的规则,并可选同时应用到勾选的账单 */
async function handleAiRuleSubmit(payload: { rules: AiRuleSuggestion[]; applyToSelection: boolean }) {
const rules = payload.rules.map(toRulePayload)
const targetIds = [...checkedTransactionIds.value]
aiRuleApplying.value = true
try {
await applyAiRules(rules)
if (payload.applyToSelection) {
await applyRulesToTransactions(targetIds, rules)
message.success(t('ai.rule.appliedAndApplied', { rules: rules.length, transactions: targetIds.length }))
} else {
message.success(t('ai.rule.applied', { count: rules.length }))
}
showAiRuleModal.value = false
exitSelectionMode()
await fetchData()
await fetchCategories()
await fetchTags()
} catch (e: any) {
message.error(e.message || t('ai.rule.applyFailed'))
} finally {
aiRuleApplying.value = false
}
}
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、交易渠道、备注) */
async function handleAiSuggestOne() {
aiFormLoading.value = true
@@ -1857,6 +1938,9 @@ onMounted(() => {
<NButton v-if="checkedTransactionIds.length >= 2" type="primary" ghost @click="handleCreateRelation">
{{ t('transaction.relation.createFromSelection') }}
</NButton>
<NButton v-if="aiEnabled" :loading="aiRuleGenerating" @click="handleAiRuleGenerate">
{{ t('ai.rule.entry') }}
</NButton>
</NSpace>
<!-- 表格信息栏(右上角) -->
@@ -2206,6 +2290,16 @@ onMounted(() => {
@applied="onAiSuggestApplied"
/>
<!-- AI 生成规则弹窗(基于勾选账单生成规则,可选同时应用到账单) -->
<AiRuleSuggestModal
v-if="aiEnabled"
v-model:show="showAiRuleModal"
:suggestions="aiRuleSuggestions"
:target-count="checkedTransactionIds.length"
:applying="aiRuleApplying"
@submit="handleAiRuleSubmit"
/>
<!-- AI 智能记账弹窗(文字 / 图片) -->
<AiRecordModal
v-if="aiEnabled"