+ {{ record.title }} +
+ ++ {{ record.text || copy.noBody }} +
+ ++ {{ record.errorMessage }} +
+From 601cbd7576db61c84f79b1418a664cbeabf8d01d Mon Sep 17 00:00:00 2001 From: Jafeng <2998840497@qq.com> Date: Fri, 13 Mar 2026 10:00:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9E=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E8=B0=83=E8=AF=95=E5=8F=B0=E5=92=8C=E5=AF=BC=E5=85=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/BottomDock.vue | 2 +- src/composables/useTransactionEntry.js | 97 ++++-- src/lib/sqlite.js | 15 + src/router/index.js | 16 +- src/services/dataTransferService.js | 93 ++++++ src/services/notificationDebugService.js | 154 ++++++++++ src/services/notificationSourceService.js | 32 +- src/stores/ui.js | 2 +- src/style.css | 15 + src/views/AddEntryView.vue | 14 +- src/views/AnalysisView.vue | 4 +- src/views/ImportHistoryView.vue | 317 +++++++++++++++++++ src/views/ListView.vue | 18 +- src/views/NotificationDebugView.vue | 357 ++++++++++++++++++++++ src/views/SettingsView.vue | 48 ++- 15 files changed, 1111 insertions(+), 73 deletions(-) create mode 100644 src/services/notificationDebugService.js create mode 100644 src/views/ImportHistoryView.vue create mode 100644 src/views/NotificationDebugView.vue diff --git a/src/components/BottomDock.vue b/src/components/BottomDock.vue index 1075cf1..9e4aa6d 100644 --- a/src/components/BottomDock.vue +++ b/src/components/BottomDock.vue @@ -8,7 +8,7 @@ const route = useRoute() const router = useRouter() const uiStore = useUiStore() -const currentTab = computed(() => route.name) +const currentTab = computed(() => route.meta?.tab || route.name) const goTab = (name) => { if (route.name === name) return diff --git a/src/composables/useTransactionEntry.js b/src/composables/useTransactionEntry.js index c672546..6987b62 100644 --- a/src/composables/useTransactionEntry.js +++ b/src/composables/useTransactionEntry.js @@ -11,6 +11,11 @@ import { } from '../services/notificationRuleService' import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge' import { useSettingsStore } from '../stores/settings' +import { + ensureNotificationRaw, + NOTIFICATION_DEBUG_STATUS, + updateNotificationRawStatus, +} from '../services/notificationDebugService.js' const notifications = ref([]) const processingId = ref('') @@ -40,7 +45,6 @@ export const useTransactionEntry = () => { const transactionStore = useTransactionStore() const settingsStore = useSettingsStore() - // 原生端:在首次同步前做一次权限校验与请求 const ensureNativePermission = async () => { if (!nativeBridgeReady || permissionChecked) return try { @@ -49,7 +53,7 @@ export const useTransactionEntry = () => { await NotificationBridge.requestPermission() } } catch (error) { - console.warn('[notifications] 原生权限检查失败', error) + console.warn('[notifications] native permission check failed', error) } finally { permissionChecked = true } @@ -62,15 +66,25 @@ export const useTransactionEntry = () => { const confirmNotification = async (id) => { const target = notifications.value.find((item) => item.id === id) if (!target || processingId.value) return + processingId.value = id try { await ensureRulesReady() await transactionStore.ensureInitialized() - const { transaction } = transformNotificationToTransaction(target, { + + const { transaction, rule } = transformNotificationToTransaction(target, { ruleSet: ruleSet.value, date: target.createdAt, }) - await transactionStore.addTransaction(transaction) + + const created = await transactionStore.addTransaction(transaction) + await updateNotificationRawStatus(id, { + status: NOTIFICATION_DEBUG_STATUS.matched, + ruleId: target.ruleId || rule?.id || '', + transactionId: created?.id || '', + errorMessage: null, + }) + removeNotification(id) await acknowledgeNotification(id) } finally { @@ -79,64 +93,87 @@ export const useTransactionEntry = () => { } const dismissNotification = async (id) => { + await updateNotificationRawStatus(id, { + status: NOTIFICATION_DEBUG_STATUS.ignored, + errorMessage: '\u7528\u6237\u624b\u52a8\u5ffd\u7565', + }) removeNotification(id) await acknowledgeNotification(id) } const syncNotifications = async () => { if (syncing.value) return + syncing.value = true try { - // 若用户关闭了通知自动捕获,则不从原生/模拟队列拉取新通知,只清空待确认列表 if (!settingsStore.notificationCaptureEnabled) { notifications.value = [] return } + if (nativeBridgeReady) { await ensureNativePermission() } + await ensureRulesReady() await transactionStore.ensureInitialized() + const queue = await fetchNotificationQueue() const manualQueue = [] + for (const item of queue) { + await ensureNotificationRaw(item, { + status: NOTIFICATION_DEBUG_STATUS.unmatched, + }) + const { transaction, rule, requiresConfirmation } = transformNotificationToTransaction(item, { ruleSet: ruleSet.value, date: item.createdAt, }) - // 如果没有任何规则命中(rule 为空),默认忽略该通知,避免系统通知等噪音进入待确认列表 if (!rule) { + await updateNotificationRawStatus(item.id, { + status: NOTIFICATION_DEBUG_STATUS.unmatched, + errorMessage: '\u672a\u547d\u4e2d\u4efb\u4f55\u901a\u77e5\u89c4\u5219', + }) await acknowledgeNotification(item.id) continue } - if (nativeBridgeReady) { - // 原生环境:规则在 Kotlin 原生层已经执行并写入 SQLite,这里只负责「待确认」列表和状态同步 - if (!requiresConfirmation) { - // 已由原生自动入账,只需从原生通知队列中移除该条 - await acknowledgeNotification(item.id) + if (!requiresConfirmation) { + if (!nativeBridgeReady) { + const created = await transactionStore.addTransaction(transaction) + await updateNotificationRawStatus(item.id, { + status: NOTIFICATION_DEBUG_STATUS.matched, + ruleId: rule?.id || '', + transactionId: created?.id || '', + errorMessage: null, + }) } else { - manualQueue.push({ - ...item, - suggestion: transaction, - ruleId: rule?.id || null, - }) - } - } else { - // Web / 模拟环境:沿用原有逻辑,由 JS 侧写入 SQLite - if (!requiresConfirmation) { - await transactionStore.addTransaction(transaction) - await acknowledgeNotification(item.id) - } else { - manualQueue.push({ - ...item, - suggestion: transaction, - ruleId: rule?.id || null, + await updateNotificationRawStatus(item.id, { + status: NOTIFICATION_DEBUG_STATUS.matched, + ruleId: rule?.id || '', + errorMessage: null, }) } + + await acknowledgeNotification(item.id) + continue } + + await updateNotificationRawStatus(item.id, { + status: NOTIFICATION_DEBUG_STATUS.review, + ruleId: rule?.id || '', + errorMessage: null, + }) + + manualQueue.push({ + ...item, + suggestion: transaction, + ruleId: rule?.id || null, + }) } + notifications.value = manualQueue } finally { syncing.value = false @@ -150,9 +187,8 @@ export const useTransactionEntry = () => { if (!bootstrapped) { bootstrapped = true - syncNotifications() + void syncNotifications() - // 原生环境:监听 NotificationBridgePlugin 推送的 notificationPosted 事件,做到“通知一到就同步一次” if (nativeBridgeReady && !nativeNotificationListener && typeof NotificationBridge?.addListener === 'function') { NotificationBridge.addListener('notificationPosted', async () => { await syncNotifications() @@ -161,11 +197,10 @@ export const useTransactionEntry = () => { nativeNotificationListener = handle }) .catch((error) => { - console.warn('[notifications] 无法注册 notificationPosted 监听', error) + console.warn('[notifications] failed to register notificationPosted listener', error) }) } - // 前台轮询兜底:即使偶发漏掉原生事件,也能定期从队列补拉一次 if (!pollTimer && typeof setInterval === 'function') { pollTimer = setInterval(() => { if (!syncing.value) { diff --git a/src/lib/sqlite.js b/src/lib/sqlite.js index 3824f58..23e972d 100644 --- a/src/lib/sqlite.js +++ b/src/lib/sqlite.js @@ -47,6 +47,20 @@ const MERCHANT_PROFILE_TABLE_SQL = ` updated_at TEXT NOT NULL ); ` +const NOTIFICATION_RAW_TABLE_SQL = ` + CREATE TABLE IF NOT EXISTS notifications_raw ( + id TEXT PRIMARY KEY NOT NULL, + source_id TEXT, + channel TEXT, + title TEXT, + text TEXT, + posted_at TEXT NOT NULL, + status TEXT NOT NULL, + rule_id TEXT, + transaction_id TEXT, + error_message TEXT + ); +` const IMPORT_BATCH_TABLE_SQL = ` CREATE TABLE IF NOT EXISTS import_batches ( id TEXT PRIMARY KEY NOT NULL, @@ -186,6 +200,7 @@ const prepareConnection = async () => { await db.open() await db.execute(TRANSACTION_TABLE_SQL) await db.execute(MERCHANT_PROFILE_TABLE_SQL) + await db.execute(NOTIFICATION_RAW_TABLE_SQL) await db.execute(IMPORT_BATCH_TABLE_SQL) await db.execute(IMPORT_LINE_TABLE_SQL) await ensureTableColumns('transactions', TRANSACTION_REQUIRED_COLUMNS) diff --git a/src/router/index.js b/src/router/index.js index d146244..0f65d72 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -3,7 +3,8 @@ import HomeView from '../views/HomeView.vue' import ListView from '../views/ListView.vue' import SettingsView from '../views/SettingsView.vue' -// TODO: 后续补充真实的分析页实现,这里先占位 +const ImportHistoryView = () => import('../views/ImportHistoryView.vue') +const NotificationDebugView = () => import('../views/NotificationDebugView.vue') const AnalysisView = () => import('../views/AnalysisView.vue').catch(() => null) const router = createRouter({ @@ -33,8 +34,19 @@ const router = createRouter({ component: SettingsView, meta: { tab: 'settings' }, }, + { + path: '/imports', + name: 'imports', + component: ImportHistoryView, + meta: { tab: 'settings' }, + }, + { + path: '/notifications', + name: 'notifications', + component: NotificationDebugView, + meta: { tab: 'settings' }, + }, ], }) export default router - diff --git a/src/services/dataTransferService.js b/src/services/dataTransferService.js index bccf610..0de1340 100644 --- a/src/services/dataTransferService.js +++ b/src/services/dataTransferService.js @@ -493,3 +493,96 @@ export const exportTransactionsAsJson = async () => { ) return transactions.length } + +const parseSummaryJson = (value) => { + if (!value) return null + try { + return JSON.parse(value) + } catch { + return null + } +} + +export const fetchImportBatches = async (limit = 20) => { + const db = await getDb() + const safeLimit = Math.max(1, Math.min(Number(limit) || 20, 100)) + const result = await db.query( + ` + SELECT + id, + source, + file_name, + imported_at, + total_count, + inserted_count, + merged_count, + skipped_count, + status, + summary_json + FROM import_batches + ORDER BY imported_at DESC + LIMIT ? + `, + [safeLimit], + ) + + return (result?.values || []).map((row) => ({ + id: row.id, + source: row.source || '', + fileName: row.file_name || '', + importedAt: row.imported_at || '', + totalCount: Number(row.total_count || 0), + insertedCount: Number(row.inserted_count || 0), + mergedCount: Number(row.merged_count || 0), + skippedCount: Number(row.skipped_count || 0), + status: row.status || 'completed', + summary: parseSummaryJson(row.summary_json), + })) +} + +export const fetchImportBatchLines = async (batchId, status = 'all') => { + if (!batchId) return [] + + const db = await getDb() + const params = [batchId] + const statusClause = status !== 'all' ? 'AND status = ?' : '' + if (status !== 'all') { + params.push(status) + } + + const result = await db.query( + ` + SELECT + id, + batch_id, + source_order_id, + merchant, + amount, + occurred_at, + status, + transaction_id, + reason, + raw_data, + created_at + FROM import_lines + WHERE batch_id = ? + ${statusClause} + ORDER BY occurred_at DESC, created_at DESC + `, + params, + ) + + return (result?.values || []).map((row) => ({ + id: row.id, + batchId: row.batch_id, + sourceOrderId: row.source_order_id || '', + merchant: row.merchant || '', + amount: Number(row.amount || 0), + occurredAt: row.occurred_at || '', + status: row.status || 'skipped', + transactionId: row.transaction_id || '', + reason: row.reason || '', + rawData: parseSummaryJson(row.raw_data) || {}, + createdAt: row.created_at || '', + })) +} diff --git a/src/services/notificationDebugService.js b/src/services/notificationDebugService.js new file mode 100644 index 0000000..25fdf9c --- /dev/null +++ b/src/services/notificationDebugService.js @@ -0,0 +1,154 @@ +import { v4 as uuidv4 } from 'uuid' +import { getDb, saveDbToStore } from '../lib/sqlite.js' + +export const NOTIFICATION_DEBUG_STATUS = { + review: 'review', + unmatched: 'unmatched', + matched: 'matched', + ignored: 'ignored', + error: 'error', +} + +const mapRow = (row) => ({ + id: row.id, + sourceId: row.source_id || '', + channel: row.channel || '', + title: row.title || '', + text: row.text || '', + postedAt: row.posted_at || '', + status: row.status || NOTIFICATION_DEBUG_STATUS.unmatched, + ruleId: row.rule_id || '', + transactionId: row.transaction_id || '', + errorMessage: row.error_message || '', +}) + +const getLatestBySourceId = async (sourceId) => { + if (!sourceId) return null + const db = await getDb() + const result = await db.query( + ` + SELECT * + FROM notifications_raw + WHERE source_id = ? + ORDER BY posted_at DESC + LIMIT 1 + `, + [sourceId], + ) + return result?.values?.[0] ? mapRow(result.values[0]) : null +} + +export const ensureNotificationRaw = async (notification, options = {}) => { + const sourceId = notification?.id || notification?.sourceId || '' + const existing = await getLatestBySourceId(sourceId) + if (existing) return existing + + const db = await getDb() + const id = notification?.rawId || uuidv4() + await db.run( + ` + INSERT INTO notifications_raw ( + id, + source_id, + channel, + title, + text, + posted_at, + status, + rule_id, + transaction_id, + error_message + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + id, + sourceId || null, + notification?.channel || null, + notification?.title || null, + notification?.text || '', + notification?.createdAt || notification?.postedAt || new Date().toISOString(), + options.status || NOTIFICATION_DEBUG_STATUS.unmatched, + options.ruleId || null, + options.transactionId || null, + options.errorMessage || null, + ], + ) + await saveDbToStore() + return { + id, + sourceId, + channel: notification?.channel || '', + title: notification?.title || '', + text: notification?.text || '', + postedAt: notification?.createdAt || notification?.postedAt || '', + status: options.status || NOTIFICATION_DEBUG_STATUS.unmatched, + ruleId: options.ruleId || '', + transactionId: options.transactionId || '', + errorMessage: options.errorMessage || '', + } +} + +export const updateNotificationRawStatus = async (sourceId, next = {}) => { + if (!sourceId) return null + const existing = await getLatestBySourceId(sourceId) + if (!existing) return null + + const db = await getDb() + await db.run( + ` + UPDATE notifications_raw + SET status = ?, rule_id = ?, transaction_id = ?, error_message = ? + WHERE id = ? + `, + [ + next.status || existing.status, + next.ruleId ?? existing.ruleId ?? null, + next.transactionId ?? existing.transactionId ?? null, + next.errorMessage ?? existing.errorMessage ?? null, + existing.id, + ], + ) + await saveDbToStore() + return { + ...existing, + status: next.status || existing.status, + ruleId: next.ruleId ?? existing.ruleId, + transactionId: next.transactionId ?? existing.transactionId, + errorMessage: next.errorMessage ?? existing.errorMessage, + } +} + +export const fetchNotificationDebugRecords = async (options = {}) => { + const db = await getDb() + const params = [] + const where = [] + + if (options.status && options.status !== 'all') { + where.push('status = ?') + params.push(options.status) + } + + if (options.keyword) { + where.push('(channel LIKE ? OR title LIKE ? OR text LIKE ?)') + const keyword = `%${String(options.keyword).trim()}%` + params.push(keyword, keyword, keyword) + } + + const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '' + const limit = Math.max(1, Math.min(Number(options.limit) || 80, 200)) + params.push(limit) + + const result = await db.query( + ` + SELECT * + FROM notifications_raw + ${whereClause} + ORDER BY posted_at DESC + LIMIT ? + `, + params, + ) + + return (result?.values || []).map(mapRow) +} diff --git a/src/services/notificationSourceService.js b/src/services/notificationSourceService.js index 1b76e32..86d80bb 100644 --- a/src/services/notificationSourceService.js +++ b/src/services/notificationSourceService.js @@ -3,17 +3,17 @@ import NotificationBridge, { isNativeNotificationBridgeAvailable, } from '../lib/notificationBridge' -// 原生插件回调,可按需在应用启动时注入,例如用于上报到服务端 +// 原生插件回调,可按需在应用启动时注入,例如后续接服务端同步。 let remoteNotificationFetcher = async () => [] let remoteNotificationAcknowledger = async () => {} -// 判断当前是否为原生环境(Android 容器内) +// 判断当前是否在原生容器内。 const nativeBridgeReady = isNativeNotificationBridgeAvailable() /** * 本地模拟通知缓存: - * - 浏览器环境:用于 Demo & Mock,可通过 pushLocalNotification 主动注入 - * - 原生环境:一般保持为空,仅在必要时作为 NotificationBridge 数据的补充兜底 + * - 浏览器环境:用于规则调试和 Demo。 + * - 原生环境:通常保持为空,仅在必要时补充桥接层数据。 */ let localNotificationQueue = [] @@ -29,9 +29,9 @@ const sortByCreatedAtDesc = (a, b) => new Date(b.createdAt || b.id) - new Date(a.createdAt || a.id) /** - * 统一获取待处理通知队列: - * - 原生环境:优先从 NotificationBridge 中拿未处理通知 - * - 浏览器:仅使用本地模拟通知 + * 统一读取待处理通知队列。 + * - 原生环境:优先读取 NotificationBridge 中的待处理通知。 + * - 浏览器环境:仅使用本地模拟通知。 */ export const fetchNotificationQueue = async () => { const remote = await remoteNotificationFetcher() @@ -40,20 +40,21 @@ export const fetchNotificationQueue = async () => { } /** - * 浏览器环境下模拟「新通知」进入队列 + * 浏览器环境下主动注入一条模拟通知。 */ export const pushLocalNotification = (payload) => { const entry = { id: payload?.id || uuidv4(), createdAt: payload?.createdAt || new Date().toISOString(), - channel: payload?.channel || '模拟', + channel: payload?.channel || '\u6a21\u62df\u901a\u77e5', + title: payload?.title || '', text: payload?.text || '', } localNotificationQueue = [entry, ...localNotificationQueue] } /** - * 确认/忽略某条通知后,从本地队列中移除,并尝试同步到远端 + * 确认或忽略通知后,从本地队列移除,并尝试同步给原生层。 */ export const acknowledgeNotification = async (id) => { if (id) { @@ -62,32 +63,27 @@ export const acknowledgeNotification = async (id) => { try { await remoteNotificationAcknowledger(id) } catch (error) { - console.warn('[notifications] 远程通知确认失败,将在下次同步重试', error) + console.warn('[notifications] remote acknowledgement failed, will retry on next sync', error) } } -// ===== 原生桥接(Android NotificationListenerService)集成 ===== - if (nativeBridgeReady) { - // 从原生层拉取待处理通知 setRemoteNotificationFetcher(async () => { try { const { notifications = [] } = await NotificationBridge.getPendingNotifications() return notifications } catch (error) { - console.warn('[notifications] 获取原生通知失败,退回本地模拟数据', error) + console.warn('[notifications] failed to fetch native notifications, fallback to local queue', error) return [] } }) - // 通知已确认/忽略后,告知原生层清除对应记录 setRemoteNotificationAcknowledger(async (id) => { if (!id) return try { await NotificationBridge.acknowledgeNotification({ id }) } catch (error) { - console.warn('[notifications] 通知确认同步至原生失败', error) + console.warn('[notifications] failed to acknowledge native notification', error) } }) } - diff --git a/src/stores/ui.js b/src/stores/ui.js index f8c524b..91ffef5 100644 --- a/src/stores/ui.js +++ b/src/stores/ui.js @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -// 管理全局 UI 状态,例如记一笔弹层的开关和当前编辑项。 +// 管理全局 UI 状态,例如记一笔弹层开关和当前编辑记录。 export const useUiStore = defineStore('ui', () => { const addEntryVisible = ref(false) const editingTransactionId = ref('') diff --git a/src/style.css b/src/style.css index 72e2384..a85f5d4 100644 --- a/src/style.css +++ b/src/style.css @@ -47,4 +47,19 @@ .slide-up-leave-to { transform: translateY(100%); } + + .ui-chip-text { + display: inline-flex; + align-items: center; + line-height: 1; + transform: translateY(0.5px); + } + + .ui-chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + transform: translateY(0.5px); + } } diff --git a/src/views/AddEntryView.vue b/src/views/AddEntryView.vue index da42c5e..2b8ac11 100644 --- a/src/views/AddEntryView.vue +++ b/src/views/AddEntryView.vue @@ -200,7 +200,7 @@ const sheetStyle = computed(() => {
{{ copy.section }}
++ {{ selectedBatch.fileName || copy.alipayFallback }} / {{ formatTime(selectedBatch.importedAt) }} +
+{{ copy.inserted }}
+{{ selectedBatch.insertedCount }}
+{{ copy.merged }}
+{{ selectedBatch.mergedCount }}
+{{ copy.skipped }}
+{{ selectedBatch.skippedCount }}
+{{ copy.skippedReasons }}
++ {{ selectedBatch.summary.skippedReasons.slice(0, 3).join('\uFF1B') }} +
+{{ line.merchant || copy.noMerchant }}
+ + {{ statusMetaMap[line.status]?.label || line.status }} + ++ {{ formatTime(line.occurredAt) }} + / {{ copy.orderPrefix }}{{ line.sourceOrderId }} +
+{{ line.reason || copy.noReason }}
+{{ formatAmount(line.amount) }}
+ +
diff --git a/src/views/NotificationDebugView.vue b/src/views/NotificationDebugView.vue
new file mode 100644
index 0000000..d859ac4
--- /dev/null
+++ b/src/views/NotificationDebugView.vue
@@ -0,0 +1,357 @@
+
+
+
+ {{ copy.section }} {{ copy.recentRecords }} {{ statusCounts.total }} {{ copy.pendingManual }} {{ pendingManualCount }} {{ copy.autoMatched }} {{ statusCounts.matched }} {{ copy.intro }} {{ pendingHint }} {{ copy.noPendingHint }} {{ copy.browserDebug }}
+ {{ copy.browserHint }}
+
+ {{ record.title }}
+
+ {{ record.text || copy.noBody }}
+
+ {{ record.errorMessage }}
+ {{ copy.title }}
+