diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts index 8d08f68..c405398 100644 --- a/frontend/src/api/ai.ts +++ b/frontend/src/api/ai.ts @@ -185,25 +185,31 @@ export function suggestRulesByAi(transactionIds: number[]) { return request.post('/ai/rule/suggest', { transactionIds }, { timeout: 120000 }) } +/** AI 规则创建/应用的单条规则载荷 */ +export interface AiRuleApplyItem { + name: string + enabled?: boolean + type?: string | null + keyword?: string | null + minAmount?: number | null + maxAmount?: number | null + categoryId?: number | null + newCategoryName?: string | null + newCategoryIcon?: string | null + tagIds?: number[] + newTags?: AiNewTag[] +} + /** 批量创建 AI 生成的规则,必要时新建分类与标签 */ -export function applyAiRules( - rules: { - name: string - enabled?: boolean - type?: string | null - keyword?: string | null - minAmount?: number | null - maxAmount?: number | null - categoryId?: number | null - newCategoryName?: string | null - newCategoryIcon?: string | null - tagIds?: number[] - newTags?: AiNewTag[] - }[], -) { +export function applyAiRules(rules: AiRuleApplyItem[]) { return request.post('/ai/rule/apply', { rules }) } +/** 将规则按类型/关键词/金额匹配并应用到指定交易,命中则覆盖分类、追加标签 */ +export function applyRulesToTransactions(transactionIds: number[], rules: AiRuleApplyItem[]) { + return request.post('/ai/rule/apply-transactions', { transactionIds, rules }) +} + /** 一键应用建议:覆盖分类并追加标签,标记为新建的分类/标签会先创建再关联 */ export function applyAiSuggestions( suggestions: { diff --git a/frontend/src/components/AiRuleSuggestModal.vue b/frontend/src/components/AiRuleSuggestModal.vue new file mode 100644 index 0000000..d4c1b47 --- /dev/null +++ b/frontend/src/components/AiRuleSuggestModal.vue @@ -0,0 +1,161 @@ + + + diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index 081d689..ba69626 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -1440,6 +1440,26 @@ export default { en: 'All generated rules already exist', ja: '生成されたルールはすべて既存です', }, + selectFirst: { + zh: '请先勾选要用于生成规则的账单', + en: 'Please select the records to derive rules from', + ja: 'ルール生成に使う記録を先に選択してください', + }, + applyToSelection: { + zh: '同时应用到当前勾选的 {count} 条账单', + en: 'Also apply to the {count} selected records', + ja: '選択中の {count} 件の記録にも適用する', + }, + applyToSelectionNote: { + zh: '命中规则时会覆盖这些账单的分类并追加标签(不改动备注与金额)', + en: 'Matching rules overwrite the category and append tags of these records (remark and amount unchanged)', + ja: 'ルールが一致した場合、これらの記録のカテゴリを上書きしタグを追加します(備考と金額は変更しません)', + }, + appliedAndApplied: { + zh: '已创建 {rules} 条规则,并应用到 {transactions} 条账单', + en: 'Created {rules} rule(s) and applied to {transactions} record(s)', + ja: '{rules} 件のルールを作成し、{transactions} 件の記録に適用しました', + }, }, category: { label: { zh: 'AI 生成分类', en: 'AI Category', ja: 'AI 生成カテゴリ' }, diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index 1cc82b9..c55792b 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -39,9 +39,19 @@ import { getChannels, batchDeleteTransactions, } from '../api/transaction' -import { suggestByAi, suggestOneByAi, queryByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi } from '../api/ai' +import { + suggestByAi, + suggestOneByAi, + queryByAi, + parseCategoryByAi, + parseTagByAi, + parseChannelByAi, + suggestRulesByAi, + applyAiRules, + applyRulesToTransactions, +} from '../api/ai' import { createChannel } from '../api/channel' -import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai' +import type { AiSuggestion, AiQueryResult, AiRecordResult, AiRuleSuggestion, AiRuleApplyItem } from '../api/ai' import { getExcludedDisplayStyle, getShowHiddenData } from '../api/setting' import { getCategoryList, createCategory } from '../api/category' import { getTagList, createTag } from '../api/tag' @@ -59,6 +69,7 @@ import { aiEnabled, ensureAiEnabledLoaded } from '../composables/useAiEnabled' import EmojiPicker from '../components/EmojiPicker.vue' import AiRecordModal from '../components/AiRecordModal.vue' import AiSuggestModal from '../components/AiSuggestModal.vue' +import AiRuleSuggestModal from '../components/AiRuleSuggestModal.vue' import TransactionBatchModal from '../components/TransactionBatchModal.vue' import BillImportModal from './BillImportModal.vue' import TransactionDetailModal from '../components/TransactionDetailModal.vue' @@ -220,6 +231,10 @@ const newTagColorSwatches = [ '#000000', ] const aiSuggestions = ref([]) // AI 建议列表 +const aiRuleSuggestions = ref([]) // AI 规则建议列表 +const aiRuleGenerating = ref(false) // 规则生成中 +const aiRuleApplying = ref(false) // 规则创建/应用中 +const showAiRuleModal = ref(false) // AI 生成规则弹窗显示状态 const aiQuerying = ref(false) // AI 查询解析状态 const aiQueryResult = ref(null) // AI 查询解析结果 const showAiQueryModal = ref(false) // AI 智能查询弹窗显示状态 @@ -1021,6 +1036,72 @@ async function handleAiSuggest() { } } +/** 基于勾选的账单生成规则建议 */ +async function handleAiRuleGenerate() { + const ids = checkedTransactionIds.value + if (ids.length === 0) { + message.warning(t('ai.rule.selectFirst')) + return + } + aiRuleGenerating.value = true + try { + const res: any = await suggestRulesByAi(ids) + const data = res.data || {} + aiRuleSuggestions.value = data.rules || [] + if (aiRuleSuggestions.value.length === 0) { + message.warning(t('ai.noSuggestion')) + return + } + showAiRuleModal.value = true + } catch (e: any) { + message.error(e.message || t('ai.rule.failed')) + } finally { + aiRuleGenerating.value = false + } +} + +/** 将规则建议映射为创建/应用载荷 */ +function toRulePayload(rule: AiRuleSuggestion): AiRuleApplyItem { + return { + name: rule.name, + enabled: true, + type: rule.type, + keyword: rule.keyword, + minAmount: rule.minAmount, + maxAmount: rule.maxAmount, + categoryId: rule.categoryId, + newCategoryName: rule.newCategoryName, + newCategoryIcon: rule.newCategoryIcon, + tagIds: rule.tagIds, + newTags: rule.newTags, + } +} + +/** 创建勾选的规则,并可选同时应用到勾选的账单 */ +async function handleAiRuleSubmit(payload: { rules: AiRuleSuggestion[]; applyToSelection: boolean }) { + const rules = payload.rules.map(toRulePayload) + const targetIds = [...checkedTransactionIds.value] + aiRuleApplying.value = true + try { + await applyAiRules(rules) + if (payload.applyToSelection) { + await applyRulesToTransactions(targetIds, rules) + message.success(t('ai.rule.appliedAndApplied', { rules: rules.length, transactions: targetIds.length })) + } else { + message.success(t('ai.rule.applied', { count: rules.length })) + } + showAiRuleModal.value = false + exitSelectionMode() + await fetchData() + await fetchCategories() + await fetchTags() + } catch (e: any) { + message.error(e.message || t('ai.rule.applyFailed')) + } finally { + aiRuleApplying.value = false + } +} + /** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、交易渠道、备注) */ async function handleAiSuggestOne() { aiFormLoading.value = true @@ -1857,6 +1938,9 @@ onMounted(() => { {{ t('transaction.relation.createFromSelection') }} + + {{ t('ai.rule.entry') }} + @@ -2206,6 +2290,16 @@ onMounted(() => { @applied="onAiSuggestApplied" /> + + + applyRulesToTransactions(@RequestBody @Valid AiRuleTransactionApplyRequest request) { + Long userId = UserContext.getUserId(); + return Result.success(aiRuleService.applyToTransactions(userId, request)); + } + /** * 将自然语言统计查询解析为统计面板参数 * diff --git a/src/main/java/cn/rosercode/ledgerly/dto/AiRuleTransactionApplyRequest.java b/src/main/java/cn/rosercode/ledgerly/dto/AiRuleTransactionApplyRequest.java new file mode 100644 index 0000000..1aff7e3 --- /dev/null +++ b/src/main/java/cn/rosercode/ledgerly/dto/AiRuleTransactionApplyRequest.java @@ -0,0 +1,25 @@ +package cn.rosercode.ledgerly.dto; + +import java.util.List; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +/** + * AI 规则应用到交易记录的请求,携带目标交易与用于匹配的规则定义。 + * + * @date 2026-10-13 20:22:47 + */ +@Data +public class AiRuleTransactionApplyRequest { + + /** 待应用的目标交易记录ID列表,不能为空 */ + @NotEmpty(message = "请至少选择一条记录") + private List transactionIds; + + /** 用于匹配并应用的规则列表,不能为空 */ + @NotEmpty(message = "请至少选择一条规则") + @Valid + private List rules; +} diff --git a/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java b/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java index d14fd6a..fed70b4 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java +++ b/src/main/java/cn/rosercode/ledgerly/service/AiRuleService.java @@ -2,6 +2,7 @@ package cn.rosercode.ledgerly.service; import cn.rosercode.ledgerly.dto.AiRuleApplyRequest; import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest; +import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest; import cn.rosercode.ledgerly.vo.AiRuleSuggestResultVO; /** @@ -28,4 +29,16 @@ public interface AiRuleService { * @return 实际创建的规则数量 */ int apply(Long userId, AiRuleApplyRequest request); + + /** + * 将规则匹配并应用到指定交易记录。 + * + *

纯本地匹配,不调用大模型:命中规则时覆盖分类并追加标签,必要时新建分类与标签。 + * 规则的关键词匹配交易的备注(包含、忽略大小写),并遵循类型与金额区间条件。

+ * + * @param userId 当前用户ID + * @param request 目标交易与规则定义 + * @return 实际发生变更的记录数 + */ + int applyToTransactions(Long userId, AiRuleTransactionApplyRequest request); } diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java index d687151..5320cdc 100644 --- a/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java +++ b/src/main/java/cn/rosercode/ledgerly/service/impl/AiRuleServiceImpl.java @@ -1,5 +1,6 @@ package cn.rosercode.ledgerly.service.impl; +import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -24,8 +25,10 @@ import org.springframework.transaction.annotation.Transactional; import cn.rosercode.ledgerly.client.DeepSeekClient; import cn.rosercode.ledgerly.client.DeepSeekResponse; import cn.rosercode.ledgerly.common.BusinessException; +import cn.rosercode.ledgerly.dto.AiApplyRequest; import cn.rosercode.ledgerly.dto.AiRuleApplyRequest; import cn.rosercode.ledgerly.dto.AiRuleSuggestRequest; +import cn.rosercode.ledgerly.dto.AiRuleTransactionApplyRequest; import cn.rosercode.ledgerly.dto.RuleRequest; import cn.rosercode.ledgerly.entity.AiConfig; import cn.rosercode.ledgerly.entity.Category; @@ -41,6 +44,7 @@ import cn.rosercode.ledgerly.mapper.TransactionTagMapper; import cn.rosercode.ledgerly.prompt.PromptTemplateService; import cn.rosercode.ledgerly.service.AiConfigService; import cn.rosercode.ledgerly.service.AiRuleService; +import cn.rosercode.ledgerly.service.AiSuggestionService; import cn.rosercode.ledgerly.service.AiUsageService; import cn.rosercode.ledgerly.service.RuleService; import cn.rosercode.ledgerly.vo.AiNewTagVO; @@ -91,6 +95,7 @@ public class AiRuleServiceImpl implements AiRuleService { private final TransactionTagMapper transactionTagMapper; private final RuleService ruleService; private final PromptTemplateService promptTemplateService; + private final AiSuggestionService aiSuggestionService; public AiRuleServiceImpl(AiConfigService aiConfigService, DeepSeekClient deepSeekClient, @@ -101,7 +106,8 @@ public class AiRuleServiceImpl implements AiRuleService { TagMapper tagMapper, TransactionTagMapper transactionTagMapper, RuleService ruleService, - PromptTemplateService promptTemplateService) { + PromptTemplateService promptTemplateService, + AiSuggestionService aiSuggestionService) { this.aiConfigService = aiConfigService; this.deepSeekClient = deepSeekClient; this.aiUsageService = aiUsageService; @@ -112,6 +118,7 @@ public class AiRuleServiceImpl implements AiRuleService { this.transactionTagMapper = transactionTagMapper; this.ruleService = ruleService; this.promptTemplateService = promptTemplateService; + this.aiSuggestionService = aiSuggestionService; } /** @@ -439,6 +446,136 @@ public class AiRuleServiceImpl implements AiRuleService { return created; } + /** + * {@inheritDoc} + * + * 纯本地匹配,不调用大模型:逐条交易按类型、关键词与金额区间匹配规则, + * 命中后聚合分类与标签,委托 {@link AiSuggestionService} 完成落库。 + */ + @Override + @Transactional + public int applyToTransactions(Long userId, AiRuleTransactionApplyRequest request) { + List transactionIds = request.getTransactionIds().stream() + .distinct().collect(Collectors.toList()); + // 仅处理属于当前用户的记录,且保持请求顺序 + Map transactionMap = transactionMapper.selectList(new LambdaQueryWrapper() + .eq(Transaction::getUserId, userId) + .in(Transaction::getId, transactionIds)) + .stream().collect(Collectors.toMap(Transaction::getId, Function.identity())); + if (transactionMap.isEmpty()) { + return 0; + } + + List rules = request.getRules(); + List applyItems = new ArrayList<>(); + for (Long transactionId : transactionIds) { + Transaction transaction = transactionMap.get(transactionId); + if (transaction == null) { + continue; + } + AiApplyRequest.Item item = buildApplyItem(transaction, rules); + if (item != null) { + applyItems.add(item); + } + } + if (applyItems.isEmpty()) { + return 0; + } + + AiApplyRequest applyRequest = new AiApplyRequest(); + applyRequest.setSuggestions(applyItems); + int applied = aiSuggestionService.apply(userId, applyRequest); + log.info("AI 规则应用到交易完成: userId={}, 目标交易={}, 命中={}, 实际应用={}", userId, + transactionIds.size(), applyItems.size(), applied); + return applied; + } + + /** + * 按规则匹配单条交易并聚合目标分类与标签。 + * + * @param transaction 目标交易 + * @param rules 规则列表 + * @return 应用项,无任何命中或可应用内容时返回 null + */ + private AiApplyRequest.Item buildApplyItem(Transaction transaction, List rules) { + String text = safe(transaction.getRemark()).toLowerCase(); + Long categoryId = null; + String newCategoryName = null; + String newCategoryIcon = null; + List tagIds = new ArrayList<>(); + List newTags = new ArrayList<>(); + Set newTagNames = new HashSet<>(); + boolean matched = false; + for (AiRuleApplyRequest.Item rule : rules) { + if (rule == null || !matchesRule(rule, transaction, text)) { + continue; + } + matched = true; + // 分类取首个命中规则:优先现有分类,其次新建分类 + if (categoryId == null && isBlank(newCategoryName)) { + if (rule.getCategoryId() != null) { + categoryId = rule.getCategoryId(); + } else if (!isBlank(rule.getNewCategoryName())) { + newCategoryName = rule.getNewCategoryName().trim(); + newCategoryIcon = rule.getNewCategoryIcon(); + } + } + // 标签取所有命中规则的并集 + if (rule.getTagIds() != null) { + for (Long tagId : rule.getTagIds()) { + if (tagId != null && !tagIds.contains(tagId)) { + tagIds.add(tagId); + } + } + } + if (rule.getNewTags() != null) { + for (AiNewTagVO newTag : rule.getNewTags()) { + if (newTag == null || isBlank(newTag.getName()) || !newTagNames.add(newTag.getName().trim())) { + continue; + } + newTags.add(newTag); + } + } + } + if (!matched || (categoryId == null && isBlank(newCategoryName) && tagIds.isEmpty() && newTags.isEmpty())) { + return null; + } + AiApplyRequest.Item item = new AiApplyRequest.Item(); + item.setTransactionId(transaction.getId()); + item.setCategoryId(categoryId); + item.setNewCategoryName(newCategoryName); + item.setNewCategoryIcon(newCategoryIcon); + item.setTagIds(tagIds); + item.setNewTags(newTags); + return item; + } + + /** + * 判断单条规则是否命中交易:类型限定、关键词包含与金额区间为「与」关系。 + * + * @param rule 规则 + * @param transaction 交易 + * @param text 交易的匹配文本(备注转小写) + * @return 命中返回 true + */ + private boolean matchesRule(AiRuleApplyRequest.Item rule, Transaction transaction, String text) { + String type = validType(rule.getType()); + if (type != null && !type.equals(transaction.getType())) { + return false; + } + if (!isBlank(rule.getKeyword()) && !text.contains(rule.getKeyword().trim().toLowerCase())) { + return false; + } + BigDecimal amount = transaction.getAmount(); + if (rule.getMinAmount() != null && (amount == null || amount.compareTo(rule.getMinAmount()) < 0)) { + return false; + } + if (rule.getMaxAmount() != null && (amount == null || amount.compareTo(rule.getMaxAmount()) > 0)) { + return false; + } + return true; + } + /** * 构建用户提示词,将可用分类、标签与已打标签记录序列化为 JSON。 *