feat: 支持自定义分类与标签规则并优化 AI 查询交互

- 新增 t_rule、t_rule_tag 表(MySQL/SQLite 双脚本)与规则引擎,支持关键词(备注/交易对方/商品名称)、收支类型、金额区间组合匹配
- 新增 /api/rule 增删改查与启用停用接口,侧边栏新增「规则管理」页,支持排序与启停
- 账单导入解析阶段批量匹配,返回命中规则与建议分类/标签,预览表格新增「匹配规则/建议」列
- 确认导入时按行重新匹配:规则建议分类优先于面板默认分类,规则标签与默认标签合并去重
- 删除标签时同步清理规则标签关联
- AI 自然语言查询解析后直接应用筛选并搜索,移除二次确认弹窗,成功提示展示识别意图
- 修正登录日志筛选 success 参数类型为 'true'/'false'
This commit is contained in:
2026-09-18 17:42:59 +08:00
parent e82e0a4ef9
commit c69e76ed4c
26 changed files with 1622 additions and 78 deletions
+10 -65
View File
@@ -2,7 +2,7 @@
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
import { ref, onMounted, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDescriptions, NDescriptionsItem, NDivider, NDropdown } from 'naive-ui'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown } from 'naive-ui'
import { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import type { DataTableColumns, AutoCompleteOption } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction'
@@ -126,7 +126,6 @@ const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID
const aiQueryText = ref('') // AI 自然语言查询输入
const aiQuerying = ref(false) // AI 查询解析状态
const showAiQueryModal = ref(false) // AI 查询结果弹窗显示状态
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
// AI 查询历史存储键与上限
const AI_QUERY_HISTORY_KEY = 'finance.transaction.aiQueryHistory'
@@ -1115,7 +1114,13 @@ async function handleAiQuery() {
try {
const res: any = await queryByAi(text)
aiQueryResult.value = res.data
showAiQueryModal.value = true
// 无任何可用筛选条件时提示,避免误清空当前筛选
if (aiQueryEmpty.value) {
message.warning(t('ai.query.noCondition'))
return
}
// 直接应用解析结果并搜索,无需二次确认
applyAiQuery()
} catch (e: any) {
message.error(e.message || t('ai.query.failed'))
} finally {
@@ -1140,9 +1145,9 @@ function applyAiQuery() {
filterMaxAmount.value = r.maxAmount ?? null
filterRemark.value = r.remark || ''
filterTag.value = r.tagIds || []
showAiQueryModal.value = false
handleSearch()
message.success(t('ai.query.applied'))
// 成功提示优先展示 AI 识别的查询意图
message.success(r.summary || t('ai.query.applied'))
}
/** 解析结果是否为空(无任何可用筛选条件) */
@@ -1161,11 +1166,6 @@ const aiQueryEmpty = computed(() => {
&& r.includeInStats == null
})
/** 格式化解析结果中的时间用于展示 */
function formatAiDate(value: string | null | undefined) {
return value ? value.replace('T', ' ').substring(0, 16) : ''
}
/** AI 查询示例(从 i18n 拆分为数组) */
const queryExamples = computed(() => t('ai.query.examples').split('|'))
@@ -1923,61 +1923,6 @@ onMounted(() => {
</NSpace>
</NModal>
<!-- AI 自然语言查询解析结果弹窗 -->
<NModal v-if="aiEnabled" v-model:show="showAiQueryModal" preset="card" :title="t('ai.query.resultTitle')" style="width: 560px; max-width: 92vw;">
<NSpace vertical :size="12">
<NAlert v-if="aiQueryResult?.summary" type="info" :show-icon="true">{{ aiQueryResult.summary }}</NAlert>
<NText v-if="aiQueryEmpty" depth="3">{{ t('ai.query.noCondition') }}</NText>
<NDescriptions v-else :column="1" label-placement="left" bordered size="small">
<NDescriptionsItem v-if="aiQueryResult?.types?.length" :label="t('ai.query.type')">
<NSpace :size="6">
<NTag v-for="tp in aiQueryResult.types" :key="tp" size="small" :type="tp === 'INCOME' ? 'success' : 'error'">
{{ tp === 'INCOME' ? t('common.types.income') : t('common.types.expense') }}
</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.categoryNames?.length || aiQueryResult?.uncategorized" :label="t('ai.query.category')">
<NSpace :size="6">
<NTag v-for="c in aiQueryResult?.categoryNames || []" :key="c" size="small">{{ c }}</NTag>
<NTag v-if="aiQueryResult?.uncategorized" size="small">{{ t('transaction.uncategorized') }}</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.tagNames?.length" :label="t('ai.query.tag')">
<NSpace :size="6">
<NTag v-for="tg in aiQueryResult.tagNames" :key="tg" size="small">{{ tg }}</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.paymentMethods?.length" :label="t('ai.query.payment')">
<NSpace :size="6">
<NTag v-for="pm in aiQueryResult.paymentMethods" :key="pm" size="small">{{ getPaymentLabel(pm) }}</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.startTime && aiQueryResult?.endTime" :label="t('ai.query.dateRange')">
{{ formatAiDate(aiQueryResult.startTime) }} ~ {{ formatAiDate(aiQueryResult.endTime) }}
</NDescriptionsItem>
<NDescriptionsItem
v-if="aiQueryResult && (aiQueryResult.minAmount != null || aiQueryResult.maxAmount != null)"
:label="t('ai.query.amountRange')"
>
{{ aiQueryResult.minAmount ?? 0 }} ~ {{ aiQueryResult.maxAmount ?? '∞' }}
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.remark" :label="t('ai.query.remark')">{{ aiQueryResult.remark }}</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult && aiQueryResult.includeInStats != null" :label="t('ai.query.includeInStats')">
{{ aiQueryResult.includeInStats ? t('ai.query.includeYes') : t('ai.query.includeNo') }}
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.unmatched?.length" :label="t('ai.query.unmatched')">
<NSpace :size="6">
<NTag v-for="u in aiQueryResult.unmatched" :key="u" size="small" type="warning">{{ u }}</NTag>
</NSpace>
</NDescriptionsItem>
</NDescriptions>
<NSpace justify="end">
<NButton @click="showAiQueryModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :disabled="aiQueryEmpty" @click="applyAiQuery">{{ t('ai.query.apply') }}</NButton>
</NSpace>
</NSpace>
</NModal>
<!-- AI 智能记账弹窗 -->
<NModal v-if="aiEnabled" v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 520px; max-width: 92vw;">
<NSpace vertical :size="12">