diff --git a/package-lock.json b/package-lock.json index 2a97bc5..d52dcf0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "jeep-sqlite": "^2.7.1", "pinia": "^2.1.7", "pinia-plugin-persistedstate": "^3.2.0", + "sql.js": "^1.11.0", "uuid": "^11.0.2", "vue": "^3.5.24", "vue-router": "^4.6.3" @@ -2362,9 +2363,9 @@ } }, "node_modules/sql.js": { - "version": "1.13.0", - "resolved": "https://registry.npmmirror.com/sql.js/-/sql.js-1.13.0.tgz", - "integrity": "sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==", + "version": "1.11.0", + "resolved": "https://registry.npmmirror.com/sql.js/-/sql.js-1.11.0.tgz", + "integrity": "sha512-GsLUDU3vhOo14Pd5ME0y2te49JQyby6HuoCuadevEV+CGgTUjmYRrm7B7lhRyzOgrmcWmspUfyjNb6sOAEqdsA==", "license": "MIT" }, "node_modules/string_decoder": { diff --git a/package.json b/package.json index 8083242..6d2f7b7 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --host 0.0.0.0", "build": "vite build", "preview": "vite preview" }, @@ -15,6 +15,7 @@ "jeep-sqlite": "^2.7.1", "pinia": "^2.1.7", "pinia-plugin-persistedstate": "^3.2.0", + "sql.js": "^1.11.0", "uuid": "^11.0.2", "vue": "^3.5.24", "vue-router": "^4.6.3" diff --git a/public/assets/sql-wasm.wasm b/public/assets/sql-wasm.wasm new file mode 100644 index 0000000..66d4a5d Binary files /dev/null and b/public/assets/sql-wasm.wasm differ diff --git a/src/composables/useTransactionEntry.js b/src/composables/useTransactionEntry.js index 8c90219..7afdf97 100644 --- a/src/composables/useTransactionEntry.js +++ b/src/composables/useTransactionEntry.js @@ -1,83 +1,35 @@ import { ref } from 'vue' -import { v4 as uuidv4 } from 'uuid' import { useTransactionStore } from '../stores/transactions' - -const CATEGORY_HINTS = [ - { keywords: ['咖啡', '奶茶', '星巴克', 'luckin', 'coffee', 'familymart', '7-eleven'], category: 'Food' }, - { keywords: ['健身', 'gym', '运动', '瑜伽'], category: 'Health' }, - { keywords: ['滴滴', '打车', '出租', '地铁', '出行'], category: 'Transport' }, - { keywords: ['超市', '盒马', 'sam', '山姆'], category: 'Groceries' }, - { keywords: ['工资', 'salary', 'bonus', '收入'], category: 'Income' }, -] - -const inferCategory = (merchant) => { - const lower = merchant.toLowerCase() - const matched = CATEGORY_HINTS.find((hint) => hint.keywords.some((key) => lower.includes(key))) - if (matched) return matched.category - return 'Uncategorized' -} - -const seedNotifications = () => [ - { - id: uuidv4(), - channel: '支付宝', - text: '支付宝提醒:你向 瑞幸咖啡 支付 ¥18.00', - createdAt: new Date().toISOString(), - }, - { - id: uuidv4(), - channel: '微信支付', - text: '微信支付:美团外卖 实付 ¥46.50 · 订单已送达', - createdAt: new Date(Date.now() - 1000 * 60 * 8).toISOString(), - }, - { - id: uuidv4(), - channel: '支付宝', - text: '支付宝到账:工资收入 ¥12500.00,来自 Echo Studio', - createdAt: new Date(Date.now() - 1000 * 60 * 32).toISOString(), - }, -] +import { + acknowledgeNotification, + fetchNotificationQueue, + pushLocalNotification, +} from '../services/notificationSourceService' +import { + loadNotificationRules, + transformNotificationToTransaction, +} from '../services/notificationRuleService' const notifications = ref([]) const processingId = ref('') -let seeded = false +const syncing = ref(false) +const rulesLoading = ref(false) +const ruleSet = ref([]) +let bootstrapped = false -const ensureSeeded = () => { - if (!seeded) { - notifications.value = seedNotifications() - seeded = true +const ensureRulesReady = async () => { + if (ruleSet.value.length) return ruleSet.value + rulesLoading.value = true + try { + ruleSet.value = await loadNotificationRules() + return ruleSet.value + } finally { + rulesLoading.value = false } } export const useTransactionEntry = () => { const transactionStore = useTransactionStore() - ensureSeeded() - - const parseNotification = (text) => { - const amountMatch = text.match(/(?:¥|¥)\s?(-?\d+(?:\.\d{1,2})?)/) - const amount = amountMatch ? parseFloat(amountMatch[1]) : 0 - - let merchant = 'Unknown' - const merchantMatch = text.match(/向\s?([\u4e00-\u9fa5A-Za-z0-9\s&]+)/) - if (merchantMatch?.[1]) { - merchant = merchantMatch[1].trim() - } else if (text.includes(':')) { - merchant = text.split(':')[1]?.split(' ')[0]?.trim() || merchant - } - - const isIncome = /收入|到账|received/i.test(text) - const normalizedAmount = isIncome ? Math.abs(amount) : -Math.abs(amount || 0) - - return { - id: uuidv4(), - amount: normalizedAmount, - merchant, - category: inferCategory(merchant), - date: new Date().toISOString(), - note: text, - syncStatus: 'pending', - } - } const removeNotification = (id) => { notifications.value = notifications.value.filter((item) => item.id !== id) @@ -88,37 +40,73 @@ export const useTransactionEntry = () => { if (!target || processingId.value) return processingId.value = id try { + await ensureRulesReady() await transactionStore.ensureInitialized() - const parsed = parseNotification(target.text) - await transactionStore.addTransaction(parsed) + const { transaction } = transformNotificationToTransaction(target, { + ruleSet: ruleSet.value, + date: target.createdAt, + }) + await transactionStore.addTransaction(transaction) removeNotification(id) + await acknowledgeNotification(id) } finally { processingId.value = '' } } - const dismissNotification = (id) => { + const dismissNotification = async (id) => { removeNotification(id) + await acknowledgeNotification(id) } - const simulateNotification = (text) => { - notifications.value = [ - { - id: uuidv4(), - channel: '模拟', - text, - createdAt: new Date().toISOString(), - }, - ...notifications.value, - ] + const syncNotifications = async () => { + if (syncing.value) return + syncing.value = true + try { + await ensureRulesReady() + await transactionStore.ensureInitialized() + const queue = await fetchNotificationQueue() + const manualQueue = [] + for (const item of queue) { + const { transaction, rule, requiresConfirmation } = transformNotificationToTransaction( + item, + { ruleSet: ruleSet.value, date: item.createdAt }, + ) + if (!requiresConfirmation) { + await transactionStore.addTransaction(transaction) + await acknowledgeNotification(item.id) + } else { + manualQueue.push({ + ...item, + suggestion: transaction, + ruleId: rule?.id || null, + }) + } + } + notifications.value = manualQueue + } finally { + syncing.value = false + } + } + + const simulateNotification = async (text) => { + pushLocalNotification({ text }) + await syncNotifications() + } + + if (!bootstrapped) { + bootstrapped = true + syncNotifications() } return { notifications, processingId, - parseNotification, + syncing, + rulesLoading, confirmNotification, dismissNotification, simulateNotification, + syncNotifications, } } diff --git a/src/lib/sqlite.js b/src/lib/sqlite.js index 3cc7828..e9b49d8 100644 --- a/src/lib/sqlite.js +++ b/src/lib/sqlite.js @@ -19,15 +19,50 @@ const TRANSACTION_TABLE_SQL = ` let sqliteConnection let db let initialized = false +let jeepLoaderPromise +let jeepElementReadyPromise + +const waitFor = (ms = 100) => + new Promise((resolve) => { + setTimeout(resolve, ms) + }) + +const ensureStoreReady = async (jeepEl, retries = 5) => { + for (let attempt = 0; attempt < retries; attempt += 1) { + const isOpen = (await jeepEl.isStoreOpen?.()) === true + if (isOpen) return + if (typeof jeepEl.openStore === 'function') { + const opened = await jeepEl.openStore('jeepSqliteStore', 'databases') + if (opened) return + } + await waitFor(200 * (attempt + 1)) + } + throw new Error('jeep-sqlite IndexedDB store 未初始化成功') +} const ensureJeepElement = async () => { - if (!customElements.get('jeep-sqlite')) { - defineJeep(window) + if (!jeepLoaderPromise) { + jeepLoaderPromise = defineJeep(window) } - if (!document.querySelector('jeep-sqlite')) { - const jeepEl = document.createElement('jeep-sqlite') - document.body.appendChild(jeepEl) + await jeepLoaderPromise + + if (!jeepElementReadyPromise) { + jeepElementReadyPromise = (async () => { + let jeepEl = document.querySelector('jeep-sqlite') + if (!jeepEl) { + jeepEl = document.createElement('jeep-sqlite') + jeepEl.setAttribute('auto-save', 'true') + document.body.appendChild(jeepEl) + } + jeepEl.setAttribute('wasm-path', '/assets') + await customElements.whenDefined('jeep-sqlite') + await jeepEl.componentOnReady?.() + return jeepEl + })() } + + const element = await jeepElementReadyPromise + await ensureStoreReady(element) await CapacitorSQLite.initWebStore() } @@ -71,6 +106,16 @@ export const getDb = async () => { return db } +export const saveDbToStore = async () => { + await initSQLite() + if (!sqliteConnection?.saveToStore) return + try { + await sqliteConnection.saveToStore(DB_NAME) + } catch (error) { + console.warn('[sqlite] saveToStore 执行失败,数据将保留在内存中', error) + } +} + export const closeSQLite = async () => { if (db) { await db.close() diff --git a/src/main.js b/src/main.js index 913b149..404fd14 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,12 @@ import App from './App.vue' import router from './router' import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' +import { defineCustomElements as jeepSqliteCE } from 'jeep-sqlite/loader' +import { Capacitor } from '@capacitor/core' + +// 注册 jeep-sqlite 自定义元素并保证 Web 端具备 Capacitor 环境 +window.Capacitor = Capacitor +jeepSqliteCE(window) const app = createApp(App) const pinia = createPinia() diff --git a/src/services/notificationRuleService.js b/src/services/notificationRuleService.js new file mode 100644 index 0000000..436c2cf --- /dev/null +++ b/src/services/notificationRuleService.js @@ -0,0 +1,143 @@ +const fallbackRules = [ + { + id: 'alipay-expense', + label: '支付宝消费', + channels: ['支付宝', 'Alipay'], + keywords: ['支付', '扣款', '支出'], + direction: 'expense', + defaultCategory: 'Food', + autoCapture: false, + merchantPattern: /向\s?([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i, + }, + { + id: 'alipay-income', + label: '支付宝收入', + channels: ['支付宝', 'Alipay'], + keywords: ['到账', '收入'], + direction: 'income', + defaultCategory: 'Income', + autoCapture: true, + merchantPattern: /来自\s?([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i, + }, + { + id: 'wechat-expense', + label: '微信消费', + channels: ['微信', 'WeChat'], + keywords: ['支付', '支出', '扣款'], + direction: 'expense', + defaultCategory: 'Groceries', + autoCapture: false, + merchantPattern: /:([\u4e00-\u9fa5A-Za-z0-9\s&]+)/, + }, + { + id: 'bank-income', + label: '工资到账', + channels: ['招商银行', '中国银行', '建设银行'], + keywords: ['工资', '薪资', '到账'], + direction: 'income', + defaultCategory: 'Income', + autoCapture: true, + }, +] + +let cachedRules = [...fallbackRules] +let remoteRuleFetcher = async () => [] + +/** + * 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际 fetch 函数 + * @param {(currentRules: Array) => Promise} fetcher + */ +export const setRemoteRuleFetcher = (fetcher) => { + remoteRuleFetcher = typeof fetcher === 'function' ? fetcher : remoteRuleFetcher +} + +const dedupeRules = (rules) => { + const bucket = new Map() + fallbackRules.forEach((rule) => bucket.set(rule.id, { ...rule })) + rules.forEach((rule) => { + const prev = bucket.get(rule.id) || {} + bucket.set(rule.id, { ...prev, ...rule }) + }) + return Array.from(bucket.values()) +} + +export const loadNotificationRules = async () => { + try { + const remote = await remoteRuleFetcher(cachedRules) + if (Array.isArray(remote) && remote.length > 0) { + cachedRules = dedupeRules(remote) + } + } catch (err) { + cachedRules = [...cachedRules] + console.warn('[rules] 使用本地规则,远程规则获取失败:', err) + } + return cachedRules +} + +export const getCachedNotificationRules = () => cachedRules + +const extractAmount = (text = '') => { + const match = text.match(/(?:¥|¥)\s?(-?\d+(?:\.\d{1,2})?)/) + if (match?.[1]) { + return Math.abs(parseFloat(match[1])) + } + const loose = text.match(/(-?\d+(?:\.\d{1,2})?)/) + return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0 +} + +const extractMerchant = (text, rule) => { + if (rule?.merchantPattern instanceof RegExp) { + 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 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 + const channel = notification?.channel || '' + const text = notification?.text || '' + const channelHit = !rule.channels?.length || rule.channels.some((item) => channel.includes(item)) + const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word)) + return channelHit && keywordHit +} + +/** + * 根据当前规则集生成交易草稿,并返回命中的规则信息 + * @param {{ id: string, channel: string, text: string, createdAt: string }} notification + * @param {{ ruleSet?: Array, overrides?: object }} options + * @returns {{ transaction: import('../types/transaction').Transaction, rule?: object, requiresConfirmation: boolean }} + */ +export const transformNotificationToTransaction = (notification, options = {}) => { + const ruleSet = options.ruleSet?.length ? options.ruleSet : cachedRules + const rule = ruleSet.find((item) => matchRule(notification, item)) + const amountFromText = extractAmount(notification?.text) + const direction = options.direction || rule?.direction || 'expense' + const normalizedAmount = direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText) + const merchant = + options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown' + const date = options.date || notification?.createdAt || new Date().toISOString() + + const transaction = { + id: options.id, + merchant, + category: options.category || rule?.defaultCategory || 'Uncategorized', + amount: Number.isFinite(normalizedAmount) ? normalizedAmount : 0, + date: new Date(date).toISOString(), + note: notification?.text || '', + syncStatus: 'pending', + } + + const requiresConfirmation = + options.requiresConfirmation ?? + !(rule?.autoCapture && transaction.amount !== 0 && merchant !== 'Unknown') + + return { transaction, rule, requiresConfirmation } +} diff --git a/src/services/notificationSourceService.js b/src/services/notificationSourceService.js new file mode 100644 index 0000000..2fb7137 --- /dev/null +++ b/src/services/notificationSourceService.js @@ -0,0 +1,64 @@ +import { v4 as uuidv4 } from 'uuid' + +let remoteNotificationFetcher = async () => [] +let remoteNotificationAcknowledger = async () => {} + +/** + * 本地模拟通知缓存,真实环境可以由原生插件 push + */ +let localNotificationQueue = [ + { + id: uuidv4(), + channel: '支付宝', + text: '支付宝提醒:你向 瑞幸咖啡 支付 ¥18.00', + createdAt: new Date().toISOString(), + }, + { + id: uuidv4(), + channel: '微信支付', + text: '微信支付:美团外卖 实付 ¥46.50 · 订单已送达', + createdAt: new Date(Date.now() - 1000 * 60 * 8).toISOString(), + }, + { + id: uuidv4(), + channel: '招商银行', + text: '招商银行:工资收入 ¥12500.00 已到账', + createdAt: new Date(Date.now() - 1000 * 60 * 32).toISOString(), + }, +] + +export const setRemoteNotificationFetcher = (fetcher) => { + remoteNotificationFetcher = typeof fetcher === 'function' ? fetcher : remoteNotificationFetcher +} + +export const setRemoteNotificationAcknowledger = (fn) => { + remoteNotificationAcknowledger = typeof fn === 'function' ? fn : remoteNotificationAcknowledger +} + +const sortByCreatedAtDesc = (a, b) => + new Date(b.createdAt || b.id) - new Date(a.createdAt || a.id) + +export const fetchNotificationQueue = async () => { + const remote = await remoteNotificationFetcher() + const composed = [...localNotificationQueue, ...(Array.isArray(remote) ? remote : [])] + return composed.sort(sortByCreatedAtDesc) +} + +export const pushLocalNotification = (payload) => { + const entry = { + id: payload?.id || uuidv4(), + createdAt: payload?.createdAt || new Date().toISOString(), + channel: payload?.channel || '模拟', + text: payload?.text || '', + } + localNotificationQueue = [entry, ...localNotificationQueue] +} + +export const acknowledgeNotification = async (id) => { + localNotificationQueue = localNotificationQueue.filter((item) => item.id !== id) + try { + await remoteNotificationAcknowledger(id) + } catch (error) { + console.warn('[notifications] 远程确认失败,将在下次同步重试', error) + } +} diff --git a/src/services/transactionService.js b/src/services/transactionService.js index 267d506..ca9f51f 100644 --- a/src/services/transactionService.js +++ b/src/services/transactionService.js @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from 'uuid' -import { getDb } from '../lib/sqlite' +import { getDb, saveDbToStore } from '../lib/sqlite' const statusMap = { pending: 0, @@ -63,6 +63,8 @@ export const insertTransaction = async (payload) => { ], ) + await saveDbToStore() + return { id, ...sanitized, @@ -89,6 +91,8 @@ export const updateTransaction = async (payload) => { ], ) + await saveDbToStore() + const result = await db.query( ` SELECT id, amount, merchant, category, date, note, sync_status @@ -105,4 +109,5 @@ export const updateTransaction = async (payload) => { export const deleteTransaction = async (id) => { const db = await getDb() await db.run(`DELETE FROM transactions WHERE id = ?`, [id]) + await saveDbToStore() }