feat: 支持自定义分类与标签规则并优化 AI 查询交互
- 新增 t_rule、t_rule_tag 表(MySQL/SQLite 双脚本)与规则引擎,支持关键词(备注/交易对方/商品名称)、收支类型、金额区间组合匹配 - 新增 /api/rule 增删改查与启用停用接口,侧边栏新增「规则管理」页,支持排序与启停 - 账单导入解析阶段批量匹配,返回命中规则与建议分类/标签,预览表格新增「匹配规则/建议」列 - 确认导入时按行重新匹配:规则建议分类优先于面板默认分类,规则标签与默认标签合并去重 - 删除标签时同步清理规则标签关联 - AI 自然语言查询解析后直接应用筛选并搜索,移除二次确认弹窗,成功提示展示识别意图 - 修正登录日志筛选 success 参数类型为 'true'/'false'
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: '先に明細ファイルを選択してください' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
/** 加载分类选项 */
|
||||
|
||||
@@ -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 导航到对应路由 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user