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 }) 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[]) { export function applyRulesToTransactions(transactionIds: number[], rules: AiRuleApplyItem[]) {
return request.post('/ai/rule/apply-transactions', { transactionIds, rules }) 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) { export function toggleRuleEnabled(id: number, enabled: boolean) {
return request.put(`/rule/${id}/enabled`, { enabled }) 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"> <script setup lang="ts">
// AI 规则建议弹窗:展示基于勾选账单生成的规则建议,勾选后创建规则,并可选同时应用到勾选账单 // AI 规则建议弹窗:展示基于勾选账单生成的规则建议,可编辑后勾选创建,并可选同时应用到勾选账单
import { ref, computed, watch, h } from 'vue' import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { NButton, NModal, NSpace, NDataTable, NTag, NSwitch, NText, NAlert } from 'naive-ui' import { NButton, NModal, NSpace, NSwitch, NText, NAlert } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import type { AiRuleSuggestion } from '../api/ai' 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<{ const props = defineProps<{
show: boolean show: boolean
suggestions: AiRuleSuggestion[] suggestions: AiRuleSuggestion[]
targetCount: number targetCount: number
applying: boolean applying: boolean
categories: CategoryOption[]
tags: TagOption[]
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -28,118 +43,41 @@ const showModel = computed({
set: (value: boolean) => emit('update:show', value), set: (value: boolean) => emit('update:show', value),
}) })
// 规则建议行(带本地 key) // 勾选的规则(由编辑表回传,已排除重复项)
const rows = computed<AiRuleRow[]>(() => props.suggestions.map((rule, index) => ({ ...rule, key: index }))) const selectedRules = ref<AiRuleSuggestion[]>([])
// 已勾选规则行 key
const checkedKeys = ref<number[]>([])
// 是否同时把规则应用到当前勾选的账单
const applyToSelection = ref(false)
// 打开时重置:默认勾选非重复项,应用开关默认关闭 // 是否同时应用到勾选账单:默认开启并记录到本机偏好
watch( const applyToSelectionPref = useStringPreference(STORAGE_KEYS.transactionAiRuleApplyToSelection, 'true', {
() => props.show, validate: (value) => value === 'true' || value === 'false',
(visible) => { })
if (!visible) return const applyToSelection = computed({
checkedKeys.value = rows.value.filter((row) => !row.duplicate).map((row) => row.key) get: () => applyToSelectionPref.value === 'true',
applyToSelection.value = false set: (value: boolean) => {
applyToSelectionPref.value = value ? 'true' : 'false'
}, },
) })
/** 规则建议表格列(只读展示) */ /** 接收编辑表的勾选规则 */
const columns = computed<DataTableColumns<AiRuleRow>>(() => [ function handleCheckedRules(rules: AiRuleSuggestion[]) {
{ type: 'selection', disabled: (row) => !!row.duplicate }, selectedRules.value = rules
{
title: t('common.name'),
key: 'name',
minWidth: 160,
render: (row) =>
h(
NSpace,
{ size: 4, align: 'center', wrap: false },
{
default: () => [
h('span', null, row.name),
row.duplicate
? h(NTag, { size: 'small', type: 'warning' }, { default: () => t('ai.rule.duplicate') })
: null,
],
},
),
},
{
title: t('rule.keyword'),
key: 'keyword',
width: 140,
render: (row) => row.keyword || '—',
},
{
title: t('common.type'),
key: 'type',
width: 80,
render: (row) => (row.type ? (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) : '—'),
},
{
title: t('rule.targetCategory'),
key: 'category',
minWidth: 130,
render: (row) => {
if (row.categoryName) return row.categoryName
if (row.newCategoryName) return `${t('ai.rule.newPrefix')}${row.newCategoryName}`
return '—'
},
},
{
title: t('rule.targetTags'),
key: 'tags',
minWidth: 150,
render: (row) => {
const names = [
...(row.tagNames || []),
...(row.newTags || []).map((tag) => `${t('ai.rule.newPrefix')}${tag.name}`),
]
return names.length > 0 ? names.join('、') : '—'
},
},
{
title: t('ai.rule.reason'),
key: 'reason',
minWidth: 160,
ellipsis: { tooltip: true },
},
])
// 已勾选规则(重复项不可选)
const selectedRules = computed(() => rows.value.filter((row) => !row.duplicate && checkedKeys.value.includes(row.key)))
/** 去除本地 key,还原为规则建议对象 */
function stripKey(row: AiRuleRow): AiRuleSuggestion {
const { key, ...rest } = row
void key
return rest
} }
/** 提交:传出勾选规则与是否同时应用到账单 */ /** 提交:传出勾选规则与是否同时应用到账单 */
function handleSubmit() { function handleSubmit() {
if (selectedRules.value.length === 0) return if (selectedRules.value.length === 0) return
emit('submit', { emit('submit', { rules: selectedRules.value, applyToSelection: applyToSelection.value })
rules: selectedRules.value.map(stripKey),
applyToSelection: applyToSelection.value,
})
} }
</script> </script>
<template> <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"> <NSpace vertical :size="12">
<NText depth="3" style="font-size: 12px">{{ t('ai.rule.hint') }}</NText> <NText depth="3" style="font-size: 12px">{{ t('ai.rule.hint') }}</NText>
<NDataTable <AiRuleEditorTable
v-model:checked-row-keys="checkedKeys" :suggestions="suggestions"
:columns="columns" :categories="categories"
:data="rows" :tags="tags"
:max-height="420" @update:checked-rules="handleCheckedRules"
:scroll-x="900"
:row-key="(row: AiRuleRow) => row.key"
size="small"
/> />
<NAlert v-if="applyToSelection" type="warning" :show-icon="true"> <NAlert v-if="applyToSelection" type="warning" :show-icon="true">
{{ t('ai.rule.applyToSelectionNote') }} {{ 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)', en: 'Deleted {count} rule(s)',
ja: '{count} 件のルールを削除しました', 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 ルールの生成に失敗しました' }, failed: { zh: 'AI 规则生成失败', en: 'Failed to generate AI rules', ja: 'AI ルールの生成に失敗しました' },
applyFailed: { zh: '创建规则失败', en: 'Failed to create rules', ja: 'ルールの作成に失敗しました' }, applyFailed: { zh: '创建规则失败', en: 'Failed to create rules', ja: 'ルールの作成に失敗しました' },
duplicate: { zh: '已存在', en: 'Exists', ja: '既存' }, duplicate: { zh: '已存在', en: 'Exists', ja: '既存' },
checkFailed: {
zh: '重复检查失败,请稍后重试',
en: 'Failed to check duplicates, please retry',
ja: '重複チェックに失敗しました。後でもう一度お試しください',
},
allDuplicate: { allDuplicate: {
zh: '生成的规则均已存在,无需创建', zh: '生成的规则均已存在,无需创建',
en: 'All generated rules already exist', en: 'All generated rules already exist',
+2
View File
@@ -29,6 +29,8 @@ export const STORAGE_KEYS = {
transactionAiScope: 'ledgerly.transaction.aiScope', transactionAiScope: 'ledgerly.transaction.aiScope',
// 收支记录 AI 记账方式 // 收支记录 AI 记账方式
transactionAiRecordTab: 'ledgerly.transaction.aiRecordTab', transactionAiRecordTab: 'ledgerly.transaction.aiRecordTab',
// 收支记录 AI 生成规则时是否同时应用到勾选账单(默认开启)
transactionAiRuleApplyToSelection: 'ledgerly.transaction.aiRuleApplyToSelection',
// 收支记录备注关键词历史 // 收支记录备注关键词历史
transactionRemarkHistory: 'ledgerly.transaction.remarkHistory', transactionRemarkHistory: 'ledgerly.transaction.remarkHistory',
// 收支记录筛选偏好(收支记录页与统计面板共用) // 收支记录筛选偏好(收支记录页与统计面板共用)
+62 -117
View File
@@ -31,6 +31,8 @@ import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render' import { renderCategoryLabel } from '../utils/render'
import { usePageSizeOptions } from '../composables/usePageSizeOptions' import { usePageSizeOptions } from '../composables/usePageSizeOptions'
import { useTablePageSize } from '../composables/useTablePageSize' import { useTablePageSize } from '../composables/useTablePageSize'
import RuleApplyPreviewModal from '../components/RuleApplyPreviewModal.vue'
import AiRuleEditorTable from '../components/AiRuleEditorTable.vue'
const message = useMessage() // 消息提示 const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框 const dialog = useDialog() // 对话框
@@ -70,10 +72,11 @@ const total = ref(0)
const searchKeyword = ref('') const searchKeyword = ref('')
const filterEnabled = ref<string | null>(null) // 'ON' | 'OFF' | null const filterEnabled = ref<string | null>(null) // 'ON' | 'OFF' | null
const filterType = ref<string | null>(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 { interface CandidateRow {
@@ -101,8 +104,8 @@ const aiCandidateTagIds = ref<number[]>([])
const aiRuleLoading = ref(false) const aiRuleLoading = ref(false)
const aiRuleApplying = ref(false) const aiRuleApplying = ref(false)
const aiRuleSummary = ref('') const aiRuleSummary = ref('')
const aiRuleRows = ref<AiRuleRow[]>([]) const aiRuleRows = ref<AiRuleSuggestion[]>([])
const checkedAiRuleKeys = ref<number[]>([]) const aiSelectedRules = ref<AiRuleSuggestion[]>([])
// 表单数据 // 表单数据
const form = ref<{ const form = ref<{
@@ -265,10 +268,15 @@ const columns = computed<DataTableColumns<RuleItem>>(() => [
{ {
title: t('common.operation'), title: t('common.operation'),
key: 'actions', key: 'actions',
width: 150, width: 220,
render: (row) => render: (row) =>
h(NSpace, null, { h(NSpace, null, {
default: () => [ 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, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
h( h(
NButton, 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) { function aiCandidateRowKey(row: CandidateRow) {
return row.id return row.id
@@ -494,7 +408,7 @@ async function handleAiRuleOpen() {
aiStep.value = 'select' aiStep.value = 'select'
aiRuleRows.value = [] aiRuleRows.value = []
aiRuleSummary.value = '' aiRuleSummary.value = ''
checkedAiRuleKeys.value = [] aiSelectedRules.value = []
aiCandidateRows.value = [] aiCandidateRows.value = []
checkedCandidateKeys.value = [] checkedCandidateKeys.value = []
aiCandidatePage.value = 1 aiCandidatePage.value = 1
@@ -557,10 +471,8 @@ async function handleAiRuleGenerate() {
const res: any = await suggestRulesByAi(checkedCandidateKeys.value) const res: any = await suggestRulesByAi(checkedCandidateKeys.value)
const data = res.data || {} const data = res.data || {}
aiRuleSummary.value = data.summary || '' aiRuleSummary.value = data.summary || ''
aiRuleRows.value = (data.rules || []).map((rule: AiRuleSuggestion, index: number) => ({ ...rule, key: index })) aiRuleRows.value = data.rules || []
// 与已有规则重复的条目不勾选 if (aiRuleRows.value.length > 0 && aiRuleRows.value.every((row: AiRuleSuggestion) => row.duplicate)) {
checkedAiRuleKeys.value = aiRuleRows.value.filter((row) => !row.duplicate).map((row) => row.key)
if (aiRuleRows.value.length > 0 && checkedAiRuleKeys.value.length === 0) {
message.warning(t('ai.rule.allDuplicate')) message.warning(t('ai.rule.allDuplicate'))
} }
aiStep.value = 'suggest' aiStep.value = 'suggest'
@@ -571,9 +483,14 @@ async function handleAiRuleGenerate() {
} }
} }
/** 接收规则编辑表勾选的规则 */
function handleAiCheckedRules(rules: AiRuleSuggestion[]) {
aiSelectedRules.value = rules
}
/** 应用勾选的 AI 规则建议:批量创建并刷新列表(跳过与已有规则重复项) */ /** 应用勾选的 AI 规则建议:批量创建并刷新列表(跳过与已有规则重复项) */
async function handleAiRuleApply() { async function handleAiRuleApply() {
const selected = aiRuleRows.value.filter((row) => !row.duplicate && checkedAiRuleKeys.value.includes(row.key)) const selected = aiSelectedRules.value
if (selected.length === 0) { if (selected.length === 0) {
message.warning(t('ai.rule.selectRequired')) message.warning(t('ai.rule.selectRequired'))
return 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( watch(
() => form.value.type, () => form.value.type,
@@ -848,6 +786,9 @@ onMounted(() => {
<h2 style="margin: 0">{{ t('rule.title') }}</h2> <h2 style="margin: 0">{{ t('rule.title') }}</h2>
<NSpace> <NSpace>
<NButton v-if="aiEnabled" @click="handleAiRuleOpen">{{ t('ai.rule.entry') }}</NButton> <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"> <NButton type="error" :disabled="checkedRuleIds.length === 0" @click="handleBatchDelete">
{{ t('rule.batchDelete') }} {{ t('rule.batchDelete') }}
</NButton> </NButton>
@@ -891,7 +832,7 @@ onMounted(() => {
:data="rules" :data="rules"
:loading="loading" :loading="loading"
:bordered="false" :bordered="false"
:scroll-x="950" :scroll-x="1020"
:row-key="(row: RuleItem) => row.id" :row-key="(row: RuleItem) => row.id"
:pagination="pagination" :pagination="pagination"
@update:page="handlePageChange" @update:page="handlePageChange"
@@ -1054,24 +995,28 @@ onMounted(() => {
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 12px"> <NText depth="3" style="font-size: 12px; display: block; margin-bottom: 12px">
{{ aiRuleSummary || t('ai.rule.hint') }} {{ aiRuleSummary || t('ai.rule.hint') }}
</NText> </NText>
<NDataTable <AiRuleEditorTable
v-model:checked-row-keys="checkedAiRuleKeys" :suggestions="aiRuleRows"
:columns="aiRuleColumns" :categories="categories"
:data="aiRuleRows" :tags="tagOptions"
:loading="aiRuleLoading" @update:checked-rules="handleAiCheckedRules"
:row-key="aiRuleRowKey"
:max-height="420"
:scroll-x="1100"
size="small"
/> />
<NSpace justify="end" style="margin-top: 16px"> <NSpace justify="end" style="margin-top: 16px">
<NButton @click="aiStep = 'select'">{{ t('ai.rule.back') }}</NButton> <NButton @click="aiStep = 'select'">{{ t('ai.rule.back') }}</NButton>
<NButton @click="showAiRuleModal = false">{{ t('common.cancel') }}</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') }} {{ t('ai.rule.apply') }}
</NButton> </NButton>
</NSpace> </NSpace>
</template> </template>
</NModal> </NModal>
<!-- 规则应用预览弹窗:命中账单预览与一键应用 -->
<RuleApplyPreviewModal v-model:show="showApplyModal" :rule-ids="applyRuleIds" @applied="handleApplied" />
</div> </div>
</template> </template>
+5
View File
@@ -326,6 +326,9 @@ const categoryFilterOptions = computed(() => [
// 标签筛选项 // 标签筛选项
const tagFilterOptions = computed(() => allTags.value.map((tag) => ({ label: tag.name, value: tag.id }))) 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(() => [ const tagFilterSelectOptions = computed(() => [
{ label: t('transaction.untagged'), value: 0 }, { label: t('transaction.untagged'), value: 0 },
@@ -2297,6 +2300,8 @@ onMounted(() => {
:suggestions="aiRuleSuggestions" :suggestions="aiRuleSuggestions"
:target-count="checkedTransactionIds.length" :target-count="checkedTransactionIds.length"
:applying="aiRuleApplying" :applying="aiRuleApplying"
:categories="categories"
:tags="ruleTagOptions"
@submit="handleAiRuleSubmit" @submit="handleAiRuleSubmit"
/> />
@@ -31,6 +31,7 @@ import cn.rosercode.ledgerly.dto.AiQueryRequest;
import cn.rosercode.ledgerly.dto.AiRecordRequest; import cn.rosercode.ledgerly.dto.AiRecordRequest;
import cn.rosercode.ledgerly.dto.AiRelationRequest; import cn.rosercode.ledgerly.dto.AiRelationRequest;
import cn.rosercode.ledgerly.dto.AiRuleApplyRequest; import cn.rosercode.ledgerly.dto.AiRuleApplyRequest;
import cn.rosercode.ledgerly.dto.AiRuleDuplicateCheckRequest;
import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest; import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest;
import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest; import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest;
import cn.rosercode.ledgerly.dto.AiSuggestOneRequest; import cn.rosercode.ledgerly.dto.AiSuggestOneRequest;
@@ -494,6 +495,19 @@ public class AiController {
return Result.success(aiRuleService.applyToTransactions(userId, request)); return Result.success(aiRuleService.applyToTransactions(userId, request));
} }
/**
* 校验编辑后的规则是否与已有规则或批内其它规则重复
*
* @param request 待校验的规则定义列表
* @return 与入参同序的重复标记列表
*/
@PostMapping("/rule/check-duplicates")
@Operation(summary = "AI 规则去重校验", description = "校验编辑后的规则是否与已有规则或批内其它规则重复,返回逐条重复标记")
public Result<List<Boolean>> checkRuleDuplicates(@RequestBody @Valid AiRuleDuplicateCheckRequest request) {
Long userId = UserContext.getUserId();
return Result.success(aiRuleService.checkDuplicates(userId, request.getRules()));
}
/** /**
* 将自然语言统计查询解析为统计面板参数 * 将自然语言统计查询解析为统计面板参数
* *
@@ -19,10 +19,13 @@ import org.springframework.web.bind.annotation.RestController;
import cn.rosercode.ledgerly.common.PageResult; import cn.rosercode.ledgerly.common.PageResult;
import cn.rosercode.ledgerly.common.Result; import cn.rosercode.ledgerly.common.Result;
import cn.rosercode.ledgerly.dto.BatchDeleteRequest; import cn.rosercode.ledgerly.dto.BatchDeleteRequest;
import cn.rosercode.ledgerly.dto.RuleApplyRequest;
import cn.rosercode.ledgerly.dto.RuleQuery; import cn.rosercode.ledgerly.dto.RuleQuery;
import cn.rosercode.ledgerly.dto.RuleRequest; import cn.rosercode.ledgerly.dto.RuleRequest;
import cn.rosercode.ledgerly.service.RuleService; import cn.rosercode.ledgerly.service.RuleService;
import cn.rosercode.ledgerly.util.UserContext; import cn.rosercode.ledgerly.util.UserContext;
import cn.rosercode.ledgerly.vo.RuleApplyPreviewVO;
import cn.rosercode.ledgerly.vo.RuleApplyResultVO;
import cn.rosercode.ledgerly.vo.RuleVO; import cn.rosercode.ledgerly.vo.RuleVO;
/** /**
@@ -141,6 +144,32 @@ public class RuleController {
return Result.success(); return Result.success();
} }
/**
* 预览所选规则命中的历史账单
*
* @param body 待应用的规则ID列表
* @return 命中账单统计与明细
*/
@PostMapping("/apply-preview")
@Operation(summary = "预览规则匹配账单", description = "按所选规则条件匹配当前用户的历史账单,返回将应用的目标分类与标签")
public Result<RuleApplyPreviewVO> applyPreview(@RequestBody @Valid RuleApplyRequest body) {
Long userId = UserContext.getUserId();
return Result.success(ruleService.previewApply(userId, body.getRuleIds()));
}
/**
* 一键将所选规则的分类与标签应用到其命中的历史账单
*
* @param body 待应用的规则ID列表
* @return 命中与变更统计
*/
@PostMapping("/apply")
@Operation(summary = "应用规则到匹配账单", description = "将所选规则的分类与标签应用到其命中的历史账单,分类覆盖、标签追加")
public Result<RuleApplyResultVO> apply(@RequestBody @Valid RuleApplyRequest body) {
Long userId = UserContext.getUserId();
return Result.success(ruleService.applyToMatchedTransactions(userId, body.getRuleIds()));
}
/** /**
* 启用状态请求体 * 启用状态请求体
*/ */
@@ -0,0 +1,23 @@
package cn.rosercode.ledgerly.dto;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
/**
* AI 规则去重校验请求,携带编辑后的规则定义列表。
*
* <p>用于在用户编辑规则建议后重新判定其是否与已有规则或批内其它规则重复。</p>
*
* @date 2026-10-13 22:40:47
*/
@Data
public class AiRuleDuplicateCheckRequest {
/** 待校验的规则列表,不能为空 */
@NotEmpty(message = "请至少提供一条规则")
@Valid
private List<AiRuleApplyRequest.Item> rules;
}
@@ -0,0 +1,21 @@
package cn.rosercode.ledgerly.dto;
import java.util.List;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
/**
* 规则应用请求,携带待应用的规则ID列表。
*
* <p>用于预览或一键将所选规则的分类与标签应用到其命中的历史账单。</p>
*
* @date 2026-10-13 22:09:05
*/
@Data
public class RuleApplyRequest {
/** 待应用的规则ID列表,不能为空 */
@NotEmpty(message = "请至少选择一条规则")
private List<Long> ruleIds;
}
@@ -1,5 +1,7 @@
package cn.rosercode.ledgerly.service; package cn.rosercode.ledgerly.service;
import java.util.List;
import cn.rosercode.ledgerly.dto.AiRuleApplyRequest; import cn.rosercode.ledgerly.dto.AiRuleApplyRequest;
import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest; import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest;
import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest; import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest;
@@ -41,4 +43,15 @@ public interface AiRuleService {
* @return 实际发生变更的记录数 * @return 实际发生变更的记录数
*/ */
int applyToTransactions(Long userId, AiRuleTransactionApplyRequest request); int applyToTransactions(Long userId, AiRuleTransactionApplyRequest request);
/**
* 校验编辑后的规则是否与已有规则或批内其它规则重复。
*
* <p>纯本地判定,不调用大模型:签名一致即视为重复,批内重复保留首条。</p>
*
* @param userId 当前用户ID
* @param items 待校验的规则定义列表
* @return 与入参同序的重复标记列表
*/
List<Boolean> checkDuplicates(Long userId, List<AiRuleApplyRequest.Item> items);
} }
@@ -5,6 +5,8 @@ import java.util.List;
import cn.rosercode.ledgerly.common.PageResult; import cn.rosercode.ledgerly.common.PageResult;
import cn.rosercode.ledgerly.dto.RuleQuery; import cn.rosercode.ledgerly.dto.RuleQuery;
import cn.rosercode.ledgerly.dto.RuleRequest; import cn.rosercode.ledgerly.dto.RuleRequest;
import cn.rosercode.ledgerly.vo.RuleApplyPreviewVO;
import cn.rosercode.ledgerly.vo.RuleApplyResultVO;
import cn.rosercode.ledgerly.vo.RuleVO; import cn.rosercode.ledgerly.vo.RuleVO;
/** /**
@@ -87,4 +89,27 @@ public interface RuleService {
* @return 与入参一一对应的匹配结果列表 * @return 与入参一一对应的匹配结果列表
*/ */
List<RuleMatchResult> matchBatch(Long userId, List<RuleMatchContext> contexts); List<RuleMatchResult> matchBatch(Long userId, List<RuleMatchContext> contexts);
/**
* 预览所选规则命中的历史账单及其将应用的目标分类与标签。
*
* <p>按规则条件(类型 + 关键词 + 金额区间)匹配当前用户的全部交易,
* 分类取命中规则中优先级最高且类型一致者,标签取命中规则标签并集。</p>
*
* @param userId 当前用户ID
* @param ruleIds 目标规则ID列表
* @return 命中账单统计与明细
*/
RuleApplyPreviewVO previewApply(Long userId, List<Long> ruleIds);
/**
* 将所选规则的分类与标签一键应用到其命中的历史账单。
*
* <p>分类覆盖写入(仅在类型一致时),标签在保留原有基础上追加,全程本地匹配不调用大模型。</p>
*
* @param userId 当前用户ID
* @param ruleIds 目标规则ID列表
* @return 命中与变更统计
*/
RuleApplyResultVO applyToMatchedTransactions(Long userId, List<Long> ruleIds);
} }
@@ -224,10 +224,29 @@ public class AiRuleServiceImpl implements AiRuleService {
* @return 规则签名 * @return 规则签名
*/ */
private String signatureOf(AiRuleSuggestionVO rule, List<Category> categories, List<Tag> tags) { private String signatureOf(AiRuleSuggestionVO rule, List<Category> categories, List<Tag> tags) {
List<String> newTagNames = rule.getNewTags() == null ? null return signatureOfFields(rule.getType(), rule.getKeyword(), rule.getCategoryId(),
: rule.getNewTags().stream().map(AiNewTagVO::getName).collect(Collectors.toList()); rule.getNewCategoryName(), rule.getTagIds(), rule.getNewTags(), categories, tags);
return buildSignature(rule.getType(), rule.getKeyword(), rule.getCategoryId(), }
rule.getNewCategoryName(), rule.getTagIds(), newTagNames, categories, tags);
/**
* 由规则定义字段计算签名,newCategory/newTags 先按名称解析到已有分类/标签 ID。
*
* @param type 收支类型
* @param keyword 关键词
* @param categoryId 分类ID
* @param newCategoryName 新建分类名称
* @param tagIds 标签ID列表
* @param newTags 新建标签列表
* @param categories 用户分类列表
* @param tags 用户标签列表
* @return 规则签名
*/
private String signatureOfFields(String type, String keyword, Long categoryId, String newCategoryName,
List<Long> tagIds, List<AiNewTagVO> newTags,
List<Category> categories, List<Tag> tags) {
List<String> newTagNames = newTags == null ? null
: newTags.stream().map(AiNewTagVO::getName).collect(Collectors.toList());
return buildSignature(type, keyword, categoryId, newCategoryName, tagIds, newTagNames, categories, tags);
} }
/** /**
@@ -490,6 +509,32 @@ public class AiRuleServiceImpl implements AiRuleService {
return applied; return applied;
} }
/**
* {@inheritDoc}
*
* 复用规则签名逻辑,逐条判定是否与已有规则或批内先前规则重复(批内保留首条)。
*/
@Override
public List<Boolean> checkDuplicates(Long userId, List<AiRuleApplyRequest.Item> items) {
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
.eq(Category::getUserId, userId));
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId));
Set<String> existing = loadExistingRuleSignatures(userId);
Set<String> seen = new HashSet<>();
List<Boolean> result = new ArrayList<>();
for (AiRuleApplyRequest.Item item : items) {
if (item == null) {
result.add(Boolean.FALSE);
continue;
}
String signature = signatureOfFields(item.getType(), item.getKeyword(), item.getCategoryId(),
item.getNewCategoryName(), item.getTagIds(), item.getNewTags(), categories, tags);
result.add(existing.contains(signature) || !seen.add(signature));
}
return result;
}
/** /**
* 按规则匹配单条交易并聚合目标分类与标签。 * 按规则匹配单条交易并聚合目标分类与标签。
* *
@@ -1,6 +1,7 @@
package cn.rosercode.ledgerly.service.impl; package cn.rosercode.ledgerly.service.impl;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -10,6 +11,7 @@ import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -25,14 +27,21 @@ import cn.rosercode.ledgerly.entity.Category;
import cn.rosercode.ledgerly.entity.Rule; import cn.rosercode.ledgerly.entity.Rule;
import cn.rosercode.ledgerly.entity.RuleTag; import cn.rosercode.ledgerly.entity.RuleTag;
import cn.rosercode.ledgerly.entity.Tag; import cn.rosercode.ledgerly.entity.Tag;
import cn.rosercode.ledgerly.entity.Transaction;
import cn.rosercode.ledgerly.entity.TransactionTag;
import cn.rosercode.ledgerly.enums.TransactionType; import cn.rosercode.ledgerly.enums.TransactionType;
import cn.rosercode.ledgerly.mapper.CategoryMapper; import cn.rosercode.ledgerly.mapper.CategoryMapper;
import cn.rosercode.ledgerly.mapper.RuleMapper; import cn.rosercode.ledgerly.mapper.RuleMapper;
import cn.rosercode.ledgerly.mapper.RuleTagMapper; import cn.rosercode.ledgerly.mapper.RuleTagMapper;
import cn.rosercode.ledgerly.mapper.TagMapper; import cn.rosercode.ledgerly.mapper.TagMapper;
import cn.rosercode.ledgerly.mapper.TransactionMapper;
import cn.rosercode.ledgerly.mapper.TransactionTagMapper;
import cn.rosercode.ledgerly.service.RuleMatchContext; import cn.rosercode.ledgerly.service.RuleMatchContext;
import cn.rosercode.ledgerly.service.RuleMatchResult; import cn.rosercode.ledgerly.service.RuleMatchResult;
import cn.rosercode.ledgerly.service.RuleService; import cn.rosercode.ledgerly.service.RuleService;
import cn.rosercode.ledgerly.vo.RuleApplyPreviewVO;
import cn.rosercode.ledgerly.vo.RuleApplyResultVO;
import cn.rosercode.ledgerly.vo.RuleMatchedTransactionVO;
import cn.rosercode.ledgerly.vo.RuleVO; import cn.rosercode.ledgerly.vo.RuleVO;
import cn.rosercode.ledgerly.vo.TagVO; import cn.rosercode.ledgerly.vo.TagVO;
@@ -48,21 +57,30 @@ import cn.rosercode.ledgerly.vo.TagVO;
@Slf4j @Slf4j
public class RuleServiceImpl implements RuleService { public class RuleServiceImpl implements RuleService {
/** 预览命中账单明细的最大展示条数 */
private static final int PREVIEW_MAX_ROWS = 500;
private final RuleMapper ruleMapper; private final RuleMapper ruleMapper;
private final RuleTagMapper ruleTagMapper; private final RuleTagMapper ruleTagMapper;
private final CategoryMapper categoryMapper; private final CategoryMapper categoryMapper;
private final TagMapper tagMapper; private final TagMapper tagMapper;
private final TransactionMapper transactionMapper;
private final TransactionTagMapper transactionTagMapper;
private final VoConverter voConverter; private final VoConverter voConverter;
public RuleServiceImpl(RuleMapper ruleMapper, public RuleServiceImpl(RuleMapper ruleMapper,
RuleTagMapper ruleTagMapper, RuleTagMapper ruleTagMapper,
CategoryMapper categoryMapper, CategoryMapper categoryMapper,
TagMapper tagMapper, TagMapper tagMapper,
TransactionMapper transactionMapper,
TransactionTagMapper transactionTagMapper,
VoConverter voConverter) { VoConverter voConverter) {
this.ruleMapper = ruleMapper; this.ruleMapper = ruleMapper;
this.ruleTagMapper = ruleTagMapper; this.ruleTagMapper = ruleTagMapper;
this.categoryMapper = categoryMapper; this.categoryMapper = categoryMapper;
this.tagMapper = tagMapper; this.tagMapper = tagMapper;
this.transactionMapper = transactionMapper;
this.transactionTagMapper = transactionTagMapper;
this.voConverter = voConverter; this.voConverter = voConverter;
} }
@@ -330,6 +348,299 @@ public class RuleServiceImpl implements RuleService {
return results; return results;
} }
/**
* {@inheritDoc}
*
* 按规则条件匹配当前用户全部交易,分类取命中规则中优先级最高且类型一致者,
* 标签取命中规则标签并集;明细最多返回 {@value #PREVIEW_MAX_ROWS} 条。
*/
@Override
public RuleApplyPreviewVO previewApply(Long userId, List<Long> ruleIds) {
List<MatchPlan> plans = computeMatchPlans(userId, ruleIds);
RuleApplyPreviewVO vo = new RuleApplyPreviewVO();
vo.setMatchedCount(plans.size());
vo.setTruncated(plans.size() > PREVIEW_MAX_ROWS);
Map<Long, Category> categoryMap = loadUserCategories(userId);
Map<Long, Tag> tagMap = loadUserTags(userId);
Map<Long, Set<Long>> existingTagIdsByTransaction = loadTransactionTagIds(
plans.stream().map(plan -> plan.transaction.getId()).collect(Collectors.toList()));
int categoryChangeCount = 0;
int tagChangeCount = 0;
List<RuleMatchedTransactionVO> rows = new ArrayList<>();
for (MatchPlan plan : plans) {
Transaction transaction = plan.transaction;
Set<Long> existingTagIds = existingTagIdsByTransaction.getOrDefault(transaction.getId(), Set.of());
boolean categoryChanged = plan.targetCategoryId != null
&& !plan.targetCategoryId.equals(transaction.getCategoryId());
boolean tagChanged = plan.targetTagIds.stream().anyMatch(tagId -> !existingTagIds.contains(tagId));
if (categoryChanged) {
categoryChangeCount++;
}
if (tagChanged) {
tagChangeCount++;
}
if (rows.size() < PREVIEW_MAX_ROWS) {
rows.add(toMatchedVo(plan, existingTagIds, categoryMap, tagMap));
}
}
vo.setCategoryChangeCount(categoryChangeCount);
vo.setTagChangeCount(tagChangeCount);
vo.setTransactions(rows);
return vo;
}
/**
* {@inheritDoc}
*
* 分类覆盖写入(仅类型一致时),标签在保留原有基础上追加,全程本地匹配。
*/
@Override
@Transactional
public RuleApplyResultVO applyToMatchedTransactions(Long userId, List<Long> ruleIds) {
List<MatchPlan> plans = computeMatchPlans(userId, ruleIds);
RuleApplyResultVO result = new RuleApplyResultVO();
result.setMatchedCount(plans.size());
if (plans.isEmpty()) {
return result;
}
Map<Long, Set<Long>> existingTagIdsByTransaction = loadTransactionTagIds(
plans.stream().map(plan -> plan.transaction.getId()).collect(Collectors.toList()));
int updated = 0;
int tagAdded = 0;
for (MatchPlan plan : plans) {
Transaction transaction = plan.transaction;
boolean changed = false;
// 分类覆盖写入:目标分类类型已在匹配阶段与交易类型校验一致
if (plan.targetCategoryId != null && !plan.targetCategoryId.equals(transaction.getCategoryId())) {
transactionMapper.update(null, new LambdaUpdateWrapper<Transaction>()
.eq(Transaction::getId, transaction.getId())
.eq(Transaction::getUserId, userId)
.set(Transaction::getCategoryId, plan.targetCategoryId));
changed = true;
}
// 标签追加:仅插入尚未关联的标签
Set<Long> existingTagIds = existingTagIdsByTransaction.getOrDefault(transaction.getId(), Set.of());
for (Long tagId : plan.targetTagIds) {
if (existingTagIds.contains(tagId)) {
continue;
}
TransactionTag relation = new TransactionTag();
relation.setTransactionId(transaction.getId());
relation.setTagId(tagId);
transactionTagMapper.insert(relation);
tagAdded++;
changed = true;
}
if (changed) {
updated++;
}
}
result.setUpdatedCount(updated);
result.setTagAddedCount(tagAdded);
log.info("规则应用到匹配账单完成: userId={}, 规则数={}, 命中={}, 变更={}, 新增标签={}",
userId, ruleIds.size(), plans.size(), updated, tagAdded);
return result;
}
/**
* 计算所选规则对当前用户全部交易的匹配计划。
*
* @param userId 当前用户ID
* @param ruleIds 目标规则ID列表
* @return 命中账单的匹配计划列表,按交易时间倒序
*/
private List<MatchPlan> computeMatchPlans(Long userId, List<Long> ruleIds) {
List<MatchPlan> plans = new ArrayList<>();
if (ruleIds == null || ruleIds.isEmpty()) {
return plans;
}
// 仅处理归属当前用户的选中规则,按优先级排序
List<Long> distinctIds = ruleIds.stream()
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (distinctIds.isEmpty()) {
return plans;
}
List<Rule> rules = ruleMapper.selectList(new LambdaQueryWrapper<Rule>()
.eq(Rule::getUserId, userId)
.in(Rule::getId, distinctIds)
.orderByAsc(Rule::getSortOrder)
.orderByAsc(Rule::getId));
if (rules.isEmpty()) {
return plans;
}
// 预加载规则标签关联,按规则分组
List<Long> selectedRuleIds = rules.stream().map(Rule::getId).collect(Collectors.toList());
Map<Long, List<Long>> tagIdsByRule = new HashMap<>();
List<RuleTag> relations = ruleTagMapper.selectList(new LambdaQueryWrapper<RuleTag>()
.in(RuleTag::getRuleId, selectedRuleIds));
for (RuleTag relation : relations) {
tagIdsByRule.computeIfAbsent(relation.getRuleId(), key -> new ArrayList<>())
.add(relation.getTagId());
}
// 预加载用户分类,用于目标分类的类型一致性校验
Map<Long, Category> categoryMap = loadUserCategories(userId);
// 仅加载匹配所需字段,避免读取 rawData 等敏感数据
List<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId, Transaction::getType, Transaction::getAmount,
Transaction::getCategoryId, Transaction::getRemark,
Transaction::getTransactionTime, Transaction::getChannel)
.eq(Transaction::getUserId, userId));
transactions.sort(Comparator.comparing(Transaction::getTransactionTime,
Comparator.nullsLast(Comparator.reverseOrder())));
for (Transaction transaction : transactions) {
String text = safe(transaction.getRemark()).toLowerCase();
List<String> matchedRuleNames = new ArrayList<>();
Long targetCategoryId = null;
Set<Long> targetTagIds = new LinkedHashSet<>();
for (Rule rule : rules) {
// 收支类型限制
if (StringUtils.hasText(rule.getType()) && !rule.getType().equals(transaction.getType())) {
continue;
}
// 关键词包含匹配(已落库账单的备注包含对方与商品信息)
if (StringUtils.hasText(rule.getKeyword())
&& !text.contains(rule.getKeyword().toLowerCase())) {
continue;
}
// 金额区间
if (rule.getMinAmount() != null
&& (transaction.getAmount() == null
|| transaction.getAmount().compareTo(rule.getMinAmount()) < 0)) {
continue;
}
if (rule.getMaxAmount() != null
&& (transaction.getAmount() == null
|| transaction.getAmount().compareTo(rule.getMaxAmount()) > 0)) {
continue;
}
matchedRuleNames.add(rule.getName());
// 分类取首个命中且类型一致的规则
if (targetCategoryId == null && rule.getCategoryId() != null) {
Category category = categoryMap.get(rule.getCategoryId());
if (category != null
&& (transaction.getType() == null || transaction.getType().equals(category.getType()))) {
targetCategoryId = rule.getCategoryId();
}
}
targetTagIds.addAll(tagIdsByRule.getOrDefault(rule.getId(), List.of()));
}
if (matchedRuleNames.isEmpty()) {
continue;
}
MatchPlan plan = new MatchPlan();
plan.transaction = transaction;
plan.matchedRuleNames = matchedRuleNames;
plan.targetCategoryId = targetCategoryId;
plan.targetTagIds = targetTagIds;
plans.add(plan);
}
return plans;
}
/**
* 组装单条命中账单的预览视图对象。
*
* @param plan 匹配计划
* @param existingTagIds 账单当前标签ID集合
* @param categoryMap 用户分类映射
* @param tagMap 用户标签映射
* @return 命中账单视图对象
*/
private RuleMatchedTransactionVO toMatchedVo(MatchPlan plan, Set<Long> existingTagIds,
Map<Long, Category> categoryMap, Map<Long, Tag> tagMap) {
Transaction transaction = plan.transaction;
RuleMatchedTransactionVO vo = new RuleMatchedTransactionVO();
vo.setId(transaction.getId());
vo.setTransactionTime(transaction.getTransactionTime());
vo.setType(transaction.getType());
vo.setAmount(transaction.getAmount());
vo.setChannel(transaction.getChannel());
vo.setRemark(transaction.getRemark());
vo.setCurrentCategoryId(transaction.getCategoryId());
Category currentCategory = transaction.getCategoryId() == null
? null : categoryMap.get(transaction.getCategoryId());
vo.setCurrentCategoryName(currentCategory == null ? null : currentCategory.getName());
vo.setCurrentTagNames(existingTagIds.stream()
.map(tagMap::get).filter(Objects::nonNull).map(Tag::getName).collect(Collectors.toList()));
vo.setTargetCategoryId(plan.targetCategoryId);
Category targetCategory = plan.targetCategoryId == null ? null : categoryMap.get(plan.targetCategoryId);
vo.setTargetCategoryName(targetCategory == null ? null : targetCategory.getName());
vo.setTargetTagNames(plan.targetTagIds.stream()
.map(tagMap::get).filter(Objects::nonNull).map(Tag::getName).collect(Collectors.toList()));
vo.setMatchedRuleNames(plan.matchedRuleNames);
return vo;
}
/**
* 加载当前用户的分类映射。
*
* @param userId 当前用户ID
* @return 分类ID到分类的映射
*/
private Map<Long, Category> loadUserCategories(Long userId) {
return categoryMapper.selectList(new LambdaQueryWrapper<Category>()
.eq(Category::getUserId, userId))
.stream().collect(Collectors.toMap(Category::getId, category -> category));
}
/**
* 加载当前用户的标签映射。
*
* @param userId 当前用户ID
* @return 标签ID到标签的映射
*/
private Map<Long, Tag> loadUserTags(Long userId) {
return tagMapper.selectList(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId))
.stream().collect(Collectors.toMap(Tag::getId, tag -> tag));
}
/**
* 批量加载交易的现有标签关联。
*
* @param transactionIds 交易ID列表
* @return 交易ID到标签ID集合的映射
*/
private Map<Long, Set<Long>> loadTransactionTagIds(List<Long> transactionIds) {
Map<Long, Set<Long>> result = new HashMap<>();
if (transactionIds.isEmpty()) {
return result;
}
List<TransactionTag> relations = transactionTagMapper.selectList(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, transactionIds));
for (TransactionTag relation : relations) {
result.computeIfAbsent(relation.getTransactionId(), key -> new LinkedHashSet<>())
.add(relation.getTagId());
}
return result;
}
/**
* 单条账单的匹配计划,承载命中规则与将应用的目标分类、标签。
*/
private static class MatchPlan {
/** 命中的交易 */
private Transaction transaction;
/** 命中的规则名称列表 */
private List<String> matchedRuleNames;
/** 将覆盖写入的目标分类ID,null 表示不修改分类 */
private Long targetCategoryId;
/** 将追加的目标标签ID集合 */
private Set<Long> targetTagIds;
}
/** /**
* 将请求字段写入规则实体,统一处理默认值。 * 将请求字段写入规则实体,统一处理默认值。
* *
@@ -0,0 +1,29 @@
package cn.rosercode.ledgerly.vo;
import java.util.List;
import lombok.Data;
/**
* 规则应用预览视图对象,汇总所选规则命中账单的统计信息与明细。
*
* @date 2026-10-13 22:09:05
*/
@Data
public class RuleApplyPreviewVO {
/** 命中的账单总数 */
private int matchedCount;
/** 将会被覆盖分类的账单数 */
private int categoryChangeCount;
/** 将会新增标签关联的账单数 */
private int tagChangeCount;
/** 命中明细是否因超出展示上限被截断 */
private boolean truncated;
/** 命中账单明细(最多展示上限条) */
private List<RuleMatchedTransactionVO> transactions;
}
@@ -0,0 +1,21 @@
package cn.rosercode.ledgerly.vo;
import lombok.Data;
/**
* 规则应用结果视图对象,返回一键应用规则到匹配账单后的变更统计。
*
* @date 2026-10-13 22:09:05
*/
@Data
public class RuleApplyResultVO {
/** 命中的账单总数 */
private int matchedCount;
/** 实际发生变更(分类或标签)的账单数 */
private int updatedCount;
/** 新增的标签关联数 */
private int tagAddedCount;
}
@@ -0,0 +1,57 @@
package cn.rosercode.ledgerly.vo;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Data;
/**
* 规则命中的交易视图对象,用于预览一键应用规则时的目标账单。
*
* <p>同时给出账单当前分类/标签与应用后将写入的目标分类/标签,便于用户确认。</p>
*
* @date 2026-10-13 22:09:05
*/
@Data
public class RuleMatchedTransactionVO {
/** 交易ID */
private Long id;
/** 交易时间 */
private LocalDateTime transactionTime;
/** 收支类型:INCOME-收入,EXPENSE-支出 */
private String type;
/** 交易金额 */
private BigDecimal amount;
/** 交易渠道编码 */
private String channel;
/** 备注 */
private String remark;
/** 当前分类ID */
private Long currentCategoryId;
/** 当前分类名称 */
private String currentCategoryName;
/** 当前标签名称列表 */
private List<String> currentTagNames;
/** 应用后的目标分类ID,null 表示不修改分类 */
private Long targetCategoryId;
/** 应用后的目标分类名称 */
private String targetCategoryName;
/** 应用后追加的目标标签名称列表 */
private List<String> targetTagNames;
/** 命中的规则名称列表,按优先级排列 */
private List<String> matchedRuleNames;
}