From a81b106ac8ef24ac7c92cc14b3fbdc11aa6db0b7 Mon Sep 17 00:00:00 2001 From: Jafeng <2998840497@qq.com> Date: Fri, 28 Nov 2025 17:57:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E6=9C=AA=E5=91=BD=E4=B8=AD=E8=A7=84=E5=88=99=E7=9A=84=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=B9=B6=E6=9B=B4=E6=96=B0=E8=A7=84=E5=88=99=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/composables/useTransactionEntry.js | 15 ++++-- src/services/notificationRuleService.js | 61 +++++++++++++++++-------- 2 files changed, 53 insertions(+), 23 deletions(-) diff --git a/src/composables/useTransactionEntry.js b/src/composables/useTransactionEntry.js index e50588d..f175b52 100644 --- a/src/composables/useTransactionEntry.js +++ b/src/composables/useTransactionEntry.js @@ -98,10 +98,17 @@ export const useTransactionEntry = () => { const queue = await fetchNotificationQueue() const manualQueue = [] for (const item of queue) { - const { transaction, rule, requiresConfirmation } = transformNotificationToTransaction( - item, - { ruleSet: ruleSet.value, date: item.createdAt }, - ) + const { transaction, rule, requiresConfirmation } = transformNotificationToTransaction(item, { + ruleSet: ruleSet.value, + date: item.createdAt, + }) + + // 如果没有任何规则命中(rule 为空),默认忽略该通知,避免系统通知等噪音进入待确认列表 + if (!rule) { + await acknowledgeNotification(item.id) + continue + } + if (!requiresConfirmation) { await transactionStore.addTransaction(transaction) await acknowledgeNotification(item.id) diff --git a/src/services/notificationRuleService.js b/src/services/notificationRuleService.js index 2bece4e..67a773b 100644 --- a/src/services/notificationRuleService.js +++ b/src/services/notificationRuleService.js @@ -3,8 +3,8 @@ const fallbackRules = [ { id: 'alipay-expense', label: '支付宝消费', - channels: ['支付宝', '支付', 'Alipay'], - keywords: ['支付', '扣款', '支出', '已消费', '成功支付'], + channels: ['支付宝', 'Alipay'], + keywords: ['支付', '扣款', '支出', '消费', '成功支付'], direction: 'expense', defaultCategory: 'Food', autoCapture: false, @@ -14,7 +14,7 @@ const fallbackRules = [ { id: 'alipay-income', label: '支付宝收款', - channels: ['支付宝', '支付', 'Alipay'], + channels: ['支付宝', 'Alipay'], keywords: ['到账', '收入', '收款', '入账'], direction: 'income', defaultCategory: 'Income', @@ -25,7 +25,7 @@ const fallbackRules = [ { id: 'wechat-expense', label: '微信消费', - channels: ['微信', '微信支付', 'WeChat'], + channels: ['微信支付', '微信', 'WeChat'], keywords: ['支付', '支出', '扣款', '消费成功'], direction: 'expense', defaultCategory: 'Groceries', @@ -36,7 +36,7 @@ const fallbackRules = [ { id: 'wechat-income', label: '微信收款', - channels: ['微信', '微信支付', 'WeChat'], + channels: ['微信支付', '微信', 'WeChat'], keywords: ['收款', '到账', '入账', '转入'], direction: 'income', defaultCategory: 'Income', @@ -98,28 +98,30 @@ const fallbackRules = [ id: 'transit-card-expense', label: '公交出行', channels: [ - '公交', - '地铁', - '乘车码', - '交通联合', - '一卡通', + '杭州通互联互通卡', + '杭州通', '北京一卡通', '上海公共交通卡', '深圳通', '苏州公交卡', - '杭州通互联互通卡', + '交通联合', + '乘车码', + '地铁', + '公交', 'Mi Pay', 'Huawei Pay', '华为钱包', - '小米智能卡', '小米钱包', 'OPPO 钱包', ], - keywords: ['扣款', '支出', '乘车', '刷卡', '过闸','行程中','地铁','公交','进站','出站'], + // 对公交卡类通知,只要来源匹配即可视为出行支出,不强制正文关键字 + keywords: [], direction: 'expense', defaultCategory: 'Transport', autoCapture: true, - // 公交卡类通知里商户往往不重要,给一个统一名称 + // 优先从正文中提取「文三路->祥园路」之类的起点-终点信息作为“商户” + merchantPattern: /([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到)\s*[\u4e00-\u9fa5]{2,})/, + // 若无法提取线路,则使用统一名称 defaultMerchant: '公交/地铁出行', }, ] @@ -128,7 +130,19 @@ let cachedRules = [...fallbackRules] let remoteRuleFetcher = async () => [] /** - * 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际的 fetch 函数 + * 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际的 fetch 函数。 + * 服务端可以返回一组规则对象,形如: + * { + * id: string; + * label?: string; + * enabled?: boolean; // false 时在本地完全忽略此规则 + * priority?: number; // 预留优先级字段(当前仅在服务端使用) + * channels?: string[]; // 匹配通知来源(标题 / 包名) + * keywords?: string[]; // 匹配通知正文的关键字 + * direction?: 'income' | 'expense'; + * defaultCategory?: string; + * autoCapture?: boolean; + * } * @param {(currentRules: Array) => Promise} fetcher */ export const setRemoteRuleFetcher = (fetcher) => { @@ -178,18 +192,28 @@ const extractMerchant = (text, rule) => { const m = text.match(rule.merchantPattern) if (m?.[1]) return m[1].trim() } - const generic = text.match(/向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/) - if (generic?.[1]) return generic[1].trim() + + const genericPatterns = [ + /向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/, + /商户[::]?\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/, + ] + + for (const pattern of genericPatterns) { + const m = text.match(pattern) + if (m?.[1]) return m[1].trim() + } + const parts = text.split(/:|:/) if (parts.length > 1) { const candidate = parts[1].split(/[,,\s]/)[0] if (candidate) return candidate.trim() } + return 'Unknown' } const matchRule = (notification, rule) => { - if (!rule) return false + if (!rule || rule.enabled === false) return false const channel = notification?.channel || '' const text = notification?.text || '' const channelHit = !rule.channels?.length || rule.channels.some((item) => channel.includes(item)) @@ -234,4 +258,3 @@ export const transformNotificationToTransaction = (notification, options = {}) = return { transaction, rule, requiresConfirmation } } -