diff --git a/src/App.vue b/src/App.vue index 2dbeed2..f274dd9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,9 +2,12 @@ import { onMounted } from 'vue' import { RouterView } from 'vue-router' import BottomDock from './components/BottomDock.vue' +import AddEntryView from './views/AddEntryView.vue' import { useTransactionStore } from './stores/transactions' +import { useUiStore } from './stores/ui' const transactionStore = useTransactionStore() +const uiStore = useUiStore() onMounted(() => { transactionStore.ensureInitialized() @@ -25,6 +28,9 @@ onMounted(() => { + + + diff --git a/src/components/BottomDock.vue b/src/components/BottomDock.vue index 07e1d97..1075cf1 100644 --- a/src/components/BottomDock.vue +++ b/src/components/BottomDock.vue @@ -1,10 +1,12 @@ diff --git a/src/composables/useTransactionEntry.js b/src/composables/useTransactionEntry.js index f175b52..7ba56e5 100644 --- a/src/composables/useTransactionEntry.js +++ b/src/composables/useTransactionEntry.js @@ -22,6 +22,8 @@ const nativeBridgeReady = isNativeNotificationBridgeAvailable() let permissionChecked = false let bootstrapped = false let nativeNotificationListener = null +let pollTimer = null +const POLL_INTERVAL_MS = 60000 const ensureRulesReady = async () => { if (ruleSet.value.length) return ruleSet.value @@ -147,6 +149,15 @@ export const useTransactionEntry = () => { console.warn('[notifications] 无法注册 notificationPosted 监听', error) }) } + + // 前台轮询兜底:即使偶发漏掉原生事件,也能定期从队列补拉一次 + if (!pollTimer && typeof setInterval === 'function') { + pollTimer = setInterval(() => { + if (!syncing.value) { + void syncNotifications() + } + }, POLL_INTERVAL_MS) + } } return { diff --git a/src/router/index.js b/src/router/index.js index 7d19d9c..d146244 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -2,7 +2,6 @@ import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' import ListView from '../views/ListView.vue' import SettingsView from '../views/SettingsView.vue' -import AddEntryView from '../views/AddEntryView.vue' // TODO: 后续补充真实的分析页实现,这里先占位 const AnalysisView = () => import('../views/AnalysisView.vue').catch(() => null) @@ -34,12 +33,6 @@ const router = createRouter({ component: SettingsView, meta: { tab: 'settings' }, }, - { - path: '/add', - name: 'add', - component: AddEntryView, - meta: { overlay: true }, - }, ], }) diff --git a/src/services/notificationRuleService.js b/src/services/notificationRuleService.js index 67a773b..7a73cd8 100644 --- a/src/services/notificationRuleService.js +++ b/src/services/notificationRuleService.js @@ -187,26 +187,59 @@ const extractAmount = (text = '') => { return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0 } +// 判断字符串是否更像是「金额」而不是商户名,用于避免把「1.00元」当成商户 +const looksLikeAmount = (raw = '') => { + const value = String(raw).trim() + if (!value) return false + const amountPattern = /^[-\d.,]+\s*(?:元|块钱|¥|¥|人民币)?$/ + return amountPattern.test(value) +} + const extractMerchant = (text, rule) => { + const content = text || '' + if (rule?.merchantPattern instanceof RegExp) { - const m = text.match(rule.merchantPattern) + const m = content.match(rule.merchantPattern) if (m?.[1]) return m[1].trim() } + // 通用的「站点 A -> 站点 B」线路模式(公交/地铁通知), + // 即使未命中专用规则也尝试提取,避免退化为金额或「Unknown」 + const routeMatch = content.match( + /([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到|->)\s*[\u4e00-\u9fa5]{2,})/, + ) + if (routeMatch?.[1]) { + return routeMatch[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) + const m = content.match(pattern) if (m?.[1]) return m[1].trim() } - const parts = text.split(/:|:/) + // 针对「……支出(消费支付宝-上海拉扎斯信息科技有限公司)3.23元」这类银行通知, + // 优先尝试从括号中的「支付宝-商户名」结构中提取真正的商户名 + const parenMatch = content.match(/\(([^)]+)\)/) + if (parenMatch?.[1]) { + const inner = parenMatch[1] + const payMatch = inner.match(/(?:支付宝|微信支付|微信)[-—\s]*([^\d元¥¥]+)$/) + if (payMatch?.[1]) { + const candidate = payMatch[1].trim() + if (candidate && !looksLikeAmount(candidate)) { + return candidate + } + } + } + + const parts = content.split(/:|:/) if (parts.length > 1) { const candidate = parts[1].split(/[,,\s]/)[0] - if (candidate) return candidate.trim() + if (candidate && !looksLikeAmount(candidate)) return candidate.trim() } return 'Unknown' diff --git a/src/stores/settings.js b/src/stores/settings.js index 3589b79..6f58ab7 100644 --- a/src/stores/settings.js +++ b/src/stores/settings.js @@ -6,6 +6,9 @@ export const useSettingsStore = defineStore( () => { const notificationCaptureEnabled = ref(true) const aiAutoCategoryEnabled = ref(false) + const monthlyBudget = ref(12000) + const profileName = ref('Echo 用户') + const profileAvatar = ref('') const setNotificationCaptureEnabled = (value) => { notificationCaptureEnabled.value = !!value @@ -15,16 +18,41 @@ export const useSettingsStore = defineStore( aiAutoCategoryEnabled.value = !!value } + const setMonthlyBudget = (value) => { + const numeric = Number(value) + monthlyBudget.value = Number.isFinite(numeric) && numeric >= 0 ? numeric : 0 + } + + const setProfileName = (value) => { + profileName.value = (value || '').trim() || 'Echo 用户' + } + + const setProfileAvatar = (value) => { + profileAvatar.value = value || '' + } + return { notificationCaptureEnabled, aiAutoCategoryEnabled, + monthlyBudget, + profileName, + profileAvatar, setNotificationCaptureEnabled, setAiAutoCategoryEnabled, + setMonthlyBudget, + setProfileName, + setProfileAvatar, } }, { persist: { - paths: ['notificationCaptureEnabled', 'aiAutoCategoryEnabled'], + paths: [ + 'notificationCaptureEnabled', + 'aiAutoCategoryEnabled', + 'monthlyBudget', + 'profileName', + 'profileAvatar', + ], }, }, ) diff --git a/src/stores/ui.js b/src/stores/ui.js new file mode 100644 index 0000000..c8ac49e --- /dev/null +++ b/src/stores/ui.js @@ -0,0 +1,26 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +// 负责全局 UI 状态,例如新增记录的底部弹窗 +export const useUiStore = defineStore('ui', () => { + const addEntryVisible = ref(false) + const editingTransactionId = ref('') + + const openAddEntry = (id = '') => { + editingTransactionId.value = id || '' + addEntryVisible.value = true + } + + const closeAddEntry = () => { + addEntryVisible.value = false + editingTransactionId.value = '' + } + + return { + addEntryVisible, + editingTransactionId, + openAddEntry, + closeAddEntry, + } +}) + diff --git a/src/views/AddEntryView.vue b/src/views/AddEntryView.vue index ff94f68..715d4e0 100644 --- a/src/views/AddEntryView.vue +++ b/src/views/AddEntryView.vue @@ -1,13 +1,12 @@ diff --git a/vite.config.js b/vite.config.js index bbcf80c..6698196 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,14 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +// 从 package.json 注入版本号,便于前端展示 +// eslint-disable-next-line no-undef +const appVersion = (process && process.env && process.env.npm_package_version) || '0.0.0' + // https://vite.dev/config/ export default defineConfig({ plugins: [vue()], + define: { + __APP_VERSION__: JSON.stringify(appVersion), + }, })