fix:数据库错误
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"jeep-sqlite": "^2.7.1",
|
"jeep-sqlite": "^2.7.1",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"pinia-plugin-persistedstate": "^3.2.0",
|
"pinia-plugin-persistedstate": "^3.2.0",
|
||||||
|
"sql.js": "^1.11.0",
|
||||||
"uuid": "^11.0.2",
|
"uuid": "^11.0.2",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
@@ -2362,9 +2363,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sql.js": {
|
"node_modules/sql.js": {
|
||||||
"version": "1.13.0",
|
"version": "1.11.0",
|
||||||
"resolved": "https://registry.npmmirror.com/sql.js/-/sql.js-1.13.0.tgz",
|
"resolved": "https://registry.npmmirror.com/sql.js/-/sql.js-1.11.0.tgz",
|
||||||
"integrity": "sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==",
|
"integrity": "sha512-GsLUDU3vhOo14Pd5ME0y2te49JQyby6HuoCuadevEV+CGgTUjmYRrm7B7lhRyzOgrmcWmspUfyjNb6sOAEqdsA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host 0.0.0.0",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"jeep-sqlite": "^2.7.1",
|
"jeep-sqlite": "^2.7.1",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"pinia-plugin-persistedstate": "^3.2.0",
|
"pinia-plugin-persistedstate": "^3.2.0",
|
||||||
|
"sql.js": "^1.11.0",
|
||||||
"uuid": "^11.0.2",
|
"uuid": "^11.0.2",
|
||||||
"vue": "^3.5.24",
|
"vue": "^3.5.24",
|
||||||
"vue-router": "^4.6.3"
|
"vue-router": "^4.6.3"
|
||||||
|
|||||||
BIN
public/assets/sql-wasm.wasm
Normal file
BIN
public/assets/sql-wasm.wasm
Normal file
Binary file not shown.
@@ -1,83 +1,35 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
import { useTransactionStore } from '../stores/transactions'
|
import { useTransactionStore } from '../stores/transactions'
|
||||||
|
import {
|
||||||
const CATEGORY_HINTS = [
|
acknowledgeNotification,
|
||||||
{ keywords: ['咖啡', '奶茶', '星巴克', 'luckin', 'coffee', 'familymart', '7-eleven'], category: 'Food' },
|
fetchNotificationQueue,
|
||||||
{ keywords: ['健身', 'gym', '运动', '瑜伽'], category: 'Health' },
|
pushLocalNotification,
|
||||||
{ keywords: ['滴滴', '打车', '出租', '地铁', '出行'], category: 'Transport' },
|
} from '../services/notificationSourceService'
|
||||||
{ keywords: ['超市', '盒马', 'sam', '山姆'], category: 'Groceries' },
|
import {
|
||||||
{ keywords: ['工资', 'salary', 'bonus', '收入'], category: 'Income' },
|
loadNotificationRules,
|
||||||
]
|
transformNotificationToTransaction,
|
||||||
|
} from '../services/notificationRuleService'
|
||||||
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(),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const notifications = ref([])
|
const notifications = ref([])
|
||||||
const processingId = ref('')
|
const processingId = ref('')
|
||||||
let seeded = false
|
const syncing = ref(false)
|
||||||
|
const rulesLoading = ref(false)
|
||||||
|
const ruleSet = ref([])
|
||||||
|
let bootstrapped = false
|
||||||
|
|
||||||
const ensureSeeded = () => {
|
const ensureRulesReady = async () => {
|
||||||
if (!seeded) {
|
if (ruleSet.value.length) return ruleSet.value
|
||||||
notifications.value = seedNotifications()
|
rulesLoading.value = true
|
||||||
seeded = true
|
try {
|
||||||
|
ruleSet.value = await loadNotificationRules()
|
||||||
|
return ruleSet.value
|
||||||
|
} finally {
|
||||||
|
rulesLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useTransactionEntry = () => {
|
export const useTransactionEntry = () => {
|
||||||
const transactionStore = useTransactionStore()
|
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) => {
|
const removeNotification = (id) => {
|
||||||
notifications.value = notifications.value.filter((item) => item.id !== id)
|
notifications.value = notifications.value.filter((item) => item.id !== id)
|
||||||
@@ -88,37 +40,73 @@ export const useTransactionEntry = () => {
|
|||||||
if (!target || processingId.value) return
|
if (!target || processingId.value) return
|
||||||
processingId.value = id
|
processingId.value = id
|
||||||
try {
|
try {
|
||||||
|
await ensureRulesReady()
|
||||||
await transactionStore.ensureInitialized()
|
await transactionStore.ensureInitialized()
|
||||||
const parsed = parseNotification(target.text)
|
const { transaction } = transformNotificationToTransaction(target, {
|
||||||
await transactionStore.addTransaction(parsed)
|
ruleSet: ruleSet.value,
|
||||||
|
date: target.createdAt,
|
||||||
|
})
|
||||||
|
await transactionStore.addTransaction(transaction)
|
||||||
removeNotification(id)
|
removeNotification(id)
|
||||||
|
await acknowledgeNotification(id)
|
||||||
} finally {
|
} finally {
|
||||||
processingId.value = ''
|
processingId.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dismissNotification = (id) => {
|
const dismissNotification = async (id) => {
|
||||||
removeNotification(id)
|
removeNotification(id)
|
||||||
|
await acknowledgeNotification(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const simulateNotification = (text) => {
|
const syncNotifications = async () => {
|
||||||
notifications.value = [
|
if (syncing.value) return
|
||||||
{
|
syncing.value = true
|
||||||
id: uuidv4(),
|
try {
|
||||||
channel: '模拟',
|
await ensureRulesReady()
|
||||||
text,
|
await transactionStore.ensureInitialized()
|
||||||
createdAt: new Date().toISOString(),
|
const queue = await fetchNotificationQueue()
|
||||||
},
|
const manualQueue = []
|
||||||
...notifications.value,
|
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 {
|
return {
|
||||||
notifications,
|
notifications,
|
||||||
processingId,
|
processingId,
|
||||||
parseNotification,
|
syncing,
|
||||||
|
rulesLoading,
|
||||||
confirmNotification,
|
confirmNotification,
|
||||||
dismissNotification,
|
dismissNotification,
|
||||||
simulateNotification,
|
simulateNotification,
|
||||||
|
syncNotifications,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,50 @@ const TRANSACTION_TABLE_SQL = `
|
|||||||
let sqliteConnection
|
let sqliteConnection
|
||||||
let db
|
let db
|
||||||
let initialized = false
|
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 () => {
|
const ensureJeepElement = async () => {
|
||||||
if (!customElements.get('jeep-sqlite')) {
|
if (!jeepLoaderPromise) {
|
||||||
defineJeep(window)
|
jeepLoaderPromise = defineJeep(window)
|
||||||
}
|
}
|
||||||
if (!document.querySelector('jeep-sqlite')) {
|
await jeepLoaderPromise
|
||||||
const jeepEl = document.createElement('jeep-sqlite')
|
|
||||||
|
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)
|
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()
|
await CapacitorSQLite.initWebStore()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +106,16 @@ export const getDb = async () => {
|
|||||||
return db
|
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 () => {
|
export const closeSQLite = async () => {
|
||||||
if (db) {
|
if (db) {
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import App from './App.vue'
|
|||||||
import router from './router'
|
import router from './router'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
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 app = createApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
|||||||
143
src/services/notificationRuleService.js
Normal file
143
src/services/notificationRuleService.js
Normal file
@@ -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<Array>} 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 }
|
||||||
|
}
|
||||||
64
src/services/notificationSourceService.js
Normal file
64
src/services/notificationSourceService.js
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
import { getDb } from '../lib/sqlite'
|
import { getDb, saveDbToStore } from '../lib/sqlite'
|
||||||
|
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
pending: 0,
|
pending: 0,
|
||||||
@@ -63,6 +63,8 @@ export const insertTransaction = async (payload) => {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await saveDbToStore()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
...sanitized,
|
...sanitized,
|
||||||
@@ -89,6 +91,8 @@ export const updateTransaction = async (payload) => {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await saveDbToStore()
|
||||||
|
|
||||||
const result = await db.query(
|
const result = await db.query(
|
||||||
`
|
`
|
||||||
SELECT id, amount, merchant, category, date, note, sync_status
|
SELECT id, amount, merchant, category, date, note, sync_status
|
||||||
@@ -105,4 +109,5 @@ export const updateTransaction = async (payload) => {
|
|||||||
export const deleteTransaction = async (id) => {
|
export const deleteTransaction = async (id) => {
|
||||||
const db = await getDb()
|
const db = await getDb()
|
||||||
await db.run(`DELETE FROM transactions WHERE id = ?`, [id])
|
await db.run(`DELETE FROM transactions WHERE id = ?`, [id])
|
||||||
|
await saveDbToStore()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user