Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 655e3f8879 | |||
| 2b2d56520f | |||
| c8b30319e3 | |||
| 601cbd7576 |
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 || '',
|
||||
}))
|
||||
}
|
||||
|
||||
176
src/services/notificationDebugService.js
Normal file
176
src/services/notificationDebugService.js
Normal 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 || [])
|
||||
}
|
||||
@@ -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,})/)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
317
src/views/ImportHistoryView.vue
Normal file
317
src/views/ImportHistoryView.vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
357
src/views/NotificationDebugView.vue
Normal file
357
src/views/NotificationDebugView.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user