Compare commits

4 Commits

Author SHA1 Message Date
655e3f8879 chore: release v0.5.1 2026-03-13 10:37:36 +08:00
2b2d56520f fix:修复通知调试状态和重复命中问题 2026-03-13 10:35:00 +08:00
c8b30319e3 chore: release v0.5.0 2026-03-13 10:10:42 +08:00
601cbd7576 feat:新增通知调试台和导入历史页 2026-03-13 10:00:21 +08:00
23 changed files with 1241 additions and 165 deletions

View File

@@ -9,8 +9,8 @@ android {
applicationId "com.echo.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 400
versionName "0.4.0"
versionCode 501
versionName "0.5.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {

View File

@@ -1,4 +1,4 @@
package com.echo.app.notification
package com.echo.app.notification
import android.content.Context
import android.database.Cursor
@@ -118,6 +118,14 @@ object NotificationDb {
}
}
private fun hasRawNotification(db: SQLiteDatabase, sourceId: String): Boolean {
if (sourceId.isBlank()) return false
val cursor = db.rawQuery("SELECT 1 FROM notifications_raw WHERE source_id = ? LIMIT 1", arrayOf(sourceId))
cursor.use {
return it.moveToFirst()
}
}
private fun ensureSchema(db: SQLiteDatabase) {
if (schemaInitialized) return
db.execSQL(SQL_CREATE_TRANSACTIONS)
@@ -140,6 +148,10 @@ object NotificationDb {
val db = getHelper(context).writableDatabase
ensureSchema(db)
if (hasRawNotification(db, raw.sourceId)) {
return
}
val rawId = UUID.randomUUID().toString()
var status = "unmatched"
var ruleId: String? = null
@@ -174,6 +186,8 @@ object NotificationDb {
val merchantKnown = parsed.merchant.isNotBlank() && parsed.merchant != "Unknown"
val requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown)
status = if (requiresConfirmation) "review" else "matched"
if (!requiresConfirmation) {
val signedAmount = if (parsed.isExpense) -abs(parsed.amount) else abs(parsed.amount)
transactionId = UUID.randomUUID().toString()

View File

@@ -126,7 +126,7 @@ internal object NotificationRuleData {
impactIncome = false,
defaultCategory = "Expense",
autoCapture = false,
merchantPattern = Regex("(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
merchantPattern = Regex("(?:在【([^】]+)】发生|商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)?"),
defaultMerchant = null,
),
NotificationRuleDefinition(

View File

@@ -1,4 +1,4 @@
package com.echo.app.notification
package com.echo.app.notification
import kotlin.math.abs
@@ -156,7 +156,7 @@ object NotificationRuleEngine {
rule.merchantPattern?.let { regex ->
val match = regex.find(content)
if (match != null && match.groupValues.size > 1) {
val candidate = match.groupValues[1].trim()
val candidate = match.groupValues.drop(1).firstOrNull { it.isNotBlank() }?.trim().orEmpty()
if (candidate.isNotEmpty()) return candidate
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "echo-app",
"version": "0.4.0",
"version": "0.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "echo-app",
"version": "0.4.0",
"version": "0.5.1",
"dependencies": {
"@capacitor-community/sqlite": "^5.7.2",
"@capacitor/android": "^5.7.8",

View File

@@ -1,7 +1,7 @@
{
"name": "echo-app",
"private": true,
"version": "0.4.0",
"version": "0.5.1",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",

View File

@@ -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

View File

@@ -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) {

View File

@@ -1,32 +1,32 @@
const transitStoredValueChannels = [
'杭州通互联互通卡',
'杭州通',
'北京一卡通',
'上海公共交通卡',
'深圳通',
'苏州公交卡',
'交通联合',
'乘车码',
'地铁',
'公交',
'\u676d\u5dde\u901a\u4e92\u8054\u4e92\u901a\u5361',
'\u676d\u5dde\u901a',
'\u5317\u4eac\u4e00\u5361\u901a',
'\u4e0a\u6d77\u516c\u5171\u4ea4\u901a\u5361',
'\u6df1\u5733\u901a',
'\u82cf\u5dde\u516c\u4ea4\u5361',
'\u4ea4\u901a\u8054\u5408',
'\u4e58\u8f66\u7801',
'\u5730\u94c1',
'\u516c\u4ea4',
'Mi Pay',
'Huawei Pay',
'华为钱包',
'小米钱包',
'OPPO 钱包',
'\u534e\u4e3a\u94b1\u5305',
'\u5c0f\u7c73\u94b1\u5305',
'OPPO \u94b1\u5305',
]
const notificationRules = [
{
id: 'alipay-expense',
label: '支付宝消费',
channels: ['支付宝', 'Alipay'],
keywords: ['支付', '扣款', '支出', '消费', '成功支付'],
label: '\u652f\u4ed8\u5b9d\u6d88\u8d39',
channels: ['\u652f\u4ed8\u5b9d', 'Alipay'],
keywords: ['\u652f\u4ed8', '\u6263\u6b3e', '\u652f\u51fa', '\u6d88\u8d39', '\u6210\u529f\u652f\u4ed8'],
requiredTextPatterns: [],
direction: 'expense',
entryType: 'expense',
fundSourceType: 'cash',
fundSourceName: '支付宝余额',
fundSourceName: '\u652f\u4ed8\u5b9d\u4f59\u989d',
fundTargetType: 'merchant',
fundTargetName: '',
impactExpense: true,
@@ -34,43 +34,43 @@ const notificationRules = [
defaultCategory: 'Food',
autoCapture: false,
merchantPattern: {
pattern: '(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
pattern: '(?:\u5411|\u6536\u6b3e\u65b9|\u5546\u6237)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
flags: 'i',
},
defaultMerchant: '',
},
{
id: 'alipay-income',
label: '支付宝收款',
channels: ['支付宝', 'Alipay'],
keywords: ['到账', '收入', '收款', '入账'],
label: '\u652f\u4ed8\u5b9d\u6536\u6b3e',
channels: ['\u652f\u4ed8\u5b9d', 'Alipay'],
keywords: ['\u5230\u8d26', '\u6536\u5165', '\u6536\u6b3e', '\u5165\u8d26'],
requiredTextPatterns: [],
direction: 'income',
entryType: 'income',
fundSourceType: 'external',
fundSourceName: '外部转入',
fundSourceName: '\u5916\u90e8\u8f6c\u5165',
fundTargetType: 'cash',
fundTargetName: '支付宝余额',
fundTargetName: '\u652f\u4ed8\u5b9d\u4f59\u989d',
impactExpense: false,
impactIncome: true,
defaultCategory: 'Income',
autoCapture: true,
merchantPattern: {
pattern: '(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
pattern: '(?:\u6765\u81ea|\u6536\u6b3e\u65b9|\u4ed8\u6b3e\u65b9|\u6765\u6e90)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
flags: 'i',
},
defaultMerchant: '',
},
{
id: 'wechat-expense',
label: '微信消费',
channels: ['微信支付', '微信', 'WeChat'],
keywords: ['支付', '支出', '扣款', '消费成功'],
label: '\u5fae\u4fe1\u6d88\u8d39',
channels: ['\u5fae\u4fe1\u652f\u4ed8', '\u5fae\u4fe1', 'WeChat'],
keywords: ['\u652f\u4ed8', '\u652f\u51fa', '\u6263\u6b3e', '\u6d88\u8d39\u6210\u529f'],
requiredTextPatterns: [],
direction: 'expense',
entryType: 'expense',
fundSourceType: 'cash',
fundSourceName: '微信余额',
fundSourceName: '\u5fae\u4fe1\u4f59\u989d',
fundTargetType: 'merchant',
fundTargetName: '',
impactExpense: true,
@@ -78,93 +78,93 @@ const notificationRules = [
defaultCategory: 'Groceries',
autoCapture: false,
merchantPattern: {
pattern: '(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
pattern: '(?:\u5411|\u6536\u6b3e\u65b9|\u5546\u6237)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
flags: '',
},
defaultMerchant: '',
},
{
id: 'wechat-income',
label: '微信收款',
channels: ['微信支付', '微信', 'WeChat'],
keywords: ['收款', '到账', '入账', '转入'],
label: '\u5fae\u4fe1\u6536\u6b3e',
channels: ['\u5fae\u4fe1\u652f\u4ed8', '\u5fae\u4fe1', 'WeChat'],
keywords: ['\u6536\u6b3e', '\u5230\u8d26', '\u5165\u8d26', '\u8f6c\u5165'],
requiredTextPatterns: [],
direction: 'income',
entryType: 'income',
fundSourceType: 'external',
fundSourceName: '外部转入',
fundSourceName: '\u5916\u90e8\u8f6c\u5165',
fundTargetType: 'cash',
fundTargetName: '微信余额',
fundTargetName: '\u5fae\u4fe1\u4f59\u989d',
impactExpense: false,
impactIncome: true,
defaultCategory: 'Income',
autoCapture: true,
merchantPattern: {
pattern: '(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
pattern: '(?:\u6765\u81ea|\u6536\u6b3e\u65b9|\u4ed8\u6b3e\u65b9|\u6765\u6e90)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
flags: '',
},
defaultMerchant: '',
},
{
id: 'bank-income',
label: '银行入账',
label: '\u94f6\u884c\u5165\u8d26',
channels: [
'招商银行',
'中国银行',
'建设银行',
'工商银行',
'农业银行',
'交通银行',
'浦发银行',
'兴业银行',
'光大银行',
'中信银行',
'广发银行',
'平安银行',
'\u62db\u5546\u94f6\u884c',
'\u4e2d\u56fd\u94f6\u884c',
'\u5efa\u8bbe\u94f6\u884c',
'\u5de5\u5546\u94f6\u884c',
'\u519c\u4e1a\u94f6\u884c',
'\u4ea4\u901a\u94f6\u884c',
'\u6d66\u53d1\u94f6\u884c',
'\u5174\u4e1a\u94f6\u884c',
'\u5149\u5927\u94f6\u884c',
'\u4e2d\u4fe1\u94f6\u884c',
'\u5e7f\u53d1\u94f6\u884c',
'\u5e73\u5b89\u94f6\u884c',
],
keywords: ['工资', '薪资', '代发', '到账', '入账', '收入'],
keywords: ['\u5de5\u8d44', '\u85aa\u8d44', '\u4ee3\u53d1', '\u5230\u8d26', '\u5165\u8d26', '\u6536\u5165'],
requiredTextPatterns: [],
direction: 'income',
entryType: 'income',
fundSourceType: 'external',
fundSourceName: '外部转入',
fundSourceName: '\u5916\u90e8\u8f6c\u5165',
fundTargetType: 'bank',
fundTargetName: '银行卡',
fundTargetName: '\u94f6\u884c\u5361',
impactExpense: false,
impactIncome: true,
defaultCategory: 'Income',
autoCapture: true,
merchantPattern: {
pattern: '(?:来自|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
pattern: '(?:\u6765\u81ea|\u4ed8\u6b3e\u65b9|\u6765\u6e90)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
flags: '',
},
defaultMerchant: '',
},
{
id: 'bank-expense',
label: '银行卡支出',
label: '\u94f6\u884c\u5361\u652f\u51fa',
channels: [
'招商银行',
'中国银行',
'建设银行',
'工商银行',
'农业银行',
'交通银行',
'浦发银行',
'兴业银行',
'光大银行',
'中信银行',
'广发银行',
'平安银行',
'借记卡',
'信用卡',
'\u62db\u5546\u94f6\u884c',
'\u4e2d\u56fd\u94f6\u884c',
'\u5efa\u8bbe\u94f6\u884c',
'\u5de5\u5546\u94f6\u884c',
'\u519c\u4e1a\u94f6\u884c',
'\u4ea4\u901a\u94f6\u884c',
'\u6d66\u53d1\u94f6\u884c',
'\u5174\u4e1a\u94f6\u884c',
'\u5149\u5927\u94f6\u884c',
'\u4e2d\u4fe1\u94f6\u884c',
'\u5e7f\u53d1\u94f6\u884c',
'\u5e73\u5b89\u94f6\u884c',
'\u501f\u8bb0\u5361',
'\u4fe1\u7528\u5361',
],
keywords: ['支出', '消费', '扣款', '刷卡消费', '银联消费'],
keywords: ['\u652f\u51fa', '\u6d88\u8d39', '\u6263\u6b3e', '\u5237\u5361\u6d88\u8d39', '\u94f6\u8054\u6d88\u8d39'],
requiredTextPatterns: [],
direction: 'expense',
entryType: 'expense',
fundSourceType: 'bank',
fundSourceName: '银行卡',
fundSourceName: '\u94f6\u884c\u5361',
fundTargetType: 'merchant',
fundTargetName: '',
impactExpense: true,
@@ -172,21 +172,22 @@ const notificationRules = [
defaultCategory: 'Expense',
autoCapture: false,
merchantPattern: {
pattern: '(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
pattern:
'(?:\u5728【([^】]+)】\u53d1\u751f|\u5546\u6237|\u5546\u5bb6|\u6d88\u8d39\u5546\u6237)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)?',
flags: '',
},
defaultMerchant: '',
},
{
id: 'stored-value-topup',
label: '储值账户充值',
label: '\u50a8\u503c\u8d26\u6237\u5145\u503c',
channels: transitStoredValueChannels,
keywords: ['充值', '充值成功', '充值到账', '钱包充值', '卡内充值'],
keywords: ['\u5145\u503c', '\u5145\u503c\u6210\u529f', '\u5145\u503c\u5230\u8d26', '\u94b1\u5305\u5145\u503c', '\u5361\u5185\u5145\u503c'],
requiredTextPatterns: [],
direction: 'expense',
entryType: 'transfer',
fundSourceType: 'cash',
fundSourceName: '现金账户',
fundSourceName: '\u73b0\u91d1\u8d26\u6237',
fundTargetType: 'stored_value',
fundTargetName: '',
fundTargetNameFromChannel: true,
@@ -195,13 +196,13 @@ const notificationRules = [
defaultCategory: 'Transfer',
autoCapture: true,
merchantPattern: null,
defaultMerchant: '储值账户充值',
defaultMerchant: '\u50a8\u503c\u8d26\u6237\u5145\u503c',
},
{
id: 'stored-value-refund',
label: '储值账户退款',
label: '\u50a8\u503c\u8d26\u6237\u9000\u6b3e',
channels: transitStoredValueChannels,
keywords: ['退款', '退资', '退卡', '余额退回', '退款成功'],
keywords: ['\u9000\u6b3e', '\u9000\u8d44', '\u9000\u5361', '\u4f59\u989d\u9000\u56de', '\u9000\u6b3e\u6210\u529f'],
requiredTextPatterns: [],
direction: 'income',
entryType: 'transfer',
@@ -209,20 +210,20 @@ const notificationRules = [
fundSourceName: '',
fundSourceNameFromChannel: true,
fundTargetType: 'cash',
fundTargetName: '现金账户',
fundTargetName: '\u73b0\u91d1\u8d26\u6237',
impactExpense: false,
impactIncome: false,
defaultCategory: 'Transfer',
autoCapture: true,
merchantPattern: null,
defaultMerchant: '储值账户退款',
defaultMerchant: '\u50a8\u503c\u8d26\u6237\u9000\u6b3e',
},
{
id: 'transit-card-expense',
label: '公交出行',
label: '\u516c\u4ea4\u51fa\u884c',
channels: transitStoredValueChannels,
keywords: [],
requiredTextPatterns: ['扣费', '扣款', '支付', '乘车码', '车费', '票价', '本次乘车', '实付', '优惠后'],
requiredTextPatterns: ['\u6263\u8d39', '\u6263\u6b3e', '\u652f\u4ed8', '\u4e58\u8f66\u7801', '\u8f66\u8d39', '\u7968\u4ef7', '\u672c\u6b21\u4e58\u8f66', '\u5b9e\u4ed8', '\u4f18\u60e0\u540e'],
direction: 'expense',
entryType: 'expense',
fundSourceType: 'stored_value',
@@ -238,7 +239,7 @@ const notificationRules = [
pattern: '([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})',
flags: '',
},
defaultMerchant: '公交/地铁出行',
defaultMerchant: '\u516c\u4ea4/\u5730\u94c1\u51fa\u884c',
},
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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 || '',
}))
}

View File

@@ -0,0 +1,176 @@
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 dedupeRecords = (rows = []) => {
const bucket = new Map()
rows.forEach((row) => {
const item = mapRow(row)
const key = item.sourceId || item.id
if (!bucket.has(key)) {
bucket.set(key, item)
return
}
const existing = bucket.get(key)
const existingScore = Number(Boolean(existing.transactionId)) + Number(existing.status === NOTIFICATION_DEBUG_STATUS.matched) + Number(Boolean(existing.ruleId))
const nextScore = Number(Boolean(item.transactionId)) + Number(item.status === NOTIFICATION_DEBUG_STATUS.matched) + Number(Boolean(item.ruleId))
if (nextScore > existingScore) {
bucket.set(key, item)
}
})
return Array.from(bucket.values())
}
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 dedupeRecords(result?.values || [])
}

View File

@@ -1,4 +1,4 @@
import { DEFAULT_LEDGER_BY_ENTRY_TYPE } from '../config/ledger.js'
import { DEFAULT_LEDGER_BY_ENTRY_TYPE } from '../config/ledger.js'
import notificationRulesSource from '../config/notificationRules.js'
const fallbackRules = notificationRulesSource.map((rule) => ({
@@ -115,7 +115,8 @@ const extractMerchant = (text, rule) => {
if (rule?.merchantPattern instanceof RegExp) {
const matched = content.match(rule.merchantPattern)
if (matched?.[1]) return matched[1].trim()
const candidate = matched?.slice(1).find((item) => item && String(item).trim())
if (candidate) return candidate.trim()
}
const routeMatch = content.match(/([\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,})/)

View File

@@ -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)
}
})
}

View File

@@ -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('')

View File

@@ -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);
}
}

View File

@@ -200,7 +200,7 @@ const sheetStyle = computed(() => {
<div class="grid grid-cols-2 gap-2 mb-6 bg-stone-100 rounded-[26px] p-1.5">
<button
class="inline-flex items-center justify-center gap-2 min-h-[48px] rounded-[22px] text-sm font-bold transition"
class="inline-flex h-12 items-center justify-center gap-2 rounded-[22px] text-sm font-bold transition"
:class="
form.type === 'expense'
? 'bg-white shadow text-rose-500'
@@ -208,10 +208,11 @@ const sheetStyle = computed(() => {
"
@click="form.type = 'expense'"
>
<i class="ph-bold ph-arrow-up-right" /> 支出
<i class="ph-bold ph-arrow-up-right ui-chip-icon" />
<span class="ui-chip-text">支出</span>
</button>
<button
class="inline-flex items-center justify-center gap-2 min-h-[48px] rounded-[22px] text-sm font-bold transition"
class="inline-flex h-12 items-center justify-center gap-2 rounded-[22px] text-sm font-bold transition"
:class="
form.type === 'income'
? 'bg-white shadow text-emerald-500'
@@ -219,7 +220,8 @@ const sheetStyle = computed(() => {
"
@click="form.type = 'income'"
>
<i class="ph-bold ph-arrow-down-left" /> 收入
<i class="ph-bold ph-arrow-down-left ui-chip-icon" />
<span class="ui-chip-text">收入</span>
</button>
</div>
@@ -258,8 +260,8 @@ const sheetStyle = computed(() => {
"
@click="form.category = category.value"
>
<i :class="['ph-fill text-lg leading-none', getCategoryMeta(category.value).icon]" />
<span class="block leading-tight">{{ getCategoryMeta(category.value).label }}</span>
<i :class="['ph-fill ui-chip-icon text-lg', getCategoryMeta(category.value).icon]" />
<span class="ui-chip-text text-center leading-tight">{{ getCategoryMeta(category.value).label }}</span>
</button>
</div>
</div>

View File

@@ -157,10 +157,10 @@ const handleKeydown = (event) => {
<button
v-for="item in quickPrompts"
:key="item"
class="shrink-0 rounded-full bg-white border border-stone-100 px-3 py-2 text-xs font-bold text-stone-600 shadow-sm"
class="shrink-0 inline-flex h-10 items-center justify-center rounded-full bg-white border border-stone-100 px-3 text-xs font-bold text-stone-600 shadow-sm"
@click="handleQuickPrompt(item)"
>
{{ item }}
<span class="ui-chip-text">{{ item }}</span>
</button>
</div>

View File

@@ -0,0 +1,317 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { fetchImportBatchLines, fetchImportBatches } from '../services/dataTransferService.js'
import { useUiStore } from '../stores/ui'
const copy = {
section: '\u5bfc\u5165\u8bb0\u5f55',
title: '\u5bfc\u5165\u5386\u53f2\u4e0e\u5f02\u5e38',
back: '\u8fd4\u56de\u8bbe\u7f6e',
loading: '\u6b63\u5728\u52a0\u8f7d\u5bfc\u5165\u8bb0\u5f55...',
alipayFallback: '\u652f\u4ed8\u5b9d\u8d26\u5355',
itemSuffix: '\u6761',
inserted: '\u65b0\u589e',
merged: '\u5408\u5e76',
skipped: '\u8df3\u8fc7',
summaryTitle: '\u672c\u6279\u5904\u7406\u7ed3\u679c',
refresh: '\u5237\u65b0',
skippedReasons: '\u5178\u578b\u8df3\u8fc7\u539f\u56e0',
noMerchant: '\u672a\u8bc6\u522b\u5546\u6237',
noReason: '\u65e0\u8bf4\u660e',
orderPrefix: '\u8ba2\u5355\u53f7 ',
viewRecord: '\u67e5\u770b\u5173\u8054\u8bb0\u5f55',
lineLoading: '\u6b63\u5728\u52a0\u8f7d\u660e\u7ec6...',
noLines: '\u5f53\u524d\u7b5b\u9009\u4e0b\u6ca1\u6709\u660e\u7ec6\u8bb0\u5f55',
empty: '\u8fd8\u6ca1\u6709\u5bfc\u5165\u8bb0\u5f55\u3002\u5148\u53bb\u8bbe\u7f6e\u9875\u5bfc\u5165\u4e00\u4efd\u652f\u4ed8\u5b9d\u8d26\u5355\u3002',
}
const router = useRouter()
const uiStore = useUiStore()
const loading = ref(false)
const lineLoading = ref(false)
const batches = ref([])
const selectedBatchId = ref('')
const lineFilter = ref('all')
const lines = ref([])
const lineFilterOptions = [
{ value: 'all', label: '\u5168\u90e8' },
{ value: 'inserted', label: copy.inserted },
{ value: 'merged', label: copy.merged },
{ value: 'skipped', label: copy.skipped },
]
const statusMetaMap = {
inserted: {
label: copy.inserted,
className: 'bg-emerald-50 text-emerald-600 border-emerald-200',
},
merged: {
label: copy.merged,
className: 'bg-blue-50 text-blue-600 border-blue-200',
},
skipped: {
label: copy.skipped,
className: 'bg-stone-100 text-stone-500 border-stone-200',
},
}
const selectedBatch = computed(
() => batches.value.find((item) => item.id === selectedBatchId.value) || null,
)
const formatTime = (value) => {
if (!value) return '--'
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
const formatAmount = (value) => {
const amount = Number(value || 0)
return `${amount >= 0 ? '+' : '-'} \u00a5${Math.abs(amount).toFixed(2)}`
}
const loadBatches = async () => {
loading.value = true
try {
const result = await fetchImportBatches(30)
batches.value = result
if (!selectedBatchId.value && result.length) {
selectedBatchId.value = result[0].id
}
} finally {
loading.value = false
}
}
const loadLines = async () => {
if (!selectedBatchId.value) {
lines.value = []
return
}
lineLoading.value = true
try {
lines.value = await fetchImportBatchLines(selectedBatchId.value, lineFilter.value)
} finally {
lineLoading.value = false
}
}
const openTransaction = (transactionId) => {
if (!transactionId) return
uiStore.openAddEntry(transactionId)
}
watch([selectedBatchId, lineFilter], () => {
void loadLines()
})
onMounted(async () => {
await loadBatches()
await loadLines()
})
</script>
<template>
<div class="space-y-5 pb-10 animate-fade-in">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-bold tracking-widest text-stone-400 uppercase">{{ copy.section }}</p>
<h2 class="text-2xl font-extrabold text-stone-800">{{ copy.title }}</h2>
</div>
<button
class="inline-flex h-10 items-center justify-center rounded-full border border-stone-200 bg-white px-4 text-sm font-bold text-stone-500"
@click="router.push({ name: 'settings' })"
>
{{ copy.back }}
</button>
</div>
<div
v-if="loading && !batches.length"
class="rounded-3xl border border-dashed border-stone-200 py-10 text-center text-sm text-stone-400"
>
{{ copy.loading }}
</div>
<template v-else>
<div v-if="batches.length" class="space-y-3">
<div class="flex gap-3 overflow-x-auto hide-scrollbar pb-1">
<button
v-for="batch in batches"
:key="batch.id"
class="shrink-0 w-[240px] rounded-[28px] border px-4 py-4 text-left transition"
:class="
selectedBatchId === batch.id
? 'border-transparent bg-gradient-warm text-white shadow-lg shadow-orange-200/50'
: 'border-stone-100 bg-white text-stone-700 shadow-sm'
"
@click="selectedBatchId = batch.id"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p
class="truncate text-sm font-bold"
:class="selectedBatchId === batch.id ? 'text-white' : 'text-stone-800'"
>
{{ batch.fileName || copy.alipayFallback }}
</p>
<p
class="mt-1 text-[11px]"
:class="selectedBatchId === batch.id ? 'text-white/80' : 'text-stone-400'"
>
{{ formatTime(batch.importedAt) }} / {{ batch.source }}
</p>
</div>
<span
class="rounded-full px-2 py-1 text-[10px] font-bold"
:class="
selectedBatchId === batch.id
? 'bg-white/15 text-white'
: 'bg-stone-100 text-stone-500'
"
>
{{ batch.totalCount }} {{ copy.itemSuffix }}
</span>
</div>
<div class="mt-4 grid grid-cols-3 gap-2 text-center text-[11px] font-bold">
<div
class="rounded-2xl px-2 py-2"
:class="selectedBatchId === batch.id ? 'bg-white/10 text-white' : 'bg-emerald-50 text-emerald-600'"
>
{{ copy.inserted }} {{ batch.insertedCount }}
</div>
<div
class="rounded-2xl px-2 py-2"
:class="selectedBatchId === batch.id ? 'bg-white/10 text-white' : 'bg-blue-50 text-blue-600'"
>
{{ copy.merged }} {{ batch.mergedCount }}
</div>
<div
class="rounded-2xl px-2 py-2"
:class="selectedBatchId === batch.id ? 'bg-white/10 text-white' : 'bg-stone-100 text-stone-500'"
>
{{ copy.skipped }} {{ batch.skippedCount }}
</div>
</div>
</button>
</div>
<div v-if="selectedBatch" class="rounded-3xl border border-stone-100 bg-white p-5 shadow-sm space-y-4">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-lg font-bold text-stone-800">{{ copy.summaryTitle }}</h3>
<p class="mt-1 text-xs text-stone-400">
{{ selectedBatch.fileName || copy.alipayFallback }} / {{ formatTime(selectedBatch.importedAt) }}
</p>
</div>
<button
class="inline-flex h-9 items-center justify-center rounded-full border border-stone-200 bg-white px-3 text-xs font-bold text-stone-500"
@click="loadBatches"
>
{{ copy.refresh }}
</button>
</div>
<div class="grid grid-cols-3 gap-3">
<div class="rounded-2xl bg-emerald-50 px-4 py-3">
<p class="text-[11px] font-bold text-emerald-600">{{ copy.inserted }}</p>
<p class="mt-1 text-xl font-extrabold text-emerald-700">{{ selectedBatch.insertedCount }}</p>
</div>
<div class="rounded-2xl bg-blue-50 px-4 py-3">
<p class="text-[11px] font-bold text-blue-600">{{ copy.merged }}</p>
<p class="mt-1 text-xl font-extrabold text-blue-700">{{ selectedBatch.mergedCount }}</p>
</div>
<div class="rounded-2xl bg-stone-100 px-4 py-3">
<p class="text-[11px] font-bold text-stone-500">{{ copy.skipped }}</p>
<p class="mt-1 text-xl font-extrabold text-stone-700">{{ selectedBatch.skippedCount }}</p>
</div>
</div>
<div v-if="selectedBatch.summary?.skippedReasons?.length" class="rounded-2xl bg-stone-50 px-4 py-3">
<p class="text-[11px] font-bold text-stone-500">{{ copy.skippedReasons }}</p>
<p class="mt-2 text-[11px] leading-5 text-stone-400">
{{ selectedBatch.summary.skippedReasons.slice(0, 3).join('\uFF1B') }}
</p>
</div>
<div class="flex gap-2 overflow-x-auto hide-scrollbar pb-1">
<button
v-for="item in lineFilterOptions"
:key="item.value"
class="shrink-0 inline-flex h-10 items-center justify-center rounded-full border px-4 text-sm font-bold transition"
:class="
lineFilter === item.value
? 'border-transparent bg-gradient-warm text-white'
: 'border-stone-100 bg-stone-50 text-stone-500'
"
@click="lineFilter = item.value"
>
{{ item.label }}
</button>
</div>
<div v-if="lineLoading" class="py-8 text-center text-sm text-stone-400">{{ copy.lineLoading }}</div>
<div v-else-if="lines.length" class="space-y-3">
<div
v-for="line in lines"
:key="line.id"
class="rounded-[24px] border border-stone-100 bg-stone-50/70 px-4 py-4"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="text-sm font-bold text-stone-800 break-words">{{ line.merchant || copy.noMerchant }}</p>
<span
class="inline-flex h-6 items-center justify-center rounded-full border px-2 text-[10px] font-bold"
:class="statusMetaMap[line.status]?.className || statusMetaMap.skipped.className"
>
{{ statusMetaMap[line.status]?.label || line.status }}
</span>
</div>
<p class="mt-1 text-[11px] text-stone-400">
{{ formatTime(line.occurredAt) }}
<span v-if="line.sourceOrderId"> / {{ copy.orderPrefix }}{{ line.sourceOrderId }}</span>
</p>
<p class="mt-2 text-[11px] leading-5 text-stone-500">{{ line.reason || copy.noReason }}</p>
</div>
<div class="shrink-0 text-right">
<p class="text-sm font-bold text-stone-800">{{ formatAmount(line.amount) }}</p>
<button
v-if="line.transactionId"
class="mt-2 text-[11px] font-bold text-orange-500"
@click="openTransaction(line.transactionId)"
>
{{ copy.viewRecord }}
</button>
</div>
</div>
</div>
</div>
<div
v-else
class="rounded-2xl border border-dashed border-stone-200 py-8 text-center text-sm text-stone-400"
>
{{ copy.noLines }}
</div>
</div>
</div>
<div
v-else
class="rounded-3xl border border-dashed border-stone-200 py-12 text-center text-sm text-stone-400"
>
{{ copy.empty }}
</div>
</template>
</div>
</template>

View File

@@ -117,7 +117,7 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
<button
v-for="chip in categoryChips"
:key="chip.value"
class="shrink-0 min-h-[44px] px-4 py-2.5 rounded-[22px] text-sm font-bold inline-flex items-center justify-center gap-2 border transition whitespace-nowrap leading-none"
class="shrink-0 h-11 px-4 rounded-[22px] text-sm font-bold inline-flex items-center justify-center gap-2 border transition whitespace-nowrap"
:class="
filters.category === chip.value
? 'bg-gradient-warm text-white border-transparent shadow-sm shadow-orange-200/40'
@@ -125,8 +125,8 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
"
@click="setCategory(chip.value)"
>
<i :class="['text-base leading-none', chip.icon]" />
<span class="inline-flex items-center leading-none">{{ chip.label }}</span>
<i :class="['ui-chip-icon text-base', chip.icon]" />
<span class="ui-chip-text">{{ chip.label }}</span>
</button>
</div>
@@ -188,25 +188,25 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
</p>
<span
v-if="resolveLedgerBadge(item)"
class="inline-flex items-center justify-center gap-1 px-2 py-1 rounded-full border text-[10px] font-bold leading-none"
class="inline-flex h-6 items-center justify-center gap-1 px-2 rounded-full border text-[10px] font-bold"
:class="resolveLedgerBadge(item).className"
>
<i :class="['ph-bold text-[10px] leading-none', resolveLedgerBadge(item).icon]" />
<span class="inline-flex items-center leading-none">{{ resolveLedgerBadge(item).label }}</span>
<i :class="['ph-bold ui-chip-icon text-[10px]', resolveLedgerBadge(item).icon]" />
<span class="ui-chip-text">{{ resolveLedgerBadge(item).label }}</span>
</span>
<span
v-if="resolveAiBadge(item)"
class="inline-flex items-center justify-center gap-1 px-2 py-1 rounded-full border text-[10px] font-bold leading-none"
class="inline-flex h-6 items-center justify-center gap-1 px-2 rounded-full border text-[10px] font-bold"
:class="resolveAiBadge(item).className"
>
<i
:class="[
'ph-bold text-[10px] leading-none',
'ph-bold ui-chip-icon text-[10px]',
resolveAiBadge(item).icon,
resolveAiBadge(item).iconClassName,
]"
/>
<span class="inline-flex items-center leading-none">{{ resolveAiBadge(item).label }}</span>
<span class="ui-chip-text">{{ resolveAiBadge(item).label }}</span>
</span>
</div>
<p class="text-xs leading-5 text-stone-400">

View File

@@ -0,0 +1,357 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import notificationRules from '../config/notificationRules.js'
import { useTransactionEntry } from '../composables/useTransactionEntry.js'
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
import {
fetchNotificationDebugRecords,
NOTIFICATION_DEBUG_STATUS,
} from '../services/notificationDebugService.js'
import { useUiStore } from '../stores/ui'
const copy = {
section: '\u901a\u77e5\u8c03\u8bd5',
title: '\u81ea\u52a8\u8bb0\u8d26\u8c03\u8bd5\u53f0',
back: '\u8fd4\u56de\u8bbe\u7f6e',
recentRecords: '\u6700\u8fd1\u8bb0\u5f55',
pendingManual: '\u5f85\u4eba\u5de5\u786e\u8ba4',
autoMatched: '\u5df2\u81ea\u52a8\u5165\u8d26',
intro:
'\u8fd9\u91cc\u4f1a\u5c55\u793a\u6700\u8fd1\u4e00\u6b21\u901a\u77e5\u8bc6\u522b\u7684\u547d\u4e2d\u89c4\u5219\u3001\u5ffd\u7565\u539f\u56e0\u548c\u6700\u7ec8\u5165\u8d26\u7ed3\u679c\u3002',
pendingHint:
'\u5f53\u524d\u8fd8\u6709 {count} \u6761\u5f85\u786e\u8ba4\u8349\u7a3f\uff0c\u4ecd\u4f1a\u7ee7\u7eed\u5728\u9996\u9875\u9876\u90e8\u5c55\u793a\u3002',
noPendingHint: '\u5f53\u524d\u6ca1\u6709\u5f85\u4eba\u5de5\u786e\u8ba4\u7684\u901a\u77e5\u8349\u7a3f\u3002',
searchLabel: '\u641c\u7d22\u901a\u77e5\u5185\u5bb9',
searchPlaceholder: '\u641c\u7d22\u6e20\u9053\u3001\u6807\u9898\u6216\u6b63\u6587',
syncNow: '\u7acb\u5373\u540c\u6b65\u901a\u77e5',
syncing: '\u540c\u6b65\u4e2d...',
browserDebug: '\u6d4f\u89c8\u5668\u8c03\u8bd5',
browserHint:
'\u5f53\u524d\u4e0d\u5728 Android \u539f\u751f\u5bb9\u5668\u4e2d\uff0c\u53ef\u4ee5\u76f4\u63a5\u8f93\u5165\u4e00\u6761\u6a21\u62df\u901a\u77e5\u6b63\u6587\uff0c\u9a8c\u8bc1\u89c4\u5219\u662f\u5426\u547d\u4e2d\u3002',
simulatePlaceholder:
'\u4f8b\u5982\uff1a\u676d\u5dde\u901a\u4e92\u8054\u4e92\u901a\u5361 \u6b66\u6797\u5e7f\u573a->\u53e4\u8361\uff1a2.10\u5143',
inject: '\u6ce8\u5165\u6a21\u62df\u901a\u77e5',
loading: '\u6b63\u5728\u52a0\u8f7d\u901a\u77e5\u8bb0\u5f55...',
noBody: '\u65e0\u6b63\u6587',
rulePrefix: '\u89c4\u5219\uff1a',
sourcePrefix: '\u6e90 ID\uff1a',
viewBill: '\u67e5\u770b\u8d26\u5355',
empty:
'\u5f53\u524d\u7b5b\u9009\u4e0b\u8fd8\u6ca1\u6709\u901a\u77e5\u8bb0\u5f55\u3002\u5f00\u542f\u81ea\u52a8\u6355\u83b7\u540e\u8fd4\u56de\u8fd9\u91cc\u67e5\u770b\u8bc6\u522b\u7ed3\u679c\u3002',
}
const router = useRouter()
const uiStore = useUiStore()
const { notifications, syncNotifications, simulateNotification } = useTransactionEntry()
const records = ref([])
const loading = ref(false)
const syncing = ref(false)
const keyword = ref('')
const status = ref('all')
const simulateText = ref('')
const nativeBridgeReady = isNativeNotificationBridgeAvailable()
const ruleLabelMap = Object.fromEntries(notificationRules.map((item) => [item.id, item.label]))
const statusOptions = [
{ value: 'all', label: '\u5168\u90e8', icon: 'ph-stack' },
{ value: NOTIFICATION_DEBUG_STATUS.review, label: '\u5f85\u786e\u8ba4', icon: 'ph-hourglass-medium' },
{ value: NOTIFICATION_DEBUG_STATUS.unmatched, label: '\u672a\u547d\u4e2d', icon: 'ph-magnifying-glass' },
{ value: NOTIFICATION_DEBUG_STATUS.matched, label: '\u5df2\u5165\u8d26', icon: 'ph-check-circle' },
{ value: NOTIFICATION_DEBUG_STATUS.ignored, label: '\u5df2\u5ffd\u7565', icon: 'ph-eye-slash' },
{ value: NOTIFICATION_DEBUG_STATUS.error, label: '\u5f02\u5e38', icon: 'ph-warning-circle' },
]
const statusMetaMap = {
[NOTIFICATION_DEBUG_STATUS.review]: {
label: '\u5f85\u786e\u8ba4',
className: 'bg-amber-50 text-amber-700 border-amber-200',
},
[NOTIFICATION_DEBUG_STATUS.unmatched]: {
label: '\u672a\u547d\u4e2d',
className: 'bg-stone-100 text-stone-600 border-stone-200',
},
[NOTIFICATION_DEBUG_STATUS.matched]: {
label: '\u5df2\u5165\u8d26',
className: 'bg-emerald-50 text-emerald-700 border-emerald-200',
},
[NOTIFICATION_DEBUG_STATUS.ignored]: {
label: '\u5df2\u5ffd\u7565',
className: 'bg-slate-100 text-slate-600 border-slate-200',
},
[NOTIFICATION_DEBUG_STATUS.error]: {
label: '\u5f02\u5e38',
className: 'bg-red-50 text-red-600 border-red-200',
},
}
const statusCounts = computed(() =>
records.value.reduce(
(acc, item) => {
acc.total += 1
if (acc[item.status] !== undefined) {
acc[item.status] += 1
}
return acc
},
{
total: 0,
[NOTIFICATION_DEBUG_STATUS.review]: 0,
[NOTIFICATION_DEBUG_STATUS.unmatched]: 0,
[NOTIFICATION_DEBUG_STATUS.matched]: 0,
[NOTIFICATION_DEBUG_STATUS.ignored]: 0,
[NOTIFICATION_DEBUG_STATUS.error]: 0,
},
),
)
const pendingManualCount = computed(() => notifications.value.length)
const pendingHint = computed(() => copy.pendingHint.replace('{count}', String(pendingManualCount.value)))
const formatTime = (value) => {
if (!value) return '--'
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
const loadRecords = async () => {
loading.value = true
try {
records.value = await fetchNotificationDebugRecords({
status: status.value,
keyword: keyword.value,
limit: 120,
})
} finally {
loading.value = false
}
}
const handleSync = async () => {
syncing.value = true
try {
await syncNotifications()
await loadRecords()
} finally {
syncing.value = false
}
}
const handleSimulate = async () => {
const text = simulateText.value.trim()
if (!text) return
syncing.value = true
try {
await simulateNotification(text)
simulateText.value = ''
await loadRecords()
} finally {
syncing.value = false
}
}
const openTransaction = (transactionId) => {
if (!transactionId) return
uiStore.openAddEntry(transactionId)
}
watch([status, keyword], () => {
void loadRecords()
})
onMounted(async () => {
if (nativeBridgeReady) {
try {
await NotificationBridge.hasPermission()
} catch {
// Ignore permission errors here; this page is diagnostic only.
}
}
await loadRecords()
})
</script>
<template>
<div class="space-y-5 pb-10 animate-fade-in">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-bold tracking-widest text-stone-400 uppercase">{{ copy.section }}</p>
<h2 class="text-2xl font-extrabold text-stone-800">{{ copy.title }}</h2>
</div>
<button
class="inline-flex h-10 items-center justify-center rounded-full border border-stone-200 bg-white px-4 text-sm font-bold text-stone-500"
@click="router.push({ name: 'settings' })"
>
{{ copy.back }}
</button>
</div>
<div class="rounded-[30px] border border-stone-100 bg-white p-5 shadow-sm space-y-4">
<div class="flex flex-wrap gap-3">
<div class="rounded-2xl bg-stone-50 px-4 py-3">
<p class="text-[11px] font-bold text-stone-500">{{ copy.recentRecords }}</p>
<p class="mt-1 text-2xl font-extrabold text-stone-800">{{ statusCounts.total }}</p>
</div>
<div class="rounded-2xl bg-amber-50 px-4 py-3">
<p class="text-[11px] font-bold text-amber-600">{{ copy.pendingManual }}</p>
<p class="mt-1 text-2xl font-extrabold text-amber-700">{{ pendingManualCount }}</p>
</div>
<div class="rounded-2xl bg-emerald-50 px-4 py-3">
<p class="text-[11px] font-bold text-emerald-600">{{ copy.autoMatched }}</p>
<p class="mt-1 text-2xl font-extrabold text-emerald-700">{{ statusCounts.matched }}</p>
</div>
</div>
<div class="rounded-2xl bg-stone-50 px-4 py-3 text-[12px] leading-6 text-stone-500">
<p>{{ copy.intro }}</p>
<p v-if="pendingManualCount">{{ pendingHint }}</p>
<p v-else>{{ copy.noPendingHint }}</p>
</div>
<div class="flex flex-wrap gap-2 overflow-x-auto hide-scrollbar pb-1">
<button
v-for="item in statusOptions"
:key="item.value"
class="shrink-0 inline-flex h-10 items-center gap-2 rounded-full border px-4 text-sm font-bold transition"
:class="
status === item.value
? 'border-transparent bg-gradient-warm text-white'
: 'border-stone-100 bg-stone-50 text-stone-500'
"
@click="status = item.value"
>
<i :class="`ph-bold ${item.icon}`" />
<span>{{ item.label }}</span>
</button>
</div>
<div class="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
<label class="block">
<span class="mb-2 block text-[11px] font-bold tracking-wide text-stone-400">{{ copy.searchLabel }}</span>
<input
v-model="keyword"
type="text"
class="h-12 w-full rounded-2xl border border-stone-200 bg-stone-50 px-4 text-sm text-stone-700 outline-none transition focus:border-orange-300 focus:bg-white"
:placeholder="copy.searchPlaceholder"
/>
</label>
<button
class="inline-flex h-12 items-center justify-center rounded-2xl bg-stone-900 px-5 text-sm font-bold text-white disabled:opacity-60"
:disabled="syncing"
@click="handleSync"
>
{{ syncing ? copy.syncing : copy.syncNow }}
</button>
</div>
</div>
<div v-if="!nativeBridgeReady" class="rounded-[30px] border border-stone-100 bg-white p-5 shadow-sm space-y-3">
<div>
<p class="text-sm font-bold text-stone-800">{{ copy.browserDebug }}</p>
<p class="mt-1 text-[12px] leading-6 text-stone-500">
{{ copy.browserHint }}
</p>
</div>
<textarea
v-model="simulateText"
rows="4"
class="w-full rounded-2xl border border-stone-200 bg-stone-50 px-4 py-3 text-sm text-stone-700 outline-none transition focus:border-orange-300 focus:bg-white"
:placeholder="copy.simulatePlaceholder"
/>
<div class="flex justify-end">
<button
class="inline-flex h-11 items-center justify-center rounded-2xl bg-gradient-warm px-5 text-sm font-bold text-white disabled:opacity-60"
:disabled="syncing || !simulateText.trim()"
@click="handleSimulate"
>
{{ copy.inject }}
</button>
</div>
</div>
<div
v-if="loading"
class="rounded-3xl border border-dashed border-stone-200 py-12 text-center text-sm text-stone-400"
>
{{ copy.loading }}
</div>
<div v-else-if="records.length" class="space-y-3">
<article
v-for="record in records"
:key="record.id"
class="rounded-[28px] border border-stone-100 bg-white px-5 py-4 shadow-sm"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-flex h-6 items-center justify-center rounded-full border px-2 text-[10px] font-bold"
:class="statusMetaMap[record.status]?.className || statusMetaMap.unmatched.className"
>
{{ statusMetaMap[record.status]?.label || record.status }}
</span>
<span class="text-[11px] text-stone-400">{{ formatTime(record.postedAt) }}</span>
<span v-if="record.channel" class="text-[11px] font-bold text-stone-500">{{ record.channel }}</span>
</div>
<p v-if="record.title" class="mt-2 text-sm font-bold text-stone-800 break-words">
{{ record.title }}
</p>
<p class="mt-2 text-[13px] leading-6 text-stone-600 break-words">
{{ record.text || copy.noBody }}
</p>
<div class="mt-3 flex flex-wrap gap-2 text-[11px]">
<span
v-if="record.ruleId"
class="inline-flex h-7 items-center justify-center rounded-full bg-purple-50 px-3 font-bold text-purple-600"
>
{{ copy.rulePrefix }}{{ ruleLabelMap[record.ruleId] || record.ruleId }}
</span>
<span
v-if="record.sourceId"
class="inline-flex h-7 items-center justify-center rounded-full bg-stone-100 px-3 font-bold text-stone-500"
>
{{ copy.sourcePrefix }}{{ record.sourceId }}
</span>
</div>
<p
v-if="record.errorMessage"
class="mt-3 rounded-2xl bg-stone-50 px-3 py-2 text-[11px] leading-5 text-stone-500"
>
{{ record.errorMessage }}
</p>
</div>
<div class="shrink-0 text-right">
<button
v-if="record.transactionId"
class="inline-flex h-9 items-center justify-center rounded-full bg-stone-900 px-4 text-[11px] font-bold text-white"
@click="openTransaction(record.transactionId)"
>
{{ copy.viewBill }}
</button>
</div>
</div>
</article>
</div>
<div
v-else
class="rounded-3xl border border-dashed border-stone-200 py-12 text-center text-sm text-stone-400"
>
{{ copy.empty }}
</div>
</div>
</template>

View File

@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
import { useSettingsStore } from '../stores/settings'
import { useTransactionStore } from '../stores/transactions'
@@ -16,6 +17,7 @@ import {
// eslint-disable-next-line no-undef
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'
const router = useRouter()
const settingsStore = useSettingsStore()
const transactionStore = useTransactionStore()
const fileInputRef = ref(null)
@@ -135,6 +137,14 @@ const openImportPicker = () => {
importFileInputRef.value?.click()
}
const goImportHistory = () => {
router.push({ name: 'imports' })
}
const goNotificationDebug = () => {
router.push({ name: 'notifications' })
}
const handleAvatarChange = (event) => {
const [file] = event.target.files || []
if (!file) return
@@ -436,9 +446,9 @@ onMounted(() => {
</button>
</div>
<div v-if="notificationCaptureEnabled" class="px-4 pb-4">
<div class="px-4 pb-4">
<div
v-if="!notificationPermissionGranted"
v-if="notificationCaptureEnabled && !notificationPermissionGranted"
class="bg-orange-50 border border-orange-200 rounded-2xl px-3 py-2 flex items-start gap-2"
>
<i class="ph-bold ph-warning text-orange-500 mt-0.5" />
@@ -452,10 +462,24 @@ onMounted(() => {
</button>
</div>
</div>
<p v-else class="text-[11px] text-emerald-600 flex items-center gap-1">
<p
v-else-if="notificationCaptureEnabled"
class="text-[11px] text-emerald-600 flex items-center gap-1"
>
<i class="ph-bold ph-check-circle" />
通知权限已开启Echo 会在收到新通知后自动刷新首页
</p>
<p v-else class="text-[11px] text-stone-400 leading-5">
调试台仍可查看历史命中记录也能在浏览器环境下注入模拟通知验证规则
</p>
<button
class="mt-3 inline-flex h-9 items-center justify-center gap-2 rounded-full border border-stone-200 bg-white px-4 text-[11px] font-bold text-stone-500"
@click="goNotificationDebug"
>
<i class="ph-bold ph-bug" />
<span>查看通知调试台</span>
</button>
</div>
</div>
@@ -608,6 +632,23 @@ onMounted(() => {
{{ lastImportSummary.skippedReasons.slice(0, 2).join('') }}
</p>
</div>
<button
class="flex w-full items-center justify-between p-4 border-b border-stone-50 active:bg-stone-50 transition text-left"
@click="goImportHistory"
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full bg-purple-50 text-purple-500 flex items-center justify-center"
>
<i class="ph-fill ph-clock-counter-clockwise" />
</div>
<div>
<p class="font-bold text-stone-700 text-sm">导入历史与异常</p>
<p class="text-[11px] text-stone-400 mt-0.5">查看每一批导入的新增合并跳过结果和异常原因</p>
</div>
</div>
<i class="ph-bold ph-caret-right text-stone-300" />
</button>
<button
class="flex w-full items-center justify-between p-4 active:bg-stone-50 transition text-left"
@click="handleClearCache"
@@ -635,3 +676,4 @@ onMounted(() => {
</template>