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
+65
View File
@@ -0,0 +1,65 @@
// 规则相关 API 接口:自定义分类与标签规则的增删改查与启用切换
import request from './request'
/** 规则关联的标签 */
export interface RuleTag {
id: number
name: string
color?: string
}
/** 规则数据结构 */
export interface RuleItem {
id: number
name: string
enabled: boolean
sortOrder: number
type: string | null
keyword: string | null
minAmount: number | null
maxAmount: number | null
categoryId: number | null
categoryName: string | null
categoryIcon: string | null
tags: RuleTag[] | null
createdAt: string
updatedAt: string
}
/** 创建/更新规则的请求体 */
export interface RulePayload {
name: string
enabled?: boolean
sortOrder?: number
type?: string | null
keyword?: string | null
minAmount?: number | null
maxAmount?: number | null
categoryId?: number | null
tagIds?: number[]
}
/** 获取规则列表 */
export function getRuleList() {
return request.get('/rule/list')
}
/** 创建规则 */
export function createRule(data: RulePayload) {
return request.post('/rule', data)
}
/** 更新指定规则 */
export function updateRule(id: number, data: RulePayload) {
return request.put(`/rule/${id}`, data)
}
/** 删除指定规则 */
export function deleteRule(id: number) {
return request.delete(`/rule/${id}`)
}
/** 启用/停用指定规则 */
export function toggleRuleEnabled(id: number, enabled: boolean) {
return request.put(`/rule/${id}/enabled`, { enabled })
}
+5
View File
@@ -85,6 +85,11 @@ export interface ParsedBillRow {
importable: boolean
selected: boolean
duplicate: boolean
matchedRuleNames: string[] | null
suggestedCategoryId: number | null
suggestedCategoryName: string | null
suggestedCategoryIcon: string | null
suggestedTags: { id: number; name: string; color?: string }[] | null
rawData: string | null
error: string | null
}
+31
View File
@@ -82,6 +82,7 @@ export default {
transactions: { zh: '收支记录', en: 'Transactions', ja: '収支記録' },
categories: { zh: '分类管理', en: 'Categories', ja: 'カテゴリ管理' },
tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' },
rules: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' },
logs: { zh: '系统日志', en: 'Logs', ja: 'システムログ' },
settings: { zh: '系统设置', en: 'Settings', ja: 'システム設定' },
user: { zh: '用户', en: 'User', ja: 'ユーザー' },
@@ -432,6 +433,35 @@ export default {
deleteConfirm: { zh: '删除后将从所有交易记录中移除该标签,确定删除吗?', en: 'This tag will be removed from all transactions. Delete it?', ja: '削除するとすべての取引記録からこのタグが外れます。削除してもよろしいですか?' },
},
// 规则管理
rule: {
title: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' },
add: { zh: '新增规则', en: 'Add Rule', ja: 'ルールを追加' },
editTitle: { zh: '编辑规则', en: 'Edit Rule', ja: 'ルールを編集' },
addTitle: { zh: '新增规则', en: 'Add Rule', ja: 'ルールを追加' },
name: { zh: '规则名称', en: 'Rule Name', ja: 'ルール名' },
namePlaceholder: { zh: '请输入规则名称', en: 'Enter rule name', ja: 'ルール名を入力してください' },
nameRequired: { zh: '请输入规则名称', en: 'Please enter a rule name', ja: 'ルール名を入力してください' },
condition: { zh: '匹配条件', en: 'Conditions', ja: '一致条件' },
keyword: { zh: '关键词', en: 'Keyword', ja: 'キーワード' },
keywordPlaceholder: { zh: '匹配备注/交易对方/商品名称,包含即命中', en: 'Matches remark / counterparty / product (contains)', ja: '備考/取引相手/商品名に含まれれば一致' },
amountRange: { zh: '金额区间', en: 'Amount Range', ja: '金額範囲' },
typeAny: { zh: '不限类型', en: 'Any type', ja: '種類を問わない' },
targetCategory: { zh: '目标分类', en: 'Target Category', ja: '対象カテゴリ' },
targetTags: { zh: '目标标签', en: 'Target Tags', ja: '対象タグ' },
notSetCategory: { zh: '不设置分类', en: 'No category', ja: 'カテゴリなし' },
notSetTag: { zh: '不设置标签', en: 'No tags', ja: 'タグなし' },
enabled: { zh: '启用', en: 'Enabled', ja: '有効' },
summaryKeyword: { zh: '含「{kw}」', en: 'contains "{kw}"', ja: '「{kw}」を含む' },
summaryAmountRange: { zh: '{min} ~ {max}', en: '{min} ~ {max}', ja: '{min} ~ {max}' },
summaryAmountMin: { zh: '≥ {min}', en: '≥ {min}', ja: '≥ {min}' },
summaryAmountMax: { zh: '≤ {max}', en: '≤ {max}', ja: '≤ {max}' },
summaryNone: { zh: '无条件', en: 'No condition', ja: '条件なし' },
deleteConfirm: { zh: '确定要删除这个规则吗?', en: 'Are you sure you want to delete this rule?', ja: 'このルールを削除してもよろしいですか?' },
loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories', ja: 'カテゴリの読み込みに失敗しました' },
loadTagsFailed: { zh: '加载标签失败', en: 'Failed to load tags', ja: 'タグの読み込みに失敗しました' },
},
// 账单导入
import: {
title: { zh: '导入账单', en: 'Import Bill', ja: '明細をインポート' },
@@ -478,6 +508,7 @@ export default {
counterparty: { zh: '交易对方', en: 'Counterparty', ja: '取引相手' },
product: { zh: '商品名称', en: 'Product', ja: '商品名' },
status: { zh: '状态', en: 'Status', ja: 'ステータス' },
matchedRule: { zh: '匹配规则/建议', en: 'Matched Rule', ja: '一致ルール' },
},
errorFileType: { zh: '账单类型「{label}」仅支持 {exts} 格式的文件', en: 'Bill type "{label}" only supports files in {exts} format', ja: '明細の種類「{label}」は {exts} 形式のファイルのみ対応しています' },
selectFileFirst: { zh: '请先选择账单文件', en: 'Please select a bill file first', ja: '先に明細ファイルを選択してください' },
+5
View File
@@ -53,6 +53,11 @@ const router = createRouter({
name: 'Tags',
component: () => import('../views/TagManage.vue'), // 标签管理
},
{
path: 'rules',
name: 'Rules',
component: () => import('../views/RuleManage.vue'), // 规则管理(自定义分类与标签规则)
},
{
path: 'settings',
name: 'Settings',
+33
View File
@@ -220,6 +220,39 @@ const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
return h('span', null, row.tradeStatus || '-')
},
},
{
title: t('import.column.matchedRule'),
key: 'matchedRule',
width: 220,
render: (row) => {
const ruleNames = row.matchedRuleNames || []
const suggestedTags = row.suggestedTags || []
if (ruleNames.length === 0 && !row.suggestedCategoryId && suggestedTags.length === 0) {
return h('span', { style: { color: '#ccc' } }, '—')
}
const nodes: any[] = []
// 命中规则名,多个时折叠为 +N
if (ruleNames.length) {
nodes.push(h(NTag, { size: 'small', type: 'warning' }, {
default: () => (ruleNames.length > 1 ? `${ruleNames[0]} +${ruleNames.length - 1}` : ruleNames[0]),
}))
}
// 建议分类
if (row.suggestedCategoryId) {
nodes.push(h(NTag, { size: 'small', type: 'info' }, {
default: () => `${row.suggestedCategoryIcon || ''}${row.suggestedCategoryName || ''}`,
}))
}
// 建议标签
for (const tag of suggestedTags) {
nodes.push(h(NTag, {
size: 'small',
color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
}, { default: () => tag.name }))
}
return h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px' } }, nodes)
},
},
])
/** 加载分类选项 */
+3 -1
View File
@@ -5,7 +5,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
import type { MenuOption } from 'naive-ui'
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5'
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline } from '@vicons/ionicons5'
import { useUserStore } from '../store/user'
import LocaleSwitch from '../components/LocaleSwitch.vue'
@@ -21,6 +21,7 @@ const activeKey = computed(() => {
'/transactions': 'transactions',
'/categories': 'categories',
'/tags': 'tags',
'/rules': 'rules',
}
return map[route.path] || 'dashboard'
})
@@ -36,6 +37,7 @@ const menuOptions = computed<MenuOption[]>(() => [
{ label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) },
{ label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) },
{ label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) },
{ label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) },
])
/** 菜单点击事件:根据 key 导航到对应路由 */
+4 -4
View File
@@ -53,7 +53,7 @@ const loginPage = ref(1)
const loginSize = ref(20)
const loginTotal = ref(0)
const loginKeyword = ref('')
const loginSuccess = ref<boolean | null>(null)
const loginSuccess = ref<'true' | 'false' | null>(null)
// ---------------- 运行日志 ----------------
const appData = ref<AppLog | null>(null)
@@ -80,8 +80,8 @@ const statusOptions = [200, 201, 400, 401, 403, 404, 500].map((value) => ({ labe
/** 登录结果下拉选项 */
const loginResultOptions = computed(() => [
{ label: t('log.success'), value: true },
{ label: t('log.failed'), value: false },
{ label: t('log.success'), value: 'true' },
{ label: t('log.failed'), value: 'false' },
])
/** 日志级别下拉选项 */
@@ -221,7 +221,7 @@ async function loadLogin() {
page: loginPage.value,
size: loginSize.value,
keyword: loginKeyword.value.trim() || undefined,
success: loginSuccess.value ?? undefined,
success: loginSuccess.value === null ? undefined : loginSuccess.value === 'true',
})
loginRecords.value = res.data.records || []
loginTotal.value = res.data.total || 0
+407
View File
@@ -0,0 +1,407 @@
<script setup lang="ts">
// 规则管理组件:展示自定义分类与标签规则,支持新增、编辑、删除、启用停用与排序
import { ref, onMounted, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem,
NInput, NInputNumber, NSelect, NSwitch, NTag,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getRuleList, createRule, updateRule, deleteRule, toggleRuleEnabled } from '../api/rule'
import type { RuleItem } from '../api/rule'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
const { t } = useI18n() // 国际化
/** 分类选项 */
interface CategoryOption {
label: string
value: number
icon: string
type: string
}
/** 标签选项 */
interface TagOption {
label: string
value: number
color: string
}
// 列表和弹窗状态
const rules = ref<RuleItem[]>([])
const loading = ref(false)
const showModal = ref(false)
const editingId = ref<number | null>(null)
const submitting = ref(false)
const categories = ref<CategoryOption[]>([])
const tags = ref<TagOption[]>([])
// 表单数据
const form = ref<{
name: string
enabled: boolean
sortOrder: number
type: string | null
keyword: string
minAmount: number | null
maxAmount: number | null
categoryId: number | null
tagIds: number[]
}>({
name: '',
enabled: true,
sortOrder: 0,
type: null,
keyword: '',
minAmount: null,
maxAmount: null,
categoryId: null,
tagIds: [],
})
// 收支类型选项(含「不限」)
const typeOptions = computed(() => [
{ label: t('rule.typeAny'), value: '' },
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
// 分类选项:按当前收支类型过滤
const categoryOptions = computed(() => {
const list = form.value.type ? categories.value.filter((c) => c.type === form.value.type) : categories.value
return list.map((c) => ({ label: c.label, value: c.value, icon: c.icon }))
})
// 标签选项
const tagOptions = computed(() => tags.value.map((tag) => ({ label: tag.label, value: tag.value })))
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
/** 构造条件摘要标签,便于列表快速识别规则 */
function conditionTags(row: RuleItem) {
const result: any[] = []
if (row.type) {
result.push(h(NTag, { size: 'small', type: row.type === 'INCOME' ? 'success' : 'error' }, {
default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')),
}))
}
if (row.keyword) {
result.push(h(NTag, { size: 'small' }, { default: () => t('rule.summaryKeyword', { kw: row.keyword }) }))
}
if (row.minAmount != null && row.maxAmount != null) {
result.push(h(NTag, { size: 'small' }, {
default: () => t('rule.summaryAmountRange', { min: row.minAmount, max: row.maxAmount }),
}))
} else if (row.minAmount != null) {
result.push(h(NTag, { size: 'small' }, { default: () => t('rule.summaryAmountMin', { min: row.minAmount }) }))
} else if (row.maxAmount != null) {
result.push(h(NTag, { size: 'small' }, { default: () => t('rule.summaryAmountMax', { max: row.maxAmount }) }))
}
if (result.length === 0) {
result.push(h(NTag, { size: 'small', type: 'default' }, { default: () => t('rule.summaryNone') }))
}
return result
}
/** 创建表格列配置 */
const columns = computed<DataTableColumns<RuleItem>>(() => [
{
title: t('common.name'),
key: 'name',
width: 150,
render: (row) => h('span', { style: { fontWeight: '500' } }, row.name),
},
{
title: t('rule.condition'),
key: 'condition',
render: (row) => h(NSpace, { size: 4 }, { default: () => conditionTags(row) }),
},
{
title: t('rule.targetCategory'),
key: 'category',
width: 140,
render: (row) => {
if (!row.categoryId) {
return h('span', { style: { color: '#ccc' } }, '—')
}
return h('span', null, [
row.categoryIcon ? h('span', { style: { marginRight: '4px' } }, row.categoryIcon) : null,
row.categoryName || '',
])
},
},
{
title: t('rule.targetTags'),
key: 'tags',
width: 160,
render: (row) => {
const list = row.tags || []
if (list.length === 0) {
return h('span', { style: { color: '#ccc' } }, '—')
}
return h(NSpace, { size: 4 }, {
default: () => list.map((tag) => h(NTag, {
size: 'small',
color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
}, { default: () => tag.name })),
})
},
},
{
title: t('rule.enabled'),
key: 'enabled',
width: 80,
render: (row) => h(NSwitch, {
value: row.enabled,
size: 'small',
onUpdateValue: (v: boolean) => { handleToggleEnabled(row, v) },
}),
},
{ title: t('common.sort'), key: 'sortOrder', width: 70 },
{
title: t('common.operation'),
key: 'actions',
width: 150,
render: (row) => h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
],
}),
},
])
/** 加载规则列表数据 */
async function fetchData() {
loading.value = true
try {
const res: any = await getRuleList()
rules.value = res.data || []
} catch (e: any) {
message.error(e.message || t('common.loadFailed'))
} finally {
loading.value = false
}
}
/** 加载分类选项 */
async function loadCategories() {
try {
const res: any = await getCategoryList()
categories.value = (res.data || []).map((c: any) => ({
label: c.name,
value: c.id,
icon: c.icon || '',
type: c.type,
}))
} catch (e: any) {
message.error(e.message || t('rule.loadCategoriesFailed'))
}
}
/** 加载标签选项 */
async function loadTags() {
try {
const res: any = await getTagList()
tags.value = (res.data || []).map((tag: any) => ({
label: tag.name,
value: tag.id,
color: tag.color || '',
}))
} catch (e: any) {
message.error(e.message || t('rule.loadTagsFailed'))
}
}
/** 打开新增规则弹窗,重置表单 */
function handleAdd() {
editingId.value = null
form.value = {
name: '',
enabled: true,
sortOrder: 0,
type: null,
keyword: '',
minAmount: null,
maxAmount: null,
categoryId: null,
tagIds: [],
}
showModal.value = true
}
/** 打开编辑规则弹窗,填充表单数据 */
function handleEdit(row: RuleItem) {
editingId.value = row.id
form.value = {
name: row.name,
enabled: row.enabled,
sortOrder: row.sortOrder,
type: row.type,
keyword: row.keyword || '',
minAmount: row.minAmount,
maxAmount: row.maxAmount,
categoryId: row.categoryId,
tagIds: (row.tags || []).map((tag) => tag.id),
}
showModal.value = true
}
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
async function handleSubmit() {
if (!form.value.name.trim()) {
message.warning(t('rule.nameRequired'))
return
}
const payload = {
name: form.value.name.trim(),
enabled: form.value.enabled,
sortOrder: form.value.sortOrder,
type: form.value.type || null,
keyword: form.value.keyword.trim() || null,
minAmount: form.value.minAmount,
maxAmount: form.value.maxAmount,
categoryId: form.value.categoryId,
tagIds: form.value.tagIds,
}
submitting.value = true
try {
if (editingId.value) {
await updateRule(editingId.value, payload)
message.success(t('common.updateSuccess'))
} else {
await createRule(payload)
message.success(t('common.createSuccess'))
}
showModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
submitting.value = false
}
}
/** 启用/停用规则 */
async function handleToggleEnabled(row: RuleItem, enabled: boolean) {
try {
await toggleRuleEnabled(row.id, enabled)
row.enabled = enabled
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
await fetchData()
}
}
/** 删除规则:弹出确认对话框,确认后执行删除并刷新列表 */
function handleDelete(id: number) {
dialog.warning({
title: t('common.confirmDelete'),
content: t('rule.deleteConfirm'),
positiveText: t('common.delete'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await deleteRule(id)
message.success(t('common.deleteSuccess'))
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.deleteFailed'))
}
},
})
}
// 收支类型变化时,若已选分类类型不匹配则清空,避免保存非法组合
watch(() => form.value.type, (type) => {
if (form.value.categoryId && type) {
const category = categories.value.find((c) => c.value === form.value.categoryId)
if (category && category.type !== type) {
form.value.categoryId = null
}
}
})
// 组件挂载时加载规则、分类与标签
onMounted(() => {
fetchData()
loadCategories()
loadTags()
})
</script>
<template>
<div>
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px;">
<h2 style="margin: 0;">{{ t('rule.title') }}</h2>
<NButton type="primary" @click="handleAdd">{{ t('rule.add') }}</NButton>
</NSpace>
<NDataTable :columns="columns" :data="rules" :loading="loading" :bordered="false" />
<!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('rule.editTitle') : t('rule.addTitle')" style="width: 520px;">
<NForm>
<NFormItem :label="t('common.name')">
<NInput v-model:value="form.name" :placeholder="t('rule.namePlaceholder')" />
</NFormItem>
<NFormItem :label="t('common.type')">
<NSelect
:value="form.type || ''"
:options="typeOptions"
@update:value="(v: string) => (form.type = v || null)"
/>
</NFormItem>
<NFormItem :label="t('rule.keyword')">
<NInput v-model:value="form.keyword" :placeholder="t('rule.keywordPlaceholder')" />
</NFormItem>
<NFormItem :label="t('rule.amountRange')">
<NSpace align="center">
<NInputNumber v-model:value="form.minAmount" :min="0" :precision="2" :placeholder="t('common.min')" style="width: 150px;" />
<span style="color: #999;">~</span>
<NInputNumber v-model:value="form.maxAmount" :min="0" :precision="2" :placeholder="t('common.max')" style="width: 150px;" />
</NSpace>
</NFormItem>
<NFormItem :label="t('rule.targetCategory')">
<NSelect
v-model:value="form.categoryId"
:options="categoryOptions"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('rule.notSetCategory')"
/>
</NFormItem>
<NFormItem :label="t('rule.targetTags')">
<NSelect
v-model:value="form.tagIds"
:options="tagOptions"
multiple
clearable
filterable
:max-tag-count="3"
:placeholder="t('rule.notSetTag')"
/>
</NFormItem>
<NFormItem :label="t('rule.enabled')">
<NSwitch v-model:value="form.enabled" />
</NFormItem>
<NFormItem :label="t('common.sort')">
<NInputNumber v-model:value="form.sortOrder" :min="0" />
</NFormItem>
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
</div>
</template>
+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">