feat:新增通知调试台和导入历史页

This commit is contained in:
2026-03-13 10:00:21 +08:00
parent 5d45ba4731
commit 601cbd7576
15 changed files with 1111 additions and 73 deletions

View File

@@ -493,3 +493,96 @@ export const exportTransactionsAsJson = async () => {
)
return transactions.length
}
const parseSummaryJson = (value) => {
if (!value) return null
try {
return JSON.parse(value)
} catch {
return null
}
}
export const fetchImportBatches = async (limit = 20) => {
const db = await getDb()
const safeLimit = Math.max(1, Math.min(Number(limit) || 20, 100))
const result = await db.query(
`
SELECT
id,
source,
file_name,
imported_at,
total_count,
inserted_count,
merged_count,
skipped_count,
status,
summary_json
FROM import_batches
ORDER BY imported_at DESC
LIMIT ?
`,
[safeLimit],
)
return (result?.values || []).map((row) => ({
id: row.id,
source: row.source || '',
fileName: row.file_name || '',
importedAt: row.imported_at || '',
totalCount: Number(row.total_count || 0),
insertedCount: Number(row.inserted_count || 0),
mergedCount: Number(row.merged_count || 0),
skippedCount: Number(row.skipped_count || 0),
status: row.status || 'completed',
summary: parseSummaryJson(row.summary_json),
}))
}
export const fetchImportBatchLines = async (batchId, status = 'all') => {
if (!batchId) return []
const db = await getDb()
const params = [batchId]
const statusClause = status !== 'all' ? 'AND status = ?' : ''
if (status !== 'all') {
params.push(status)
}
const result = await db.query(
`
SELECT
id,
batch_id,
source_order_id,
merchant,
amount,
occurred_at,
status,
transaction_id,
reason,
raw_data,
created_at
FROM import_lines
WHERE batch_id = ?
${statusClause}
ORDER BY occurred_at DESC, created_at DESC
`,
params,
)
return (result?.values || []).map((row) => ({
id: row.id,
batchId: row.batch_id,
sourceOrderId: row.source_order_id || '',
merchant: row.merchant || '',
amount: Number(row.amount || 0),
occurredAt: row.occurred_at || '',
status: row.status || 'skipped',
transactionId: row.transaction_id || '',
reason: row.reason || '',
rawData: parseSummaryJson(row.raw_data) || {},
createdAt: row.created_at || '',
}))
}

View File

@@ -0,0 +1,154 @@
import { v4 as uuidv4 } from 'uuid'
import { getDb, saveDbToStore } from '../lib/sqlite.js'
export const NOTIFICATION_DEBUG_STATUS = {
review: 'review',
unmatched: 'unmatched',
matched: 'matched',
ignored: 'ignored',
error: 'error',
}
const mapRow = (row) => ({
id: row.id,
sourceId: row.source_id || '',
channel: row.channel || '',
title: row.title || '',
text: row.text || '',
postedAt: row.posted_at || '',
status: row.status || NOTIFICATION_DEBUG_STATUS.unmatched,
ruleId: row.rule_id || '',
transactionId: row.transaction_id || '',
errorMessage: row.error_message || '',
})
const getLatestBySourceId = async (sourceId) => {
if (!sourceId) return null
const db = await getDb()
const result = await db.query(
`
SELECT *
FROM notifications_raw
WHERE source_id = ?
ORDER BY posted_at DESC
LIMIT 1
`,
[sourceId],
)
return result?.values?.[0] ? mapRow(result.values[0]) : null
}
export const ensureNotificationRaw = async (notification, options = {}) => {
const sourceId = notification?.id || notification?.sourceId || ''
const existing = await getLatestBySourceId(sourceId)
if (existing) return existing
const db = await getDb()
const id = notification?.rawId || uuidv4()
await db.run(
`
INSERT INTO notifications_raw (
id,
source_id,
channel,
title,
text,
posted_at,
status,
rule_id,
transaction_id,
error_message
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
id,
sourceId || null,
notification?.channel || null,
notification?.title || null,
notification?.text || '',
notification?.createdAt || notification?.postedAt || new Date().toISOString(),
options.status || NOTIFICATION_DEBUG_STATUS.unmatched,
options.ruleId || null,
options.transactionId || null,
options.errorMessage || null,
],
)
await saveDbToStore()
return {
id,
sourceId,
channel: notification?.channel || '',
title: notification?.title || '',
text: notification?.text || '',
postedAt: notification?.createdAt || notification?.postedAt || '',
status: options.status || NOTIFICATION_DEBUG_STATUS.unmatched,
ruleId: options.ruleId || '',
transactionId: options.transactionId || '',
errorMessage: options.errorMessage || '',
}
}
export const updateNotificationRawStatus = async (sourceId, next = {}) => {
if (!sourceId) return null
const existing = await getLatestBySourceId(sourceId)
if (!existing) return null
const db = await getDb()
await db.run(
`
UPDATE notifications_raw
SET status = ?, rule_id = ?, transaction_id = ?, error_message = ?
WHERE id = ?
`,
[
next.status || existing.status,
next.ruleId ?? existing.ruleId ?? null,
next.transactionId ?? existing.transactionId ?? null,
next.errorMessage ?? existing.errorMessage ?? null,
existing.id,
],
)
await saveDbToStore()
return {
...existing,
status: next.status || existing.status,
ruleId: next.ruleId ?? existing.ruleId,
transactionId: next.transactionId ?? existing.transactionId,
errorMessage: next.errorMessage ?? existing.errorMessage,
}
}
export const fetchNotificationDebugRecords = async (options = {}) => {
const db = await getDb()
const params = []
const where = []
if (options.status && options.status !== 'all') {
where.push('status = ?')
params.push(options.status)
}
if (options.keyword) {
where.push('(channel LIKE ? OR title LIKE ? OR text LIKE ?)')
const keyword = `%${String(options.keyword).trim()}%`
params.push(keyword, keyword, keyword)
}
const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : ''
const limit = Math.max(1, Math.min(Number(options.limit) || 80, 200))
params.push(limit)
const result = await db.query(
`
SELECT *
FROM notifications_raw
${whereClause}
ORDER BY posted_at DESC
LIMIT ?
`,
params,
)
return (result?.values || []).map(mapRow)
}

View File

@@ -3,17 +3,17 @@ import NotificationBridge, {
isNativeNotificationBridgeAvailable,
} from '../lib/notificationBridge'
// 原生插件回调,可按需在应用启动时注入,例如用于上报到服务端
// 原生插件回调,可按需在应用启动时注入,例如后续接服务端同步。
let remoteNotificationFetcher = async () => []
let remoteNotificationAcknowledger = async () => {}
// 判断当前是否原生环境Android 容器内
// 判断当前是否原生容器内
const nativeBridgeReady = isNativeNotificationBridgeAvailable()
/**
* 本地模拟通知缓存:
* - 浏览器环境:用于 Demo & Mock可通过 pushLocalNotification 主动注入
* - 原生环境:一般保持为空,仅在必要时作为 NotificationBridge 数据的补充兜底
* - 浏览器环境:用于规则调试和 Demo。
* - 原生环境:通常保持为空,仅在必要时补充桥接层数据。
*/
let localNotificationQueue = []
@@ -29,9 +29,9 @@ const sortByCreatedAtDesc = (a, b) =>
new Date(b.createdAt || b.id) - new Date(a.createdAt || a.id)
/**
* 统一取待处理通知队列
* - 原生环境:优先 NotificationBridge 中拿未处理通知
* - 浏览器:仅使用本地模拟通知
* 统一取待处理通知队列
* - 原生环境:优先读取 NotificationBridge 中的待处理通知
* - 浏览器环境:仅使用本地模拟通知
*/
export const fetchNotificationQueue = async () => {
const remote = await remoteNotificationFetcher()
@@ -40,20 +40,21 @@ export const fetchNotificationQueue = async () => {
}
/**
* 浏览器环境下模拟「新通知」进入队列
* 浏览器环境下主动注入一条模拟通知。
*/
export const pushLocalNotification = (payload) => {
const entry = {
id: payload?.id || uuidv4(),
createdAt: payload?.createdAt || new Date().toISOString(),
channel: payload?.channel || '模拟',
channel: payload?.channel || '\u6a21\u62df\u901a\u77e5',
title: payload?.title || '',
text: payload?.text || '',
}
localNotificationQueue = [entry, ...localNotificationQueue]
}
/**
* 确认/忽略某条通知后,从本地队列移除,并尝试同步到远端
* 确认忽略通知后,从本地队列移除,并尝试同步给原生层。
*/
export const acknowledgeNotification = async (id) => {
if (id) {
@@ -62,32 +63,27 @@ export const acknowledgeNotification = async (id) => {
try {
await remoteNotificationAcknowledger(id)
} catch (error) {
console.warn('[notifications] 远程通知确认失败,将在下次同步重试', error)
console.warn('[notifications] remote acknowledgement failed, will retry on next sync', error)
}
}
// ===== 原生桥接Android NotificationListenerService集成 =====
if (nativeBridgeReady) {
// 从原生层拉取待处理通知
setRemoteNotificationFetcher(async () => {
try {
const { notifications = [] } = await NotificationBridge.getPendingNotifications()
return notifications
} catch (error) {
console.warn('[notifications] 获取原生通知失败,退回本地模拟数据', error)
console.warn('[notifications] failed to fetch native notifications, fallback to local queue', error)
return []
}
})
// 通知已确认/忽略后,告知原生层清除对应记录
setRemoteNotificationAcknowledger(async (id) => {
if (!id) return
try {
await NotificationBridge.acknowledgeNotification({ id })
} catch (error) {
console.warn('[notifications] 通知确认同步至原生失败', error)
console.warn('[notifications] failed to acknowledge native notification', error)
}
})
}