Compare commits
4 Commits
feature
...
655e3f8879
| Author | SHA1 | Date | |
|---|---|---|---|
| 655e3f8879 | |||
| 2b2d56520f | |||
| c8b30319e3 | |||
| 601cbd7576 |
@@ -9,8 +9,8 @@ android {
|
|||||||
applicationId "com.echo.app"
|
applicationId "com.echo.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 400
|
versionCode 501
|
||||||
versionName "0.4.0"
|
versionName "0.5.1"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.echo.app.notification
|
package com.echo.app.notification
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
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) {
|
private fun ensureSchema(db: SQLiteDatabase) {
|
||||||
if (schemaInitialized) return
|
if (schemaInitialized) return
|
||||||
db.execSQL(SQL_CREATE_TRANSACTIONS)
|
db.execSQL(SQL_CREATE_TRANSACTIONS)
|
||||||
@@ -140,6 +148,10 @@ object NotificationDb {
|
|||||||
val db = getHelper(context).writableDatabase
|
val db = getHelper(context).writableDatabase
|
||||||
ensureSchema(db)
|
ensureSchema(db)
|
||||||
|
|
||||||
|
if (hasRawNotification(db, raw.sourceId)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val rawId = UUID.randomUUID().toString()
|
val rawId = UUID.randomUUID().toString()
|
||||||
var status = "unmatched"
|
var status = "unmatched"
|
||||||
var ruleId: String? = null
|
var ruleId: String? = null
|
||||||
@@ -174,6 +186,8 @@ object NotificationDb {
|
|||||||
val merchantKnown = parsed.merchant.isNotBlank() && parsed.merchant != "Unknown"
|
val merchantKnown = parsed.merchant.isNotBlank() && parsed.merchant != "Unknown"
|
||||||
val requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown)
|
val requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown)
|
||||||
|
|
||||||
|
status = if (requiresConfirmation) "review" else "matched"
|
||||||
|
|
||||||
if (!requiresConfirmation) {
|
if (!requiresConfirmation) {
|
||||||
val signedAmount = if (parsed.isExpense) -abs(parsed.amount) else abs(parsed.amount)
|
val signedAmount = if (parsed.isExpense) -abs(parsed.amount) else abs(parsed.amount)
|
||||||
transactionId = UUID.randomUUID().toString()
|
transactionId = UUID.randomUUID().toString()
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ internal object NotificationRuleData {
|
|||||||
impactIncome = false,
|
impactIncome = false,
|
||||||
defaultCategory = "Expense",
|
defaultCategory = "Expense",
|
||||||
autoCapture = false,
|
autoCapture = false,
|
||||||
merchantPattern = Regex("(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
merchantPattern = Regex("(?:在【([^】]+)】发生|商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)?"),
|
||||||
defaultMerchant = null,
|
defaultMerchant = null,
|
||||||
),
|
),
|
||||||
NotificationRuleDefinition(
|
NotificationRuleDefinition(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.echo.app.notification
|
package com.echo.app.notification
|
||||||
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ object NotificationRuleEngine {
|
|||||||
rule.merchantPattern?.let { regex ->
|
rule.merchantPattern?.let { regex ->
|
||||||
val match = regex.find(content)
|
val match = regex.find(content)
|
||||||
if (match != null && match.groupValues.size > 1) {
|
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
|
if (candidate.isNotEmpty()) return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "echo-app",
|
"name": "echo-app",
|
||||||
"version": "0.4.0",
|
"version": "0.5.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "echo-app",
|
"name": "echo-app",
|
||||||
"version": "0.4.0",
|
"version": "0.5.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capacitor-community/sqlite": "^5.7.2",
|
"@capacitor-community/sqlite": "^5.7.2",
|
||||||
"@capacitor/android": "^5.7.8",
|
"@capacitor/android": "^5.7.8",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "echo-app",
|
"name": "echo-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.0",
|
"version": "0.5.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
const currentTab = computed(() => route.name)
|
const currentTab = computed(() => route.meta?.tab || route.name)
|
||||||
|
|
||||||
const goTab = (name) => {
|
const goTab = (name) => {
|
||||||
if (route.name === name) return
|
if (route.name === name) return
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
} from '../services/notificationRuleService'
|
} from '../services/notificationRuleService'
|
||||||
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
|
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
|
import {
|
||||||
|
ensureNotificationRaw,
|
||||||
|
NOTIFICATION_DEBUG_STATUS,
|
||||||
|
updateNotificationRawStatus,
|
||||||
|
} from '../services/notificationDebugService.js'
|
||||||
|
|
||||||
const notifications = ref([])
|
const notifications = ref([])
|
||||||
const processingId = ref('')
|
const processingId = ref('')
|
||||||
@@ -40,7 +45,6 @@ export const useTransactionEntry = () => {
|
|||||||
const transactionStore = useTransactionStore()
|
const transactionStore = useTransactionStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
// 原生端:在首次同步前做一次权限校验与请求
|
|
||||||
const ensureNativePermission = async () => {
|
const ensureNativePermission = async () => {
|
||||||
if (!nativeBridgeReady || permissionChecked) return
|
if (!nativeBridgeReady || permissionChecked) return
|
||||||
try {
|
try {
|
||||||
@@ -49,7 +53,7 @@ export const useTransactionEntry = () => {
|
|||||||
await NotificationBridge.requestPermission()
|
await NotificationBridge.requestPermission()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[notifications] 原生权限检查失败', error)
|
console.warn('[notifications] native permission check failed', error)
|
||||||
} finally {
|
} finally {
|
||||||
permissionChecked = true
|
permissionChecked = true
|
||||||
}
|
}
|
||||||
@@ -62,15 +66,25 @@ export const useTransactionEntry = () => {
|
|||||||
const confirmNotification = async (id) => {
|
const confirmNotification = async (id) => {
|
||||||
const target = notifications.value.find((item) => item.id === id)
|
const target = notifications.value.find((item) => item.id === id)
|
||||||
if (!target || processingId.value) return
|
if (!target || processingId.value) return
|
||||||
|
|
||||||
processingId.value = id
|
processingId.value = id
|
||||||
try {
|
try {
|
||||||
await ensureRulesReady()
|
await ensureRulesReady()
|
||||||
await transactionStore.ensureInitialized()
|
await transactionStore.ensureInitialized()
|
||||||
const { transaction } = transformNotificationToTransaction(target, {
|
|
||||||
|
const { transaction, rule } = transformNotificationToTransaction(target, {
|
||||||
ruleSet: ruleSet.value,
|
ruleSet: ruleSet.value,
|
||||||
date: target.createdAt,
|
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)
|
removeNotification(id)
|
||||||
await acknowledgeNotification(id)
|
await acknowledgeNotification(id)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -79,64 +93,87 @@ export const useTransactionEntry = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dismissNotification = async (id) => {
|
const dismissNotification = async (id) => {
|
||||||
|
await updateNotificationRawStatus(id, {
|
||||||
|
status: NOTIFICATION_DEBUG_STATUS.ignored,
|
||||||
|
errorMessage: '\u7528\u6237\u624b\u52a8\u5ffd\u7565',
|
||||||
|
})
|
||||||
removeNotification(id)
|
removeNotification(id)
|
||||||
await acknowledgeNotification(id)
|
await acknowledgeNotification(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncNotifications = async () => {
|
const syncNotifications = async () => {
|
||||||
if (syncing.value) return
|
if (syncing.value) return
|
||||||
|
|
||||||
syncing.value = true
|
syncing.value = true
|
||||||
try {
|
try {
|
||||||
// 若用户关闭了通知自动捕获,则不从原生/模拟队列拉取新通知,只清空待确认列表
|
|
||||||
if (!settingsStore.notificationCaptureEnabled) {
|
if (!settingsStore.notificationCaptureEnabled) {
|
||||||
notifications.value = []
|
notifications.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nativeBridgeReady) {
|
if (nativeBridgeReady) {
|
||||||
await ensureNativePermission()
|
await ensureNativePermission()
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureRulesReady()
|
await ensureRulesReady()
|
||||||
await transactionStore.ensureInitialized()
|
await transactionStore.ensureInitialized()
|
||||||
|
|
||||||
const queue = await fetchNotificationQueue()
|
const queue = await fetchNotificationQueue()
|
||||||
const manualQueue = []
|
const manualQueue = []
|
||||||
|
|
||||||
for (const item of queue) {
|
for (const item of queue) {
|
||||||
|
await ensureNotificationRaw(item, {
|
||||||
|
status: NOTIFICATION_DEBUG_STATUS.unmatched,
|
||||||
|
})
|
||||||
|
|
||||||
const { transaction, rule, requiresConfirmation } = transformNotificationToTransaction(item, {
|
const { transaction, rule, requiresConfirmation } = transformNotificationToTransaction(item, {
|
||||||
ruleSet: ruleSet.value,
|
ruleSet: ruleSet.value,
|
||||||
date: item.createdAt,
|
date: item.createdAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果没有任何规则命中(rule 为空),默认忽略该通知,避免系统通知等噪音进入待确认列表
|
|
||||||
if (!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)
|
await acknowledgeNotification(item.id)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nativeBridgeReady) {
|
|
||||||
// 原生环境:规则在 Kotlin 原生层已经执行并写入 SQLite,这里只负责「待确认」列表和状态同步
|
|
||||||
if (!requiresConfirmation) {
|
if (!requiresConfirmation) {
|
||||||
// 已由原生自动入账,只需从原生通知队列中移除该条
|
if (!nativeBridgeReady) {
|
||||||
await acknowledgeNotification(item.id)
|
const created = await transactionStore.addTransaction(transaction)
|
||||||
|
await updateNotificationRawStatus(item.id, {
|
||||||
|
status: NOTIFICATION_DEBUG_STATUS.matched,
|
||||||
|
ruleId: rule?.id || '',
|
||||||
|
transactionId: created?.id || '',
|
||||||
|
errorMessage: null,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
|
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({
|
manualQueue.push({
|
||||||
...item,
|
...item,
|
||||||
suggestion: transaction,
|
suggestion: transaction,
|
||||||
ruleId: rule?.id || null,
|
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notifications.value = manualQueue
|
notifications.value = manualQueue
|
||||||
} finally {
|
} finally {
|
||||||
syncing.value = false
|
syncing.value = false
|
||||||
@@ -150,9 +187,8 @@ export const useTransactionEntry = () => {
|
|||||||
|
|
||||||
if (!bootstrapped) {
|
if (!bootstrapped) {
|
||||||
bootstrapped = true
|
bootstrapped = true
|
||||||
syncNotifications()
|
void syncNotifications()
|
||||||
|
|
||||||
// 原生环境:监听 NotificationBridgePlugin 推送的 notificationPosted 事件,做到“通知一到就同步一次”
|
|
||||||
if (nativeBridgeReady && !nativeNotificationListener && typeof NotificationBridge?.addListener === 'function') {
|
if (nativeBridgeReady && !nativeNotificationListener && typeof NotificationBridge?.addListener === 'function') {
|
||||||
NotificationBridge.addListener('notificationPosted', async () => {
|
NotificationBridge.addListener('notificationPosted', async () => {
|
||||||
await syncNotifications()
|
await syncNotifications()
|
||||||
@@ -161,11 +197,10 @@ export const useTransactionEntry = () => {
|
|||||||
nativeNotificationListener = handle
|
nativeNotificationListener = handle
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.warn('[notifications] 无法注册 notificationPosted 监听', error)
|
console.warn('[notifications] failed to register notificationPosted listener', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 前台轮询兜底:即使偶发漏掉原生事件,也能定期从队列补拉一次
|
|
||||||
if (!pollTimer && typeof setInterval === 'function') {
|
if (!pollTimer && typeof setInterval === 'function') {
|
||||||
pollTimer = setInterval(() => {
|
pollTimer = setInterval(() => {
|
||||||
if (!syncing.value) {
|
if (!syncing.value) {
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
const transitStoredValueChannels = [
|
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',
|
'Mi Pay',
|
||||||
'Huawei Pay',
|
'Huawei Pay',
|
||||||
'华为钱包',
|
'\u534e\u4e3a\u94b1\u5305',
|
||||||
'小米钱包',
|
'\u5c0f\u7c73\u94b1\u5305',
|
||||||
'OPPO 钱包',
|
'OPPO \u94b1\u5305',
|
||||||
]
|
]
|
||||||
|
|
||||||
const notificationRules = [
|
const notificationRules = [
|
||||||
{
|
{
|
||||||
id: 'alipay-expense',
|
id: 'alipay-expense',
|
||||||
label: '支付宝消费',
|
label: '\u652f\u4ed8\u5b9d\u6d88\u8d39',
|
||||||
channels: ['支付宝', 'Alipay'],
|
channels: ['\u652f\u4ed8\u5b9d', 'Alipay'],
|
||||||
keywords: ['支付', '扣款', '支出', '消费', '成功支付'],
|
keywords: ['\u652f\u4ed8', '\u6263\u6b3e', '\u652f\u51fa', '\u6d88\u8d39', '\u6210\u529f\u652f\u4ed8'],
|
||||||
requiredTextPatterns: [],
|
requiredTextPatterns: [],
|
||||||
direction: 'expense',
|
direction: 'expense',
|
||||||
entryType: 'expense',
|
entryType: 'expense',
|
||||||
fundSourceType: 'cash',
|
fundSourceType: 'cash',
|
||||||
fundSourceName: '支付宝余额',
|
fundSourceName: '\u652f\u4ed8\u5b9d\u4f59\u989d',
|
||||||
fundTargetType: 'merchant',
|
fundTargetType: 'merchant',
|
||||||
fundTargetName: '',
|
fundTargetName: '',
|
||||||
impactExpense: true,
|
impactExpense: true,
|
||||||
@@ -34,43 +34,43 @@ const notificationRules = [
|
|||||||
defaultCategory: 'Food',
|
defaultCategory: 'Food',
|
||||||
autoCapture: false,
|
autoCapture: false,
|
||||||
merchantPattern: {
|
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',
|
flags: 'i',
|
||||||
},
|
},
|
||||||
defaultMerchant: '',
|
defaultMerchant: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'alipay-income',
|
id: 'alipay-income',
|
||||||
label: '支付宝收款',
|
label: '\u652f\u4ed8\u5b9d\u6536\u6b3e',
|
||||||
channels: ['支付宝', 'Alipay'],
|
channels: ['\u652f\u4ed8\u5b9d', 'Alipay'],
|
||||||
keywords: ['到账', '收入', '收款', '入账'],
|
keywords: ['\u5230\u8d26', '\u6536\u5165', '\u6536\u6b3e', '\u5165\u8d26'],
|
||||||
requiredTextPatterns: [],
|
requiredTextPatterns: [],
|
||||||
direction: 'income',
|
direction: 'income',
|
||||||
entryType: 'income',
|
entryType: 'income',
|
||||||
fundSourceType: 'external',
|
fundSourceType: 'external',
|
||||||
fundSourceName: '外部转入',
|
fundSourceName: '\u5916\u90e8\u8f6c\u5165',
|
||||||
fundTargetType: 'cash',
|
fundTargetType: 'cash',
|
||||||
fundTargetName: '支付宝余额',
|
fundTargetName: '\u652f\u4ed8\u5b9d\u4f59\u989d',
|
||||||
impactExpense: false,
|
impactExpense: false,
|
||||||
impactIncome: true,
|
impactIncome: true,
|
||||||
defaultCategory: 'Income',
|
defaultCategory: 'Income',
|
||||||
autoCapture: true,
|
autoCapture: true,
|
||||||
merchantPattern: {
|
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',
|
flags: 'i',
|
||||||
},
|
},
|
||||||
defaultMerchant: '',
|
defaultMerchant: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'wechat-expense',
|
id: 'wechat-expense',
|
||||||
label: '微信消费',
|
label: '\u5fae\u4fe1\u6d88\u8d39',
|
||||||
channels: ['微信支付', '微信', 'WeChat'],
|
channels: ['\u5fae\u4fe1\u652f\u4ed8', '\u5fae\u4fe1', 'WeChat'],
|
||||||
keywords: ['支付', '支出', '扣款', '消费成功'],
|
keywords: ['\u652f\u4ed8', '\u652f\u51fa', '\u6263\u6b3e', '\u6d88\u8d39\u6210\u529f'],
|
||||||
requiredTextPatterns: [],
|
requiredTextPatterns: [],
|
||||||
direction: 'expense',
|
direction: 'expense',
|
||||||
entryType: 'expense',
|
entryType: 'expense',
|
||||||
fundSourceType: 'cash',
|
fundSourceType: 'cash',
|
||||||
fundSourceName: '微信余额',
|
fundSourceName: '\u5fae\u4fe1\u4f59\u989d',
|
||||||
fundTargetType: 'merchant',
|
fundTargetType: 'merchant',
|
||||||
fundTargetName: '',
|
fundTargetName: '',
|
||||||
impactExpense: true,
|
impactExpense: true,
|
||||||
@@ -78,93 +78,93 @@ const notificationRules = [
|
|||||||
defaultCategory: 'Groceries',
|
defaultCategory: 'Groceries',
|
||||||
autoCapture: false,
|
autoCapture: false,
|
||||||
merchantPattern: {
|
merchantPattern: {
|
||||||
pattern: '(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
|
pattern: '(?:\u5411|\u6536\u6b3e\u65b9|\u5546\u6237)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
|
||||||
flags: '',
|
flags: '',
|
||||||
},
|
},
|
||||||
defaultMerchant: '',
|
defaultMerchant: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'wechat-income',
|
id: 'wechat-income',
|
||||||
label: '微信收款',
|
label: '\u5fae\u4fe1\u6536\u6b3e',
|
||||||
channels: ['微信支付', '微信', 'WeChat'],
|
channels: ['\u5fae\u4fe1\u652f\u4ed8', '\u5fae\u4fe1', 'WeChat'],
|
||||||
keywords: ['收款', '到账', '入账', '转入'],
|
keywords: ['\u6536\u6b3e', '\u5230\u8d26', '\u5165\u8d26', '\u8f6c\u5165'],
|
||||||
requiredTextPatterns: [],
|
requiredTextPatterns: [],
|
||||||
direction: 'income',
|
direction: 'income',
|
||||||
entryType: 'income',
|
entryType: 'income',
|
||||||
fundSourceType: 'external',
|
fundSourceType: 'external',
|
||||||
fundSourceName: '外部转入',
|
fundSourceName: '\u5916\u90e8\u8f6c\u5165',
|
||||||
fundTargetType: 'cash',
|
fundTargetType: 'cash',
|
||||||
fundTargetName: '微信余额',
|
fundTargetName: '\u5fae\u4fe1\u4f59\u989d',
|
||||||
impactExpense: false,
|
impactExpense: false,
|
||||||
impactIncome: true,
|
impactIncome: true,
|
||||||
defaultCategory: 'Income',
|
defaultCategory: 'Income',
|
||||||
autoCapture: true,
|
autoCapture: true,
|
||||||
merchantPattern: {
|
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: '',
|
flags: '',
|
||||||
},
|
},
|
||||||
defaultMerchant: '',
|
defaultMerchant: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bank-income',
|
id: 'bank-income',
|
||||||
label: '银行入账',
|
label: '\u94f6\u884c\u5165\u8d26',
|
||||||
channels: [
|
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: [],
|
requiredTextPatterns: [],
|
||||||
direction: 'income',
|
direction: 'income',
|
||||||
entryType: 'income',
|
entryType: 'income',
|
||||||
fundSourceType: 'external',
|
fundSourceType: 'external',
|
||||||
fundSourceName: '外部转入',
|
fundSourceName: '\u5916\u90e8\u8f6c\u5165',
|
||||||
fundTargetType: 'bank',
|
fundTargetType: 'bank',
|
||||||
fundTargetName: '银行卡',
|
fundTargetName: '\u94f6\u884c\u5361',
|
||||||
impactExpense: false,
|
impactExpense: false,
|
||||||
impactIncome: true,
|
impactIncome: true,
|
||||||
defaultCategory: 'Income',
|
defaultCategory: 'Income',
|
||||||
autoCapture: true,
|
autoCapture: true,
|
||||||
merchantPattern: {
|
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: '',
|
flags: '',
|
||||||
},
|
},
|
||||||
defaultMerchant: '',
|
defaultMerchant: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bank-expense',
|
id: 'bank-expense',
|
||||||
label: '银行卡支出',
|
label: '\u94f6\u884c\u5361\u652f\u51fa',
|
||||||
channels: [
|
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: [],
|
requiredTextPatterns: [],
|
||||||
direction: 'expense',
|
direction: 'expense',
|
||||||
entryType: 'expense',
|
entryType: 'expense',
|
||||||
fundSourceType: 'bank',
|
fundSourceType: 'bank',
|
||||||
fundSourceName: '银行卡',
|
fundSourceName: '\u94f6\u884c\u5361',
|
||||||
fundTargetType: 'merchant',
|
fundTargetType: 'merchant',
|
||||||
fundTargetName: '',
|
fundTargetName: '',
|
||||||
impactExpense: true,
|
impactExpense: true,
|
||||||
@@ -172,21 +172,22 @@ const notificationRules = [
|
|||||||
defaultCategory: 'Expense',
|
defaultCategory: 'Expense',
|
||||||
autoCapture: false,
|
autoCapture: false,
|
||||||
merchantPattern: {
|
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: '',
|
flags: '',
|
||||||
},
|
},
|
||||||
defaultMerchant: '',
|
defaultMerchant: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stored-value-topup',
|
id: 'stored-value-topup',
|
||||||
label: '储值账户充值',
|
label: '\u50a8\u503c\u8d26\u6237\u5145\u503c',
|
||||||
channels: transitStoredValueChannels,
|
channels: transitStoredValueChannels,
|
||||||
keywords: ['充值', '充值成功', '充值到账', '钱包充值', '卡内充值'],
|
keywords: ['\u5145\u503c', '\u5145\u503c\u6210\u529f', '\u5145\u503c\u5230\u8d26', '\u94b1\u5305\u5145\u503c', '\u5361\u5185\u5145\u503c'],
|
||||||
requiredTextPatterns: [],
|
requiredTextPatterns: [],
|
||||||
direction: 'expense',
|
direction: 'expense',
|
||||||
entryType: 'transfer',
|
entryType: 'transfer',
|
||||||
fundSourceType: 'cash',
|
fundSourceType: 'cash',
|
||||||
fundSourceName: '现金账户',
|
fundSourceName: '\u73b0\u91d1\u8d26\u6237',
|
||||||
fundTargetType: 'stored_value',
|
fundTargetType: 'stored_value',
|
||||||
fundTargetName: '',
|
fundTargetName: '',
|
||||||
fundTargetNameFromChannel: true,
|
fundTargetNameFromChannel: true,
|
||||||
@@ -195,13 +196,13 @@ const notificationRules = [
|
|||||||
defaultCategory: 'Transfer',
|
defaultCategory: 'Transfer',
|
||||||
autoCapture: true,
|
autoCapture: true,
|
||||||
merchantPattern: null,
|
merchantPattern: null,
|
||||||
defaultMerchant: '储值账户充值',
|
defaultMerchant: '\u50a8\u503c\u8d26\u6237\u5145\u503c',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'stored-value-refund',
|
id: 'stored-value-refund',
|
||||||
label: '储值账户退款',
|
label: '\u50a8\u503c\u8d26\u6237\u9000\u6b3e',
|
||||||
channels: transitStoredValueChannels,
|
channels: transitStoredValueChannels,
|
||||||
keywords: ['退款', '退资', '退卡', '余额退回', '退款成功'],
|
keywords: ['\u9000\u6b3e', '\u9000\u8d44', '\u9000\u5361', '\u4f59\u989d\u9000\u56de', '\u9000\u6b3e\u6210\u529f'],
|
||||||
requiredTextPatterns: [],
|
requiredTextPatterns: [],
|
||||||
direction: 'income',
|
direction: 'income',
|
||||||
entryType: 'transfer',
|
entryType: 'transfer',
|
||||||
@@ -209,20 +210,20 @@ const notificationRules = [
|
|||||||
fundSourceName: '',
|
fundSourceName: '',
|
||||||
fundSourceNameFromChannel: true,
|
fundSourceNameFromChannel: true,
|
||||||
fundTargetType: 'cash',
|
fundTargetType: 'cash',
|
||||||
fundTargetName: '现金账户',
|
fundTargetName: '\u73b0\u91d1\u8d26\u6237',
|
||||||
impactExpense: false,
|
impactExpense: false,
|
||||||
impactIncome: false,
|
impactIncome: false,
|
||||||
defaultCategory: 'Transfer',
|
defaultCategory: 'Transfer',
|
||||||
autoCapture: true,
|
autoCapture: true,
|
||||||
merchantPattern: null,
|
merchantPattern: null,
|
||||||
defaultMerchant: '储值账户退款',
|
defaultMerchant: '\u50a8\u503c\u8d26\u6237\u9000\u6b3e',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'transit-card-expense',
|
id: 'transit-card-expense',
|
||||||
label: '公交出行',
|
label: '\u516c\u4ea4\u51fa\u884c',
|
||||||
channels: transitStoredValueChannels,
|
channels: transitStoredValueChannels,
|
||||||
keywords: [],
|
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',
|
direction: 'expense',
|
||||||
entryType: 'expense',
|
entryType: 'expense',
|
||||||
fundSourceType: 'stored_value',
|
fundSourceType: 'stored_value',
|
||||||
@@ -238,7 +239,7 @@ const notificationRules = [
|
|||||||
pattern: '([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})',
|
pattern: '([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})',
|
||||||
flags: '',
|
flags: '',
|
||||||
},
|
},
|
||||||
defaultMerchant: '公交/地铁出行',
|
defaultMerchant: '\u516c\u4ea4/\u5730\u94c1\u51fa\u884c',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ const MERCHANT_PROFILE_TABLE_SQL = `
|
|||||||
updated_at TEXT NOT NULL
|
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 = `
|
const IMPORT_BATCH_TABLE_SQL = `
|
||||||
CREATE TABLE IF NOT EXISTS import_batches (
|
CREATE TABLE IF NOT EXISTS import_batches (
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
@@ -186,6 +200,7 @@ const prepareConnection = async () => {
|
|||||||
await db.open()
|
await db.open()
|
||||||
await db.execute(TRANSACTION_TABLE_SQL)
|
await db.execute(TRANSACTION_TABLE_SQL)
|
||||||
await db.execute(MERCHANT_PROFILE_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_BATCH_TABLE_SQL)
|
||||||
await db.execute(IMPORT_LINE_TABLE_SQL)
|
await db.execute(IMPORT_LINE_TABLE_SQL)
|
||||||
await ensureTableColumns('transactions', TRANSACTION_REQUIRED_COLUMNS)
|
await ensureTableColumns('transactions', TRANSACTION_REQUIRED_COLUMNS)
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import HomeView from '../views/HomeView.vue'
|
|||||||
import ListView from '../views/ListView.vue'
|
import ListView from '../views/ListView.vue'
|
||||||
import SettingsView from '../views/SettingsView.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 AnalysisView = () => import('../views/AnalysisView.vue').catch(() => null)
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -33,8 +34,19 @@ const router = createRouter({
|
|||||||
component: SettingsView,
|
component: SettingsView,
|
||||||
meta: { tab: 'settings' },
|
meta: { tab: 'settings' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/imports',
|
||||||
|
name: 'imports',
|
||||||
|
component: ImportHistoryView,
|
||||||
|
meta: { tab: 'settings' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/notifications',
|
||||||
|
name: 'notifications',
|
||||||
|
component: NotificationDebugView,
|
||||||
|
meta: { tab: 'settings' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
||||||
|
|||||||
@@ -493,3 +493,96 @@ export const exportTransactionsAsJson = async () => {
|
|||||||
)
|
)
|
||||||
return transactions.length
|
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'
|
import notificationRulesSource from '../config/notificationRules.js'
|
||||||
|
|
||||||
const fallbackRules = notificationRulesSource.map((rule) => ({
|
const fallbackRules = notificationRulesSource.map((rule) => ({
|
||||||
@@ -115,7 +115,8 @@ const extractMerchant = (text, rule) => {
|
|||||||
|
|
||||||
if (rule?.merchantPattern instanceof RegExp) {
|
if (rule?.merchantPattern instanceof RegExp) {
|
||||||
const matched = content.match(rule.merchantPattern)
|
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,})/)
|
const routeMatch = content.match(/([\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,})/)
|
||||||
|
|||||||
@@ -3,17 +3,17 @@ import NotificationBridge, {
|
|||||||
isNativeNotificationBridgeAvailable,
|
isNativeNotificationBridgeAvailable,
|
||||||
} from '../lib/notificationBridge'
|
} from '../lib/notificationBridge'
|
||||||
|
|
||||||
// 原生插件回调,可按需在应用启动时注入,例如用于上报到服务端
|
// 原生插件回调,可按需在应用启动时注入,例如后续接服务端同步。
|
||||||
let remoteNotificationFetcher = async () => []
|
let remoteNotificationFetcher = async () => []
|
||||||
let remoteNotificationAcknowledger = async () => {}
|
let remoteNotificationAcknowledger = async () => {}
|
||||||
|
|
||||||
// 判断当前是否为原生环境(Android 容器内)
|
// 判断当前是否在原生容器内。
|
||||||
const nativeBridgeReady = isNativeNotificationBridgeAvailable()
|
const nativeBridgeReady = isNativeNotificationBridgeAvailable()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本地模拟通知缓存:
|
* 本地模拟通知缓存:
|
||||||
* - 浏览器环境:用于 Demo & Mock,可通过 pushLocalNotification 主动注入
|
* - 浏览器环境:用于规则调试和 Demo。
|
||||||
* - 原生环境:一般保持为空,仅在必要时作为 NotificationBridge 数据的补充兜底
|
* - 原生环境:通常保持为空,仅在必要时补充桥接层数据。
|
||||||
*/
|
*/
|
||||||
let localNotificationQueue = []
|
let localNotificationQueue = []
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@ const sortByCreatedAtDesc = (a, b) =>
|
|||||||
new Date(b.createdAt || b.id) - new Date(a.createdAt || a.id)
|
new Date(b.createdAt || b.id) - new Date(a.createdAt || a.id)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一获取待处理通知队列:
|
* 统一读取待处理通知队列。
|
||||||
* - 原生环境:优先从 NotificationBridge 中拿未处理通知
|
* - 原生环境:优先读取 NotificationBridge 中的待处理通知。
|
||||||
* - 浏览器:仅使用本地模拟通知
|
* - 浏览器环境:仅使用本地模拟通知。
|
||||||
*/
|
*/
|
||||||
export const fetchNotificationQueue = async () => {
|
export const fetchNotificationQueue = async () => {
|
||||||
const remote = await remoteNotificationFetcher()
|
const remote = await remoteNotificationFetcher()
|
||||||
@@ -40,20 +40,21 @@ export const fetchNotificationQueue = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 浏览器环境下模拟「新通知」进入队列
|
* 浏览器环境下主动注入一条模拟通知。
|
||||||
*/
|
*/
|
||||||
export const pushLocalNotification = (payload) => {
|
export const pushLocalNotification = (payload) => {
|
||||||
const entry = {
|
const entry = {
|
||||||
id: payload?.id || uuidv4(),
|
id: payload?.id || uuidv4(),
|
||||||
createdAt: payload?.createdAt || new Date().toISOString(),
|
createdAt: payload?.createdAt || new Date().toISOString(),
|
||||||
channel: payload?.channel || '模拟',
|
channel: payload?.channel || '\u6a21\u62df\u901a\u77e5',
|
||||||
|
title: payload?.title || '',
|
||||||
text: payload?.text || '',
|
text: payload?.text || '',
|
||||||
}
|
}
|
||||||
localNotificationQueue = [entry, ...localNotificationQueue]
|
localNotificationQueue = [entry, ...localNotificationQueue]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确认/忽略某条通知后,从本地队列中移除,并尝试同步到远端
|
* 确认或忽略通知后,从本地队列移除,并尝试同步给原生层。
|
||||||
*/
|
*/
|
||||||
export const acknowledgeNotification = async (id) => {
|
export const acknowledgeNotification = async (id) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
@@ -62,32 +63,27 @@ export const acknowledgeNotification = async (id) => {
|
|||||||
try {
|
try {
|
||||||
await remoteNotificationAcknowledger(id)
|
await remoteNotificationAcknowledger(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[notifications] 远程通知确认失败,将在下次同步重试', error)
|
console.warn('[notifications] remote acknowledgement failed, will retry on next sync', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 原生桥接(Android NotificationListenerService)集成 =====
|
|
||||||
|
|
||||||
if (nativeBridgeReady) {
|
if (nativeBridgeReady) {
|
||||||
// 从原生层拉取待处理通知
|
|
||||||
setRemoteNotificationFetcher(async () => {
|
setRemoteNotificationFetcher(async () => {
|
||||||
try {
|
try {
|
||||||
const { notifications = [] } = await NotificationBridge.getPendingNotifications()
|
const { notifications = [] } = await NotificationBridge.getPendingNotifications()
|
||||||
return notifications
|
return notifications
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[notifications] 获取原生通知失败,退回本地模拟数据', error)
|
console.warn('[notifications] failed to fetch native notifications, fallback to local queue', error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 通知已确认/忽略后,告知原生层清除对应记录
|
|
||||||
setRemoteNotificationAcknowledger(async (id) => {
|
setRemoteNotificationAcknowledger(async (id) => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
await NotificationBridge.acknowledgeNotification({ id })
|
await NotificationBridge.acknowledgeNotification({ id })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[notifications] 通知确认同步至原生失败', error)
|
console.warn('[notifications] failed to acknowledge native notification', error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
// 管理全局 UI 状态,例如记一笔弹层的开关和当前编辑项。
|
// 管理全局 UI 状态,例如记一笔弹层开关和当前编辑记录。
|
||||||
export const useUiStore = defineStore('ui', () => {
|
export const useUiStore = defineStore('ui', () => {
|
||||||
const addEntryVisible = ref(false)
|
const addEntryVisible = ref(false)
|
||||||
const editingTransactionId = ref('')
|
const editingTransactionId = ref('')
|
||||||
|
|||||||
@@ -47,4 +47,19 @@
|
|||||||
.slide-up-leave-to {
|
.slide-up-leave-to {
|
||||||
transform: translateY(100%);
|
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">
|
<div class="grid grid-cols-2 gap-2 mb-6 bg-stone-100 rounded-[26px] p-1.5">
|
||||||
<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="
|
:class="
|
||||||
form.type === 'expense'
|
form.type === 'expense'
|
||||||
? 'bg-white shadow text-rose-500'
|
? 'bg-white shadow text-rose-500'
|
||||||
@@ -208,10 +208,11 @@ const sheetStyle = computed(() => {
|
|||||||
"
|
"
|
||||||
@click="form.type = 'expense'"
|
@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>
|
||||||
<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="
|
:class="
|
||||||
form.type === 'income'
|
form.type === 'income'
|
||||||
? 'bg-white shadow text-emerald-500'
|
? 'bg-white shadow text-emerald-500'
|
||||||
@@ -219,7 +220,8 @@ const sheetStyle = computed(() => {
|
|||||||
"
|
"
|
||||||
@click="form.type = 'income'"
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -258,8 +260,8 @@ const sheetStyle = computed(() => {
|
|||||||
"
|
"
|
||||||
@click="form.category = category.value"
|
@click="form.category = category.value"
|
||||||
>
|
>
|
||||||
<i :class="['ph-fill text-lg leading-none', getCategoryMeta(category.value).icon]" />
|
<i :class="['ph-fill ui-chip-icon text-lg', getCategoryMeta(category.value).icon]" />
|
||||||
<span class="block leading-tight">{{ getCategoryMeta(category.value).label }}</span>
|
<span class="ui-chip-text text-center leading-tight">{{ getCategoryMeta(category.value).label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -157,10 +157,10 @@ const handleKeydown = (event) => {
|
|||||||
<button
|
<button
|
||||||
v-for="item in quickPrompts"
|
v-for="item in quickPrompts"
|
||||||
:key="item"
|
: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)"
|
@click="handleQuickPrompt(item)"
|
||||||
>
|
>
|
||||||
{{ item }}
|
<span class="ui-chip-text">{{ item }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<button
|
||||||
v-for="chip in categoryChips"
|
v-for="chip in categoryChips"
|
||||||
:key="chip.value"
|
: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="
|
:class="
|
||||||
filters.category === chip.value
|
filters.category === chip.value
|
||||||
? 'bg-gradient-warm text-white border-transparent shadow-sm shadow-orange-200/40'
|
? '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)"
|
@click="setCategory(chip.value)"
|
||||||
>
|
>
|
||||||
<i :class="['text-base leading-none', chip.icon]" />
|
<i :class="['ui-chip-icon text-base', chip.icon]" />
|
||||||
<span class="inline-flex items-center leading-none">{{ chip.label }}</span>
|
<span class="ui-chip-text">{{ chip.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -188,25 +188,25 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
|
|||||||
</p>
|
</p>
|
||||||
<span
|
<span
|
||||||
v-if="resolveLedgerBadge(item)"
|
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"
|
:class="resolveLedgerBadge(item).className"
|
||||||
>
|
>
|
||||||
<i :class="['ph-bold text-[10px] leading-none', resolveLedgerBadge(item).icon]" />
|
<i :class="['ph-bold ui-chip-icon text-[10px]', resolveLedgerBadge(item).icon]" />
|
||||||
<span class="inline-flex items-center leading-none">{{ resolveLedgerBadge(item).label }}</span>
|
<span class="ui-chip-text">{{ resolveLedgerBadge(item).label }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="resolveAiBadge(item)"
|
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"
|
:class="resolveAiBadge(item).className"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
:class="[
|
:class="[
|
||||||
'ph-bold text-[10px] leading-none',
|
'ph-bold ui-chip-icon text-[10px]',
|
||||||
resolveAiBadge(item).icon,
|
resolveAiBadge(item).icon,
|
||||||
resolveAiBadge(item).iconClassName,
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs leading-5 text-stone-400">
|
<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>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
|
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { useTransactionStore } from '../stores/transactions'
|
import { useTransactionStore } from '../stores/transactions'
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'
|
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const transactionStore = useTransactionStore()
|
const transactionStore = useTransactionStore()
|
||||||
const fileInputRef = ref(null)
|
const fileInputRef = ref(null)
|
||||||
@@ -135,6 +137,14 @@ const openImportPicker = () => {
|
|||||||
importFileInputRef.value?.click()
|
importFileInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goImportHistory = () => {
|
||||||
|
router.push({ name: 'imports' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const goNotificationDebug = () => {
|
||||||
|
router.push({ name: 'notifications' })
|
||||||
|
}
|
||||||
|
|
||||||
const handleAvatarChange = (event) => {
|
const handleAvatarChange = (event) => {
|
||||||
const [file] = event.target.files || []
|
const [file] = event.target.files || []
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -436,9 +446,9 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="notificationCaptureEnabled" class="px-4 pb-4">
|
<div class="px-4 pb-4">
|
||||||
<div
|
<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"
|
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" />
|
<i class="ph-bold ph-warning text-orange-500 mt-0.5" />
|
||||||
@@ -452,10 +462,24 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<i class="ph-bold ph-check-circle" />
|
||||||
通知权限已开启,Echo 会在收到新通知后自动刷新首页。
|
通知权限已开启,Echo 会在收到新通知后自动刷新首页。
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -608,6 +632,23 @@ onMounted(() => {
|
|||||||
{{ lastImportSummary.skippedReasons.slice(0, 2).join(';') }}
|
{{ lastImportSummary.skippedReasons.slice(0, 2).join(';') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
class="flex w-full items-center justify-between p-4 active:bg-stone-50 transition text-left"
|
class="flex w-full items-center justify-between p-4 active:bg-stone-50 transition text-left"
|
||||||
@click="handleClearCache"
|
@click="handleClearCache"
|
||||||
@@ -635,3 +676,4 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user