feat: 规则支持一键应用到匹配账单,AI 规则建议可编辑并实时去重

本次为分类与标签规则补充两项能力:规则管理页可一键把规则的分类与标签应用到其命中的全部历史账单(先预览命中账单再确认,分类覆盖、标签追加);AI 生成的规则建议支持在弹窗内完整编辑名称、类型、关键词、金额区间、分类与标签,编辑后由后端实时重算重复标记并自动调整勾选,同时把「同时应用到勾选账单」默认开启并记入本机偏好。

变更内容:
- 后端新增 `POST /api/rule/apply-preview` 与 `POST /api/rule/apply`(`RuleService.previewApply` / `applyToMatchedTransactions`):按规则条件匹配全部历史账单并预览命中明细,应用时分类覆盖、标签追加,新增 DTO `RuleApplyRequest` 与 VO `RuleApplyPreviewVO` / `RuleApplyResultVO` / `RuleMatchedTransactionVO`。
- 后端新增 `POST /api/ai/rule/check-duplicates`(`AiRuleService.checkDuplicates`):纯本地复用规则签名逻辑,返回编辑后逐条重复标记,新增 DTO `AiRuleDuplicateCheckRequest`。
- 前端新增 `RuleApplyPreviewModal.vue`(命中账单预览与一键应用)与共享组件 `AiRuleEditorTable.vue`(完整编辑 + 编辑后防抖去重重算 + 重复项自动增删勾选),`RuleManage.vue` 与 `AiRuleSuggestModal.vue` 统一改用该编辑表。
- 规则管理页新增行内「应用」按钮与勾选规则后的「应用匹配账单」批量入口;收支记录页「同时应用到勾选账单」默认开启并经偏好 `ledgerly.transaction.aiRuleApplyToSelection` 持久化。
- 补充 `frontend/src/api/rule.ts`、`frontend/src/api/ai.ts` 接口封装与 `locales/messages.ts` 中英日文案。

说明:
- `apply-preview` / `apply` 与 `check-duplicates` 均为本地匹配、不调用大模型、不外发敏感数据,去重签名口径与匹配逻辑保持一致。
- 未改动数据库表结构。
- 一次应用命中较多账单时预览明细最多展示 500 条,应用仍作用于全部命中。
This commit is contained in:
2026-10-13 23:44:47 +08:00
parent f0956f6587
commit 524fa52908
20 changed files with 1385 additions and 225 deletions
+5
View File
@@ -205,6 +205,11 @@ export function applyAiRules(rules: AiRuleApplyItem[]) {
return request.post('/ai/rule/apply', { rules })
}
/** 校验编辑后的规则是否与已有规则或批内其它规则重复,返回逐条重复标记 */
export function checkRuleDuplicates(rules: AiRuleApplyItem[]) {
return request.post('/ai/rule/check-duplicates', { rules })
}
/** 将规则按类型/关键词/金额匹配并应用到指定交易,命中则覆盖分类、追加标签 */
export function applyRulesToTransactions(transactionIds: number[], rules: AiRuleApplyItem[]) {
return request.post('/ai/rule/apply-transactions', { transactionIds, rules })
+43
View File
@@ -82,3 +82,46 @@ export function batchDeleteRules(ids: number[]) {
export function toggleRuleEnabled(id: number, enabled: boolean) {
return request.put(`/rule/${id}/enabled`, { enabled })
}
/** 规则命中的账单(用于应用预览) */
export interface RuleMatchedTransaction {
id: number
transactionTime: string | null
type: string
amount: number
channel: string
remark: string | null
currentCategoryId: number | null
currentCategoryName: string | null
currentTagNames: string[]
targetCategoryId: number | null
targetCategoryName: string | null
targetTagNames: string[]
matchedRuleNames: string[]
}
/** 规则应用预览结果 */
export interface RuleApplyPreview {
matchedCount: number
categoryChangeCount: number
tagChangeCount: number
truncated: boolean
transactions: RuleMatchedTransaction[]
}
/** 规则应用结果统计 */
export interface RuleApplyResult {
matchedCount: number
updatedCount: number
tagAddedCount: number
}
/** 预览所选规则命中的历史账单(分类与标签目标) */
export function getRuleApplyPreview(ruleIds: number[]) {
return request.post('/rule/apply-preview', { ruleIds })
}
/** 一键将所选规则的分类与标签应用到其命中的历史账单 */
export function applyRulesToMatchedTransactions(ruleIds: number[]) {
return request.post('/rule/apply', { ruleIds })
}
@@ -0,0 +1,413 @@
<script setup lang="ts">
// AI 规则编辑表:可完整编辑规则建议,编辑后经后端接口实时重算去重并自动增删勾选
import { ref, computed, watch, nextTick, h, onBeforeUnmount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, NSpace, NDataTable, NInput, NInputNumber, NSelect, NTag } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { checkRuleDuplicates } from '../api/ai'
import type { AiRuleApplyItem, AiRuleSuggestion, AiNewTag } from '../api/ai'
import { renderCategoryLabel } from '../utils/render'
/** 分类下拉选项 */
interface CategoryOption {
label: string
value: number
type: string
icon?: string
}
/** 标签下拉选项 */
interface TagOption {
label: string
value: number
}
/** 可编辑规则行:附加本地 key 与 AI 新建标签选项(含颜色) */
type AiRuleRow = AiRuleSuggestion & { key: number; newTagOptions: AiNewTag[] }
const props = defineProps<{
suggestions: AiRuleSuggestion[]
categories: CategoryOption[]
tags: TagOption[]
}>()
const emit = defineEmits<{
(e: 'update:checkedRules', rules: AiRuleSuggestion[]): void
}>()
const message = useMessage()
const { t } = useI18n()
// 新建分类/标签在下拉中的哨兵值
const NEW_CATEGORY = '__new_category__'
const NEW_TAG_PREFIX = '__new_tag__:'
// 编辑后的规则行
const rows = ref<AiRuleRow[]>([])
// 表格勾选的行 key(重复项不可选)
const checkedKeys = ref<number[]>([])
// 重算去重时的加载态
const checking = ref(false)
// 记录重算前的重复状态,用于「重复→不重复」自动勾选
const prevDuplicate = new Map<number, boolean>()
// 抑制标志:重算写回重复标记时避免再次触发去重
let suppress = false
let dedupTimer: ReturnType<typeof setTimeout> | null = null
// 收支类型下拉选项
const typeOptions = computed(() => [
{ label: t('rule.typeAny'), value: '' },
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
/** 将规则建议规范化为可编辑行 */
function toRow(rule: AiRuleSuggestion, index: number): AiRuleRow {
const newTags = (rule.newTags || []).map((tag) => ({ ...tag }))
return {
...rule,
key: index,
tagIds: [...(rule.tagIds || [])],
newTags,
newTagOptions: newTags.map((tag) => ({ ...tag })),
}
}
/** 规则行转接口载荷 */
function toItem(row: AiRuleRow): AiRuleApplyItem {
return {
name: row.name,
enabled: true,
type: row.type,
keyword: row.keyword,
minAmount: row.minAmount,
maxAmount: row.maxAmount,
categoryId: row.categoryId,
newCategoryName: row.newCategoryName,
newCategoryIcon: row.newCategoryIcon,
tagIds: row.tagIds,
newTags: row.newTags,
}
}
/** 去除本地字段,还原为待创建的规则建议 */
function stripKey(row: AiRuleRow): AiRuleSuggestion {
const { key, newTagOptions, ...rest } = row
void key
void newTagOptions
return rest
}
/** 派发当前勾选的规则 */
function emitChecked() {
const rules = rows.value.filter((row) => !row.duplicate && checkedKeys.value.includes(row.key)).map(stripKey)
emit('update:checkedRules', rules)
}
/** 初始化编辑行与默认勾选(非重复项) */
function initRows(list: AiRuleSuggestion[]) {
if (dedupTimer) {
clearTimeout(dedupTimer)
dedupTimer = null
}
suppress = true
rows.value = (list || []).map((rule, index) => toRow(rule, index))
prevDuplicate.clear()
rows.value.forEach((row) => prevDuplicate.set(row.key, !!row.duplicate))
checkedKeys.value = rows.value.filter((row) => !row.duplicate).map((row) => row.key)
emitChecked()
nextTick(() => {
suppress = false
})
}
/** 任一可编辑字段变化后,防抖重算去重 */
function scheduleDedup() {
if (dedupTimer) clearTimeout(dedupTimer)
dedupTimer = setTimeout(runDedup, 300)
}
/** 调用后端重算去重并同步勾选 */
async function runDedup() {
if (rows.value.length === 0) return
checking.value = true
try {
const res: any = await checkRuleDuplicates(rows.value.map(toItem))
const flags: boolean[] = res.data || []
rows.value.forEach((row, index) => {
row.duplicate = !!flags[index]
})
syncSelection()
emitChecked()
} catch (e: any) {
message.error(e.message || t('ai.rule.checkFailed'))
} finally {
checking.value = false
}
}
/** 依据重算结果调整勾选:重复项取消;重复→不重复的自动勾选 */
function syncSelection() {
const next = new Set(checkedKeys.value)
rows.value.forEach((row) => {
const wasDuplicate = prevDuplicate.get(row.key) ?? false
if (row.duplicate) {
next.delete(row.key)
} else if (wasDuplicate) {
next.add(row.key)
}
prevDuplicate.set(row.key, !!row.duplicate)
})
checkedKeys.value = Array.from(next)
}
/** 分类下拉当前值 */
function categoryValue(row: AiRuleRow) {
if (row.categoryId != null) return row.categoryId
if (row.newCategoryName) return NEW_CATEGORY
return null
}
/** 分类下拉选项:按当前类型过滤,并附加 AI 建议的新建分类 */
function categoryOptionsFor(row: AiRuleRow) {
const options: { label: string; value: number | string; icon?: string }[] = props.categories
.filter((category) => !row.type || category.type === row.type)
.map((category) => ({ label: category.label, value: category.value, icon: category.icon }))
if (row.newCategoryName) {
options.push({
label: `${t('ai.rule.newPrefix')}${row.newCategoryName}`,
value: NEW_CATEGORY,
icon: row.newCategoryIcon || '',
})
}
return options
}
/** 分类变更:选现有分类则写入 ID 并清除新建,选哨兵则保留新建 */
function handleCategoryChange(row: AiRuleRow, value: number | string | null) {
if (value === NEW_CATEGORY) {
row.categoryId = null
return
}
row.categoryId = typeof value === 'number' ? value : null
row.newCategoryName = null
row.newCategoryIcon = null
}
/** 标签下拉当前值 */
function tagValue(row: AiRuleRow) {
const ids = (row.tagIds || []).map((id) => id)
const news = (row.newTags || []).map((tag) => NEW_TAG_PREFIX + tag.name)
return [...ids, ...news]
}
/** 标签下拉选项:现有标签 + AI 建议的新建标签 */
function tagOptionsFor(row: AiRuleRow) {
const options: { label: string; value: number | string }[] = props.tags.map((tag) => ({
label: tag.label,
value: tag.value,
}))
;(row.newTagOptions || []).forEach((tag) => {
options.push({ label: `${t('ai.rule.newPrefix')}${tag.name}`, value: NEW_TAG_PREFIX + tag.name })
})
return options
}
/** 标签变更:区分现有标签 ID 与新建标签哨兵 */
function handleTagsChange(row: AiRuleRow, value: (number | string)[]) {
row.tagIds = value.filter((item): item is number => typeof item === 'number')
row.newTags = value
.filter((item): item is string => typeof item === 'string' && item.startsWith(NEW_TAG_PREFIX))
.map((item) => {
const name = item.slice(NEW_TAG_PREFIX.length)
const option = (row.newTagOptions || []).find((tag) => tag.name === name)
return { name, color: option?.color || '#2080f0' }
})
}
/** 类型变更:清理与新类型不一致的现有分类 */
function handleTypeChange(row: AiRuleRow, value: string) {
row.type = value || null
if (row.categoryId != null) {
const category = props.categories.find((item) => item.value === row.categoryId)
if (category && row.type && category.type !== row.type) {
row.categoryId = null
}
}
}
// 规则建议变化时重建编辑行
watch(
() => props.suggestions,
(list) => initRows(list),
{ immediate: true },
)
// 仅参与去重的字段变化后重算(名称等不影响签名的字段不触发)
watch(
() =>
rows.value
.map((row) =>
[
row.type,
row.keyword,
row.minAmount,
row.maxAmount,
row.categoryId,
row.newCategoryName,
(row.tagIds || []).join(','),
(row.newTags || []).map((tag) => tag.name).join(','),
].join('|'),
)
.join('::'),
() => {
if (!suppress) scheduleDedup()
},
)
// 勾选变化时派发
watch(checkedKeys, () => emitChecked())
onBeforeUnmount(() => {
if (dedupTimer) clearTimeout(dedupTimer)
})
/** 编辑表列 */
const columns = computed<DataTableColumns<AiRuleRow>>(() => [
{ type: 'selection', disabled: (row) => !!row.duplicate },
{
title: t('common.name'),
key: 'name',
width: 220,
render: (row) =>
h(
NSpace,
{ size: 4, align: 'center', wrap: false },
{
default: () => [
h(NInput, {
value: row.name,
size: 'small',
onUpdateValue: (value: string) => {
row.name = value
},
}),
row.duplicate
? h(NTag, { size: 'small', type: 'warning' }, { default: () => t('ai.rule.duplicate') })
: null,
],
},
),
},
{
title: t('common.type'),
key: 'type',
width: 110,
render: (row) =>
h(NSelect, {
value: row.type || '',
size: 'small',
options: typeOptions.value,
onUpdateValue: (value: string) => handleTypeChange(row, value),
}),
},
{
title: t('rule.keyword'),
key: 'keyword',
width: 150,
render: (row) =>
h(NInput, {
value: row.keyword || '',
size: 'small',
placeholder: '—',
onUpdateValue: (value: string) => {
row.keyword = value || null
},
}),
},
{
title: t('rule.amountRange'),
key: 'amount',
width: 200,
render: (row) =>
h(
NSpace,
{ size: 4, align: 'center', wrap: false },
{
default: () => [
h(NInputNumber, {
value: row.minAmount,
size: 'small',
min: 0,
precision: 2,
placeholder: t('common.min'),
style: 'width: 84px',
onUpdateValue: (value: number | null) => {
row.minAmount = value
},
}),
h('span', { style: { color: '#999' } }, '~'),
h(NInputNumber, {
value: row.maxAmount,
size: 'small',
min: 0,
precision: 2,
placeholder: t('common.max'),
style: 'width: 84px',
onUpdateValue: (value: number | null) => {
row.maxAmount = value
},
}),
],
},
),
},
{
title: t('rule.targetCategory'),
key: 'category',
width: 170,
render: (row) =>
h(NSelect, {
value: categoryValue(row),
size: 'small',
options: categoryOptionsFor(row),
renderLabel: renderCategoryLabel,
filterable: true,
clearable: true,
placeholder: t('rule.notSetCategory'),
onUpdateValue: (value: number | string | null) => handleCategoryChange(row, value),
}),
},
{
title: t('rule.targetTags'),
key: 'tags',
width: 200,
render: (row) =>
h(NSelect, {
value: tagValue(row),
size: 'small',
multiple: true,
filterable: true,
clearable: true,
maxTagCount: 2,
options: tagOptionsFor(row),
placeholder: t('rule.notSetTag'),
onUpdateValue: (value: (number | string)[]) => handleTagsChange(row, value),
}),
},
{ title: t('ai.rule.reason'), key: 'reason', minWidth: 150, ellipsis: { tooltip: true } },
])
</script>
<template>
<NDataTable
v-model:checked-row-keys="checkedKeys"
:columns="columns"
:data="rows"
:loading="checking"
:row-key="(row: AiRuleRow) => row.key"
:max-height="420"
:scroll-x="1200"
size="small"
/>
</template>
+42 -104
View File
@@ -1,19 +1,34 @@
<script setup lang="ts">
// AI 规则建议弹窗:展示基于勾选账单生成的规则建议,勾选后创建规则,并可选同时应用到勾选账单
import { ref, computed, watch, h } from 'vue'
// AI 规则建议弹窗:展示基于勾选账单生成的规则建议,可编辑后勾选创建,并可选同时应用到勾选账单
import { ref, computed } 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 { NButton, NModal, NSpace, NSwitch, NText, NAlert } from 'naive-ui'
import type { AiRuleSuggestion } from '../api/ai'
import AiRuleEditorTable from './AiRuleEditorTable.vue'
import { useStringPreference } from '../composables/usePreference'
import { STORAGE_KEYS } from '../utils/storageKeys'
/** 规则建议行:附加本地 key 供表格选择使用 */
type AiRuleRow = AiRuleSuggestion & { key: number }
/** 分类下拉选项 */
interface CategoryOption {
label: string
value: number
type: string
icon?: string
}
/** 标签下拉选项 */
interface TagOption {
label: string
value: number
}
const props = defineProps<{
show: boolean
suggestions: AiRuleSuggestion[]
targetCount: number
applying: boolean
categories: CategoryOption[]
tags: TagOption[]
}>()
const emit = defineEmits<{
@@ -28,118 +43,41 @@ const showModel = computed({
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)
// 勾选的规则(由编辑表回传,已排除重复项)
const selectedRules = ref<AiRuleSuggestion[]>([])
// 打开时重置:默认勾选非重复项,应用开关默认关闭
watch(
() => props.show,
(visible) => {
if (!visible) return
checkedKeys.value = rows.value.filter((row) => !row.duplicate).map((row) => row.key)
applyToSelection.value = false
// 是否同时应用到勾选账单:默认开启并记录到本机偏好
const applyToSelectionPref = useStringPreference(STORAGE_KEYS.transactionAiRuleApplyToSelection, 'true', {
validate: (value) => value === 'true' || value === 'false',
})
const applyToSelection = computed({
get: () => applyToSelectionPref.value === 'true',
set: (value: boolean) => {
applyToSelectionPref.value = value ? 'true' : '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 handleCheckedRules(rules: AiRuleSuggestion[]) {
selectedRules.value = rules
}
/** 提交:传出勾选规则与是否同时应用到账单 */
function handleSubmit() {
if (selectedRules.value.length === 0) return
emit('submit', {
rules: selectedRules.value.map(stripKey),
applyToSelection: applyToSelection.value,
})
emit('submit', { rules: selectedRules.value, applyToSelection: applyToSelection.value })
}
</script>
<template>
<NModal v-model:show="showModel" preset="card" :title="t('ai.rule.title')" style="width: min(1100px, 94vw)">
<NModal v-model:show="showModel" preset="card" :title="t('ai.rule.title')" style="width: min(1200px, 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"
<AiRuleEditorTable
:suggestions="suggestions"
:categories="categories"
:tags="tags"
@update:checked-rules="handleCheckedRules"
/>
<NAlert v-if="applyToSelection" type="warning" :show-icon="true">
{{ t('ai.rule.applyToSelectionNote') }}
@@ -0,0 +1,166 @@
<script setup lang="ts">
// 规则应用预览弹窗:展示所选规则命中的历史账单及将应用的目标分类与标签,确认后一键应用
import { ref, computed, watch, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, NButton, NModal, NSpace, NDataTable, NAlert, NText, NTag } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getRuleApplyPreview, applyRulesToMatchedTransactions } from '../api/rule'
import type { RuleApplyPreview, RuleMatchedTransaction } from '../api/rule'
import { getChannelLabel } from '../utils/channel'
const props = defineProps<{
show: boolean
ruleIds: number[]
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'applied', result: { matchedCount: number; updatedCount: number; tagAddedCount: number }): void
}>()
const message = useMessage()
const { t } = useI18n()
const showModel = computed({
get: () => props.show,
set: (value: boolean) => emit('update:show', value),
})
const loading = ref(false)
const applying = ref(false)
const preview = ref<RuleApplyPreview | null>(null)
// 打开时加载预览数据
watch(
() => props.show,
async (visible) => {
if (!visible) return
preview.value = null
loading.value = true
try {
const res: any = await getRuleApplyPreview(props.ruleIds)
preview.value = res.data || null
} catch (e: any) {
message.error(e.message || t('rule.previewFailed'))
} finally {
loading.value = false
}
},
)
/** 渲染标签名列表小标签 */
function renderTags(names: string[]) {
if (!names || names.length === 0) {
return h('span', { style: { color: '#ccc' } }, '—')
}
return h(
NSpace,
{ size: 4 },
{ default: () => names.map((name) => h(NTag, { size: 'small' }, { default: () => name })) },
)
}
/** 预览明细表格列 */
const columns = computed<DataTableColumns<RuleMatchedTransaction>>(() => [
{
title: t('rule.applyColTime'),
key: 'transactionTime',
width: 140,
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '—'),
},
{
title: t('rule.applyColType'),
key: 'type',
width: 70,
render: (row) => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')),
},
{ title: t('rule.applyColAmount'), key: 'amount', width: 100 },
{
title: t('rule.applyColChannel'),
key: 'channel',
width: 100,
render: (row) => getChannelLabel(row.channel),
},
{ title: t('rule.applyColRemark'), key: 'remark', minWidth: 160, ellipsis: { tooltip: true } },
{
title: t('rule.applyColCurrentCategory'),
key: 'currentCategoryName',
width: 130,
render: (row) => row.currentCategoryName || t('rule.applyNone'),
},
{
title: t('rule.applyColCurrentTags'),
key: 'currentTagNames',
width: 160,
render: (row) => renderTags(row.currentTagNames),
},
{
title: t('rule.applyColTargetCategory'),
key: 'targetCategoryName',
width: 130,
render: (row) => row.targetCategoryName || t('rule.applyNone'),
},
{
title: t('rule.applyColTargetTags'),
key: 'targetTagNames',
width: 160,
render: (row) => renderTags(row.targetTagNames),
},
])
const matchedCount = computed(() => preview.value?.matchedCount ?? 0)
/** 确认应用:调用接口并按结果提示 */
async function handleApply() {
applying.value = true
try {
const res: any = await applyRulesToMatchedTransactions(props.ruleIds)
const result = res.data || { matchedCount: 0, updatedCount: 0, tagAddedCount: 0 }
message.success(t('rule.applySuccess', { updated: result.updatedCount, tags: result.tagAddedCount }))
showModel.value = false
emit('applied', result)
} catch (e: any) {
message.error(e.message || t('rule.applyFailed'))
} finally {
applying.value = false
}
}
</script>
<template>
<NModal v-model:show="showModel" preset="card" :title="t('rule.applyTitle')" style="width: min(1200px, 94vw)">
<NSpace vertical :size="12">
<template v-if="preview">
<NAlert v-if="matchedCount === 0" type="warning" :show-icon="true">{{ t('rule.applyNoMatch') }}</NAlert>
<NAlert v-else type="info" :show-icon="true">
{{
t('rule.applySummary', {
matched: preview.matchedCount,
category: preview.categoryChangeCount,
tag: preview.tagChangeCount,
})
}}
</NAlert>
<NAlert v-if="preview.truncated" type="warning" :show-icon="true">
{{ t('rule.applyTruncated', { count: preview.transactions.length }) }}
</NAlert>
</template>
<NText depth="3" style="font-size: 12px">{{ t('rule.applyNote') }}</NText>
<NDataTable
:columns="columns"
:data="preview?.transactions || []"
:loading="loading"
:max-height="420"
:scroll-x="1250"
:bordered="false"
size="small"
/>
<NSpace justify="end">
<NButton @click="showModel = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="applying" :disabled="loading || matchedCount === 0" @click="handleApply">
{{ t('rule.applyConfirm') }}
</NButton>
</NSpace>
</NSpace>
</NModal>
</template>
+55
View File
@@ -1139,6 +1139,56 @@ export default {
en: 'Deleted {count} rule(s)',
ja: '{count} 件のルールを削除しました',
},
apply: { zh: '应用', en: 'Apply', ja: '適用' },
batchApply: { zh: '应用匹配账单', en: 'Apply to Matched', ja: '一致明細に適用' },
batchApplyNone: {
zh: '请先选择要应用的规则',
en: 'Please select rules to apply',
ja: '適用するルールを選択してください',
},
applyTitle: {
zh: '应用规则到匹配账单',
en: 'Apply Rules to Matched Bills',
ja: 'ルールを一致明細に適用',
},
applySummary: {
zh: '命中 {matched} 条账单,将更新 {category} 条分类、为 {tag} 条追加标签',
en: 'Matched {matched} bill(s); will update {category} category/categories and add tags to {tag} bill(s)',
ja: '{matched} 件の明細が一致、{category} 件のカテゴリを更新し {tag} 件にタグを追加します',
},
applyNoMatch: {
zh: '未找到匹配的账单',
en: 'No matched bills found',
ja: '一致する明細が見つかりません',
},
applyTruncated: {
zh: '命中账单较多,仅展示前 {count} 条',
en: 'Too many matches; showing only the first {count}',
ja: '一致が多いため先頭 {count} 件のみ表示します',
},
applyNote: {
zh: '应用将覆盖命中账单的分类(类型一致时),并追加规则标签,不会删除账单已有标签。',
en: 'Applying overwrites the category of matched bills (when the type matches) and appends rule tags without removing existing ones.',
ja: '適用すると一致明細のカテゴリ(種類が一致する場合)を上書きし、既存タグを残したままルールのタグを追加します。',
},
applyColTime: { zh: '时间', en: 'Time', ja: '日時' },
applyColType: { zh: '类型', en: 'Type', ja: '種類' },
applyColAmount: { zh: '金额', en: 'Amount', ja: '金額' },
applyColChannel: { zh: '渠道', en: 'Channel', ja: 'チャネル' },
applyColRemark: { zh: '备注', en: 'Remark', ja: '備考' },
applyColCurrentCategory: { zh: '当前分类', en: 'Current Category', ja: '現在のカテゴリ' },
applyColCurrentTags: { zh: '当前标签', en: 'Current Tags', ja: '現在のタグ' },
applyColTargetCategory: { zh: '目标分类', en: 'Target Category', ja: '対象カテゴリ' },
applyColTargetTags: { zh: '目标标签', en: 'Target Tags', ja: '対象タグ' },
applyNone: { zh: '无', en: 'None', ja: 'なし' },
applyConfirm: { zh: '确认应用', en: 'Apply', ja: '適用する' },
applySuccess: {
zh: '已更新 {updated} 条账单,新增 {tags} 个标签',
en: 'Updated {updated} bill(s), added {tags} tag(s)',
ja: '{updated} 件の明細を更新、{tags} 件のタグを追加しました',
},
applyFailed: { zh: '应用失败', en: 'Failed to apply', ja: '適用に失敗しました' },
previewFailed: { zh: '加载预览失败', en: 'Failed to load preview', ja: 'プレビューの読み込みに失敗しました' },
},
// 账单导入
@@ -1435,6 +1485,11 @@ export default {
failed: { zh: 'AI 规则生成失败', en: 'Failed to generate AI rules', ja: 'AI ルールの生成に失敗しました' },
applyFailed: { zh: '创建规则失败', en: 'Failed to create rules', ja: 'ルールの作成に失敗しました' },
duplicate: { zh: '已存在', en: 'Exists', ja: '既存' },
checkFailed: {
zh: '重复检查失败,请稍后重试',
en: 'Failed to check duplicates, please retry',
ja: '重複チェックに失敗しました。後でもう一度お試しください',
},
allDuplicate: {
zh: '生成的规则均已存在,无需创建',
en: 'All generated rules already exist',
+2
View File
@@ -29,6 +29,8 @@ export const STORAGE_KEYS = {
transactionAiScope: 'ledgerly.transaction.aiScope',
// 收支记录 AI 记账方式
transactionAiRecordTab: 'ledgerly.transaction.aiRecordTab',
// 收支记录 AI 生成规则时是否同时应用到勾选账单(默认开启)
transactionAiRuleApplyToSelection: 'ledgerly.transaction.aiRuleApplyToSelection',
// 收支记录备注关键词历史
transactionRemarkHistory: 'ledgerly.transaction.remarkHistory',
// 收支记录筛选偏好(收支记录页与统计面板共用)
+62 -117
View File
@@ -31,6 +31,8 @@ import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render'
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
import { useTablePageSize } from '../composables/useTablePageSize'
import RuleApplyPreviewModal from '../components/RuleApplyPreviewModal.vue'
import AiRuleEditorTable from '../components/AiRuleEditorTable.vue'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
@@ -70,10 +72,11 @@ const total = ref(0)
const searchKeyword = ref('')
const filterEnabled = ref<string | null>(null) // 'ON' | 'OFF' | null
const filterType = ref<string | null>(null)
const checkedRuleIds = ref<number[]>([]) // 表格多选选中的规则ID(用于批量删除)
const checkedRuleIds = ref<number[]>([]) // 表格多选选中的规则ID(用于批量删除/批量应用)
/** AI 规则建议行(带本地 key 供表格选择使用) */
type AiRuleRow = AiRuleSuggestion & { key: number }
// 规则应用预览弹窗状态
const showApplyModal = ref(false)
const applyRuleIds = ref<number[]>([])
/** 已打标签的候选记录 */
interface CandidateRow {
@@ -101,8 +104,8 @@ const aiCandidateTagIds = ref<number[]>([])
const aiRuleLoading = ref(false)
const aiRuleApplying = ref(false)
const aiRuleSummary = ref('')
const aiRuleRows = ref<AiRuleRow[]>([])
const checkedAiRuleKeys = ref<number[]>([])
const aiRuleRows = ref<AiRuleSuggestion[]>([])
const aiSelectedRules = ref<AiRuleSuggestion[]>([])
// 表单数据
const form = ref<{
@@ -265,10 +268,15 @@ const columns = computed<DataTableColumns<RuleItem>>(() => [
{
title: t('common.operation'),
key: 'actions',
width: 150,
width: 220,
render: (row) =>
h(NSpace, null, {
default: () => [
h(
NButton,
{ size: 'small', type: 'primary', onClick: () => handleApplyRule(row) },
{ default: () => t('rule.apply') },
),
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
h(
NButton,
@@ -280,100 +288,6 @@ const columns = computed<DataTableColumns<RuleItem>>(() => [
},
])
/** AI 规则建议表格行键 */
function aiRuleRowKey(row: AiRuleRow) {
return row.key
}
/** AI 规则建议表格列:名称与关键词可编辑,分类/标签展示 AI 建议 */
const aiRuleColumns = computed<DataTableColumns<AiRuleRow>>(() => [
// 与已有规则重复的条目禁止勾选(禁止导入)
{ type: 'selection', disabled: (row) => !!row.duplicate },
{
title: t('rule.name'),
key: 'name',
width: 240,
render: (row) =>
h(
NSpace,
{ size: 4, align: 'center', wrap: false },
{
default: () => [
h(NInput, {
value: row.name,
size: 'small',
disabled: !!row.duplicate,
onUpdateValue: (v: string) => {
row.name = v
},
}),
row.duplicate
? h(
NTag,
{ size: 'small', type: 'warning' },
{
default: () => t('ai.rule.duplicate'),
},
)
: null,
],
},
),
},
{
title: t('rule.keyword'),
key: 'keyword',
width: 160,
render: (row) =>
h(NInput, {
value: row.keyword || '',
size: 'small',
placeholder: '—',
disabled: !!row.duplicate,
onUpdateValue: (v: string) => {
row.keyword = v || null
},
}),
},
{
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',
width: 150,
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',
width: 170,
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',
render: (row) => row.reason || '',
},
])
/** 候选记录表格行键 */
function aiCandidateRowKey(row: CandidateRow) {
return row.id
@@ -494,7 +408,7 @@ async function handleAiRuleOpen() {
aiStep.value = 'select'
aiRuleRows.value = []
aiRuleSummary.value = ''
checkedAiRuleKeys.value = []
aiSelectedRules.value = []
aiCandidateRows.value = []
checkedCandidateKeys.value = []
aiCandidatePage.value = 1
@@ -557,10 +471,8 @@ async function handleAiRuleGenerate() {
const res: any = await suggestRulesByAi(checkedCandidateKeys.value)
const data = res.data || {}
aiRuleSummary.value = data.summary || ''
aiRuleRows.value = (data.rules || []).map((rule: AiRuleSuggestion, index: number) => ({ ...rule, key: index }))
// 与已有规则重复的条目不勾选
checkedAiRuleKeys.value = aiRuleRows.value.filter((row) => !row.duplicate).map((row) => row.key)
if (aiRuleRows.value.length > 0 && checkedAiRuleKeys.value.length === 0) {
aiRuleRows.value = data.rules || []
if (aiRuleRows.value.length > 0 && aiRuleRows.value.every((row: AiRuleSuggestion) => row.duplicate)) {
message.warning(t('ai.rule.allDuplicate'))
}
aiStep.value = 'suggest'
@@ -571,9 +483,14 @@ async function handleAiRuleGenerate() {
}
}
/** 接收规则编辑表勾选的规则 */
function handleAiCheckedRules(rules: AiRuleSuggestion[]) {
aiSelectedRules.value = rules
}
/** 应用勾选的 AI 规则建议:批量创建并刷新列表(跳过与已有规则重复项) */
async function handleAiRuleApply() {
const selected = aiRuleRows.value.filter((row) => !row.duplicate && checkedAiRuleKeys.value.includes(row.key))
const selected = aiSelectedRules.value
if (selected.length === 0) {
message.warning(t('ai.rule.selectRequired'))
return
@@ -819,6 +736,27 @@ function handleBatchDelete() {
})
}
/** 打开单条规则的应用预览弹窗 */
function handleApplyRule(row: RuleItem) {
applyRuleIds.value = [row.id]
showApplyModal.value = true
}
/** 打开选中规则的应用预览弹窗(批量) */
function handleApplySelected() {
if (checkedRuleIds.value.length === 0) {
message.warning(t('rule.batchApplyNone'))
return
}
applyRuleIds.value = [...checkedRuleIds.value]
showApplyModal.value = true
}
/** 规则应用完成后刷新列表 */
function handleApplied() {
fetchData()
}
// 收支类型变化时,若已选分类类型不匹配则清空,避免保存非法组合
watch(
() => form.value.type,
@@ -848,6 +786,9 @@ onMounted(() => {
<h2 style="margin: 0">{{ t('rule.title') }}</h2>
<NSpace>
<NButton v-if="aiEnabled" @click="handleAiRuleOpen">{{ t('ai.rule.entry') }}</NButton>
<NButton :disabled="checkedRuleIds.length === 0" @click="handleApplySelected">
{{ t('rule.batchApply') }}
</NButton>
<NButton type="error" :disabled="checkedRuleIds.length === 0" @click="handleBatchDelete">
{{ t('rule.batchDelete') }}
</NButton>
@@ -891,7 +832,7 @@ onMounted(() => {
:data="rules"
:loading="loading"
:bordered="false"
:scroll-x="950"
:scroll-x="1020"
:row-key="(row: RuleItem) => row.id"
:pagination="pagination"
@update:page="handlePageChange"
@@ -1054,24 +995,28 @@ onMounted(() => {
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 12px">
{{ aiRuleSummary || t('ai.rule.hint') }}
</NText>
<NDataTable
v-model:checked-row-keys="checkedAiRuleKeys"
:columns="aiRuleColumns"
:data="aiRuleRows"
:loading="aiRuleLoading"
:row-key="aiRuleRowKey"
:max-height="420"
:scroll-x="1100"
size="small"
<AiRuleEditorTable
:suggestions="aiRuleRows"
:categories="categories"
:tags="tagOptions"
@update:checked-rules="handleAiCheckedRules"
/>
<NSpace justify="end" style="margin-top: 16px">
<NButton @click="aiStep = 'select'">{{ t('ai.rule.back') }}</NButton>
<NButton @click="showAiRuleModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRuleApplying" :disabled="aiRuleLoading" @click="handleAiRuleApply">
<NButton
type="primary"
:loading="aiRuleApplying"
:disabled="aiRuleLoading || aiSelectedRules.length === 0"
@click="handleAiRuleApply"
>
{{ t('ai.rule.apply') }}
</NButton>
</NSpace>
</template>
</NModal>
<!-- 规则应用预览弹窗:命中账单预览与一键应用 -->
<RuleApplyPreviewModal v-model:show="showApplyModal" :rule-ids="applyRuleIds" @applied="handleApplied" />
</div>
</template>
+5
View File
@@ -326,6 +326,9 @@ const categoryFilterOptions = computed(() => [
// 标签筛选项
const tagFilterOptions = computed(() => allTags.value.map((tag) => ({ label: tag.name, value: tag.id })))
// AI 规则编辑用的标签选项(与筛选一致,供规则弹窗编辑分类与标签)
const ruleTagOptions = computed(() => allTags.value.map((tag) => ({ label: tag.name, value: tag.id })))
// 标签筛选下拉项:额外提供「无标签」选项(仅筛选使用,编辑/批量选择不包含)
const tagFilterSelectOptions = computed(() => [
{ label: t('transaction.untagged'), value: 0 },
@@ -2297,6 +2300,8 @@ onMounted(() => {
:suggestions="aiRuleSuggestions"
:target-count="checkedTransactionIds.length"
:applying="aiRuleApplying"
:categories="categories"
:tags="ruleTagOptions"
@submit="handleAiRuleSubmit"
/>