Compare commits
4 Commits
e70b87a212
...
c8b30319e3
| Author | SHA1 | Date | |
|---|---|---|---|
| c8b30319e3 | |||
| 601cbd7576 | |||
| 5d45ba4731 | |||
| 6c209d8781 |
52
README.md
52
README.md
@@ -1,24 +1,52 @@
|
||||
# Echo
|
||||
# Echo
|
||||
|
||||
Local-first personal finance app built with Vue 3, Vite, Capacitor, and SQLite.
|
||||
Echo 是一个本地优先的个人记账 App,当前重点是自动记账、储值账户语义、AI 分类与本地账本分析。
|
||||
|
||||
## Commands
|
||||
## 当前文档入口
|
||||
|
||||
- 主说明:[design/README.md](design/README.md)
|
||||
- 项目现状与阶段规划:[design/echo-阶段总结与规划.md](design/echo-%E9%98%B6%E6%AE%B5%E6%80%BB%E7%BB%93%E4%B8%8E%E8%A7%84%E5%88%92.md)
|
||||
- 设计/规划整理结论:[design/设计整理-2026-03-12.md](design/%E8%AE%BE%E8%AE%A1%E6%95%B4%E7%90%86-2026-03-12.md)
|
||||
- 版本与协作约定:[design/版本与协作约定.md](design/%E7%89%88%E6%9C%AC%E4%B8%8E%E5%8D%8F%E4%BD%9C%E7%BA%A6%E5%AE%9A.md)
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
npm run rules:sync
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run rules:sync
|
||||
npm run rule:smoke
|
||||
npm run version:sync
|
||||
npm run release:patch
|
||||
npm run release:minor
|
||||
npm run release:major
|
||||
```
|
||||
|
||||
## Structure
|
||||
## 版本管理
|
||||
|
||||
- `src/`: web app, stores, services, views, and SQLite bridge
|
||||
- `android/`: Capacitor Android shell and notification listener implementation
|
||||
- `scripts/`: release and local verification scripts
|
||||
- `src/config/notificationRules.js`: single source of truth for notification rules
|
||||
当前版本管理机制保留这套:
|
||||
- `package.json.version` 是单一版本源。
|
||||
- `npm run version:sync` 会同步 Android `versionCode/versionName`。
|
||||
- `npm run release:*` 负责 bump 版本、同步 Android、提交 release commit 并打 tag。
|
||||
|
||||
## Notes
|
||||
建议按下面的方式使用:
|
||||
- 日常开发不要直接在 `main` 上堆改动,开 `feat/*` 或 `fix/*` 分支。
|
||||
- `main` 只保留已验证、准备发版的内容。
|
||||
- release 必须在干净工作区执行。
|
||||
|
||||
- Transaction data is stored locally.
|
||||
- After editing notification rules, run `npm run rules:sync` to regenerate the Android rule file.
|
||||
当前仓库已经加了两条保护:
|
||||
- 只能在 `main` 分支执行 release。
|
||||
- 工作区有未提交改动时禁止 release。
|
||||
|
||||
## 目录说明
|
||||
|
||||
- `src/`:前端页面、状态、服务、SQLite 与 AI/通知逻辑。
|
||||
- `android/`:Capacitor Android 工程和通知监听原生实现。
|
||||
- `scripts/`:规则同步、版本同步、发布脚本。
|
||||
- `design/`:当前说明文档、历史设计稿和整理索引。
|
||||
|
||||
## 说明
|
||||
|
||||
- 通知规则单一来源:`src/config/notificationRules.js`
|
||||
- 修改通知规则后,需要执行一次 `npm run rules:sync`
|
||||
- 当前 AI 第一阶段使用本地 DeepSeek Key,正式发布前仍建议切到后端代理模式
|
||||
|
||||
@@ -9,8 +9,8 @@ android {
|
||||
applicationId "com.echo.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 400
|
||||
versionName "0.4.0"
|
||||
versionCode 500
|
||||
versionName "0.5.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
aaptOptions {
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "echo-app",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "echo-app",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"dependencies": {
|
||||
"@capacitor-community/sqlite": "^5.7.2",
|
||||
"@capacitor/android": "^5.7.8",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "echo-app",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* 自定义发布脚本:接管 npm version + Android 版本同步 + git 提交与打 tag */
|
||||
const { execSync } = require('child_process')
|
||||
const { execSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
@@ -10,23 +9,34 @@ const run = (cmd) => {
|
||||
execSync(cmd, { stdio: 'inherit', cwd: rootDir })
|
||||
}
|
||||
|
||||
const read = (cmd) => execSync(cmd, { cwd: rootDir, encoding: 'utf8' }).trim()
|
||||
|
||||
const readJson = (file) =>
|
||||
JSON.parse(fs.readFileSync(path.join(rootDir, file), 'utf8'))
|
||||
|
||||
const ensureReleaseReady = () => {
|
||||
const branch = read('git branch --show-current')
|
||||
if (branch !== 'main') {
|
||||
throw new Error(`release 只能在 main 分支执行,当前分支:${branch}`)
|
||||
}
|
||||
|
||||
const status = read('git status --short')
|
||||
if (status) {
|
||||
throw new Error('release 前请先提交或清理当前改动,工作区必须保持干净')
|
||||
}
|
||||
}
|
||||
|
||||
const main = () => {
|
||||
const type = process.argv[2] || 'patch' // patch / minor / major
|
||||
const type = process.argv[2] || 'patch'
|
||||
|
||||
ensureReleaseReady()
|
||||
|
||||
// 1)仅更新 version 字段,不让 npm 自动 commit / tag(.npmrc 已全局禁止,但这里再显式加一层保险)
|
||||
run(`npm version ${type} --no-git-tag-version`)
|
||||
|
||||
// 2)同步 Android 版本(versionCode / versionName)
|
||||
run('npm run version:sync')
|
||||
|
||||
// 3)读取新版本号
|
||||
const pkg = readJson('package.json')
|
||||
const version = pkg.version
|
||||
|
||||
// 4)只提交与版本相关的文件,避免误提交其它开发中的改动
|
||||
const filesToAdd = [
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
@@ -34,7 +44,6 @@ const main = () => {
|
||||
]
|
||||
run(`git add ${filesToAdd.join(' ')}`)
|
||||
|
||||
// 5)提交并打 tag
|
||||
run(`git commit -m "chore: release v${version}"`)
|
||||
run(`git tag v${version}`)
|
||||
|
||||
@@ -42,4 +51,3 @@ const main = () => {
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* 同步前端 package.json 的版本号到 Android 工程(versionCode / versionName) */
|
||||
const fs = require('fs')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
@@ -16,7 +15,7 @@ const calcVersionCode = (version) => {
|
||||
const [major = 0, minor = 0, patch = 0] = String(version)
|
||||
.split('.')
|
||||
.map((n) => Number.parseInt(n, 10) || 0)
|
||||
// 简单规则:MMmmpp → 1.2.3 => 10203,足够覆盖 0–99 范围
|
||||
|
||||
return major * 10000 + minor * 100 + patch
|
||||
}
|
||||
|
||||
@@ -49,4 +48,3 @@ const main = () => {
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
const currentTab = computed(() => route.name)
|
||||
const currentTab = computed(() => route.meta?.tab || route.name)
|
||||
|
||||
const goTab = (name) => {
|
||||
if (route.name === name) return
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
} from '../services/notificationRuleService'
|
||||
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import {
|
||||
ensureNotificationRaw,
|
||||
NOTIFICATION_DEBUG_STATUS,
|
||||
updateNotificationRawStatus,
|
||||
} from '../services/notificationDebugService.js'
|
||||
|
||||
const notifications = ref([])
|
||||
const processingId = ref('')
|
||||
@@ -40,7 +45,6 @@ export const useTransactionEntry = () => {
|
||||
const transactionStore = useTransactionStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
// 原生端:在首次同步前做一次权限校验与请求
|
||||
const ensureNativePermission = async () => {
|
||||
if (!nativeBridgeReady || permissionChecked) return
|
||||
try {
|
||||
@@ -49,7 +53,7 @@ export const useTransactionEntry = () => {
|
||||
await NotificationBridge.requestPermission()
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[notifications] 原生权限检查失败', error)
|
||||
console.warn('[notifications] native permission check failed', error)
|
||||
} finally {
|
||||
permissionChecked = true
|
||||
}
|
||||
@@ -62,15 +66,25 @@ export const useTransactionEntry = () => {
|
||||
const confirmNotification = async (id) => {
|
||||
const target = notifications.value.find((item) => item.id === id)
|
||||
if (!target || processingId.value) return
|
||||
|
||||
processingId.value = id
|
||||
try {
|
||||
await ensureRulesReady()
|
||||
await transactionStore.ensureInitialized()
|
||||
const { transaction } = transformNotificationToTransaction(target, {
|
||||
|
||||
const { transaction, rule } = transformNotificationToTransaction(target, {
|
||||
ruleSet: ruleSet.value,
|
||||
date: target.createdAt,
|
||||
})
|
||||
await transactionStore.addTransaction(transaction)
|
||||
|
||||
const created = await transactionStore.addTransaction(transaction)
|
||||
await updateNotificationRawStatus(id, {
|
||||
status: NOTIFICATION_DEBUG_STATUS.matched,
|
||||
ruleId: target.ruleId || rule?.id || '',
|
||||
transactionId: created?.id || '',
|
||||
errorMessage: null,
|
||||
})
|
||||
|
||||
removeNotification(id)
|
||||
await acknowledgeNotification(id)
|
||||
} finally {
|
||||
@@ -79,64 +93,87 @@ export const useTransactionEntry = () => {
|
||||
}
|
||||
|
||||
const dismissNotification = async (id) => {
|
||||
await updateNotificationRawStatus(id, {
|
||||
status: NOTIFICATION_DEBUG_STATUS.ignored,
|
||||
errorMessage: '\u7528\u6237\u624b\u52a8\u5ffd\u7565',
|
||||
})
|
||||
removeNotification(id)
|
||||
await acknowledgeNotification(id)
|
||||
}
|
||||
|
||||
const syncNotifications = async () => {
|
||||
if (syncing.value) return
|
||||
|
||||
syncing.value = true
|
||||
try {
|
||||
// 若用户关闭了通知自动捕获,则不从原生/模拟队列拉取新通知,只清空待确认列表
|
||||
if (!settingsStore.notificationCaptureEnabled) {
|
||||
notifications.value = []
|
||||
return
|
||||
}
|
||||
|
||||
if (nativeBridgeReady) {
|
||||
await ensureNativePermission()
|
||||
}
|
||||
|
||||
await ensureRulesReady()
|
||||
await transactionStore.ensureInitialized()
|
||||
|
||||
const queue = await fetchNotificationQueue()
|
||||
const manualQueue = []
|
||||
|
||||
for (const item of queue) {
|
||||
await ensureNotificationRaw(item, {
|
||||
status: NOTIFICATION_DEBUG_STATUS.unmatched,
|
||||
})
|
||||
|
||||
const { transaction, rule, requiresConfirmation } = transformNotificationToTransaction(item, {
|
||||
ruleSet: ruleSet.value,
|
||||
date: item.createdAt,
|
||||
})
|
||||
|
||||
// 如果没有任何规则命中(rule 为空),默认忽略该通知,避免系统通知等噪音进入待确认列表
|
||||
if (!rule) {
|
||||
await updateNotificationRawStatus(item.id, {
|
||||
status: NOTIFICATION_DEBUG_STATUS.unmatched,
|
||||
errorMessage: '\u672a\u547d\u4e2d\u4efb\u4f55\u901a\u77e5\u89c4\u5219',
|
||||
})
|
||||
await acknowledgeNotification(item.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (nativeBridgeReady) {
|
||||
// 原生环境:规则在 Kotlin 原生层已经执行并写入 SQLite,这里只负责「待确认」列表和状态同步
|
||||
if (!requiresConfirmation) {
|
||||
// 已由原生自动入账,只需从原生通知队列中移除该条
|
||||
await acknowledgeNotification(item.id)
|
||||
if (!requiresConfirmation) {
|
||||
if (!nativeBridgeReady) {
|
||||
const created = await transactionStore.addTransaction(transaction)
|
||||
await updateNotificationRawStatus(item.id, {
|
||||
status: NOTIFICATION_DEBUG_STATUS.matched,
|
||||
ruleId: rule?.id || '',
|
||||
transactionId: created?.id || '',
|
||||
errorMessage: null,
|
||||
})
|
||||
} else {
|
||||
manualQueue.push({
|
||||
...item,
|
||||
suggestion: transaction,
|
||||
ruleId: rule?.id || null,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Web / 模拟环境:沿用原有逻辑,由 JS 侧写入 SQLite
|
||||
if (!requiresConfirmation) {
|
||||
await transactionStore.addTransaction(transaction)
|
||||
await acknowledgeNotification(item.id)
|
||||
} else {
|
||||
manualQueue.push({
|
||||
...item,
|
||||
suggestion: transaction,
|
||||
ruleId: rule?.id || null,
|
||||
await updateNotificationRawStatus(item.id, {
|
||||
status: NOTIFICATION_DEBUG_STATUS.matched,
|
||||
ruleId: rule?.id || '',
|
||||
errorMessage: null,
|
||||
})
|
||||
}
|
||||
|
||||
await acknowledgeNotification(item.id)
|
||||
continue
|
||||
}
|
||||
|
||||
await updateNotificationRawStatus(item.id, {
|
||||
status: NOTIFICATION_DEBUG_STATUS.review,
|
||||
ruleId: rule?.id || '',
|
||||
errorMessage: null,
|
||||
})
|
||||
|
||||
manualQueue.push({
|
||||
...item,
|
||||
suggestion: transaction,
|
||||
ruleId: rule?.id || null,
|
||||
})
|
||||
}
|
||||
|
||||
notifications.value = manualQueue
|
||||
} finally {
|
||||
syncing.value = false
|
||||
@@ -150,9 +187,8 @@ export const useTransactionEntry = () => {
|
||||
|
||||
if (!bootstrapped) {
|
||||
bootstrapped = true
|
||||
syncNotifications()
|
||||
void syncNotifications()
|
||||
|
||||
// 原生环境:监听 NotificationBridgePlugin 推送的 notificationPosted 事件,做到“通知一到就同步一次”
|
||||
if (nativeBridgeReady && !nativeNotificationListener && typeof NotificationBridge?.addListener === 'function') {
|
||||
NotificationBridge.addListener('notificationPosted', async () => {
|
||||
await syncNotifications()
|
||||
@@ -161,11 +197,10 @@ export const useTransactionEntry = () => {
|
||||
nativeNotificationListener = handle
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[notifications] 无法注册 notificationPosted 监听', error)
|
||||
console.warn('[notifications] failed to register notificationPosted listener', error)
|
||||
})
|
||||
}
|
||||
|
||||
// 前台轮询兜底:即使偶发漏掉原生事件,也能定期从队列补拉一次
|
||||
if (!pollTimer && typeof setInterval === 'function') {
|
||||
pollTimer = setInterval(() => {
|
||||
if (!syncing.value) {
|
||||
|
||||
@@ -26,7 +26,15 @@ const TRANSACTION_TABLE_SQL = `
|
||||
ai_reason TEXT,
|
||||
ai_status TEXT DEFAULT 'idle',
|
||||
ai_model TEXT,
|
||||
ai_normalized_merchant TEXT
|
||||
ai_normalized_merchant TEXT,
|
||||
source_type TEXT,
|
||||
source_order_id TEXT,
|
||||
source_merchant_order_id TEXT,
|
||||
source_counterparty TEXT,
|
||||
source_account TEXT,
|
||||
source_payment_method TEXT,
|
||||
source_record_hash TEXT,
|
||||
import_batch_id TEXT
|
||||
);
|
||||
`
|
||||
const MERCHANT_PROFILE_TABLE_SQL = `
|
||||
@@ -39,6 +47,49 @@ const MERCHANT_PROFILE_TABLE_SQL = `
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
const NOTIFICATION_RAW_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS notifications_raw (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
source_id TEXT,
|
||||
channel TEXT,
|
||||
title TEXT,
|
||||
text TEXT,
|
||||
posted_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
rule_id TEXT,
|
||||
transaction_id TEXT,
|
||||
error_message TEXT
|
||||
);
|
||||
`
|
||||
const IMPORT_BATCH_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS import_batches (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
file_name TEXT,
|
||||
imported_at TEXT NOT NULL,
|
||||
total_count INTEGER DEFAULT 0,
|
||||
inserted_count INTEGER DEFAULT 0,
|
||||
merged_count INTEGER DEFAULT 0,
|
||||
skipped_count INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'completed',
|
||||
summary_json TEXT
|
||||
);
|
||||
`
|
||||
const IMPORT_LINE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS import_lines (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
batch_id TEXT NOT NULL,
|
||||
source_order_id TEXT,
|
||||
merchant TEXT,
|
||||
amount REAL,
|
||||
occurred_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
transaction_id TEXT,
|
||||
reason TEXT,
|
||||
raw_data TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`
|
||||
const TRANSACTION_REQUIRED_COLUMNS = {
|
||||
entry_type: "TEXT DEFAULT 'expense'",
|
||||
fund_source_type: "TEXT DEFAULT 'cash'",
|
||||
@@ -54,6 +105,14 @@ const TRANSACTION_REQUIRED_COLUMNS = {
|
||||
ai_status: "TEXT DEFAULT 'idle'",
|
||||
ai_model: 'TEXT',
|
||||
ai_normalized_merchant: 'TEXT',
|
||||
source_type: 'TEXT',
|
||||
source_order_id: 'TEXT',
|
||||
source_merchant_order_id: 'TEXT',
|
||||
source_counterparty: 'TEXT',
|
||||
source_account: 'TEXT',
|
||||
source_payment_method: 'TEXT',
|
||||
source_record_hash: 'TEXT',
|
||||
import_batch_id: 'TEXT',
|
||||
}
|
||||
|
||||
let sqliteConnection
|
||||
@@ -141,6 +200,9 @@ const prepareConnection = async () => {
|
||||
await db.open()
|
||||
await db.execute(TRANSACTION_TABLE_SQL)
|
||||
await db.execute(MERCHANT_PROFILE_TABLE_SQL)
|
||||
await db.execute(NOTIFICATION_RAW_TABLE_SQL)
|
||||
await db.execute(IMPORT_BATCH_TABLE_SQL)
|
||||
await db.execute(IMPORT_LINE_TABLE_SQL)
|
||||
await ensureTableColumns('transactions', TRANSACTION_REQUIRED_COLUMNS)
|
||||
initialized = true
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import HomeView from '../views/HomeView.vue'
|
||||
import ListView from '../views/ListView.vue'
|
||||
import SettingsView from '../views/SettingsView.vue'
|
||||
|
||||
// TODO: 后续补充真实的分析页实现,这里先占位
|
||||
const ImportHistoryView = () => import('../views/ImportHistoryView.vue')
|
||||
const NotificationDebugView = () => import('../views/NotificationDebugView.vue')
|
||||
const AnalysisView = () => import('../views/AnalysisView.vue').catch(() => null)
|
||||
|
||||
const router = createRouter({
|
||||
@@ -33,8 +34,19 @@ const router = createRouter({
|
||||
component: SettingsView,
|
||||
meta: { tab: 'settings' },
|
||||
},
|
||||
{
|
||||
path: '/imports',
|
||||
name: 'imports',
|
||||
component: ImportHistoryView,
|
||||
meta: { tab: 'settings' },
|
||||
},
|
||||
{
|
||||
path: '/notifications',
|
||||
name: 'notifications',
|
||||
component: NotificationDebugView,
|
||||
meta: { tab: 'settings' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
|
||||
588
src/services/dataTransferService.js
Normal file
588
src/services/dataTransferService.js
Normal file
@@ -0,0 +1,588 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { fetchTransactions, insertTransaction, mergeImportedTransaction } from './transactionService.js'
|
||||
import { getDb, saveDbToStore } from '../lib/sqlite.js'
|
||||
|
||||
const IMPORT_SOURCE_ALIPAY = 'alipay'
|
||||
const IMPORT_SUCCESS_STATUS = '交易成功'
|
||||
const ALIPAY_REQUIRED_HEADERS = ['交易时间', '交易分类', '交易对方', '商品说明', '收/支', '金额', '交易状态']
|
||||
const ALIPAY_HEADERS = {
|
||||
occurredAt: '交易时间',
|
||||
tradeCategory: '交易分类',
|
||||
merchant: '交易对方',
|
||||
counterpartyAccount: '对方账号',
|
||||
productName: '商品说明',
|
||||
direction: '收/支',
|
||||
amount: '金额',
|
||||
paymentMethod: '收/付款方式',
|
||||
status: '交易状态',
|
||||
orderId: '交易订单号',
|
||||
merchantOrderId: '商家订单号',
|
||||
note: '备注',
|
||||
}
|
||||
|
||||
const downloadText = (filename, text, mimeType) => {
|
||||
const blob = new Blob([text], { type: mimeType })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const escapeCsvCell = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
|
||||
const cleanCell = (value) =>
|
||||
String(value ?? '')
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/\r/g, '')
|
||||
.replace(/^"|"$/g, '')
|
||||
.trim()
|
||||
|
||||
const normalizeMerchantKey = (value) =>
|
||||
String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[\s"'`~!@#$%^&*()_+\-=[\]{};:,.<>/?\\|,。!?;:()【】《》·、]/g, '')
|
||||
.slice(0, 80)
|
||||
|
||||
const normalizeAmount = (value) => {
|
||||
const numeric = Number(String(value ?? '').replace(/[^\d.-]/g, ''))
|
||||
return Number.isFinite(numeric) ? numeric : 0
|
||||
}
|
||||
|
||||
const normalizeDate = (value) => {
|
||||
if (!value) return ''
|
||||
const normalized = String(value).trim().replace(/\//g, '-')
|
||||
const parsed = new Date(normalized)
|
||||
return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString()
|
||||
}
|
||||
|
||||
const parseDelimitedLine = (line, delimiter) => {
|
||||
const cells = []
|
||||
let current = ''
|
||||
let inQuotes = false
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index]
|
||||
const next = line[index + 1]
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && next === '"') {
|
||||
current += '"'
|
||||
index += 1
|
||||
} else {
|
||||
inQuotes = !inQuotes
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!inQuotes && char === delimiter) {
|
||||
cells.push(cleanCell(current))
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
|
||||
current += char
|
||||
}
|
||||
|
||||
cells.push(cleanCell(current))
|
||||
return cells
|
||||
}
|
||||
|
||||
const detectHeaderIndex = (rows) =>
|
||||
rows.findIndex((row) => ALIPAY_REQUIRED_HEADERS.every((header) => row.includes(header)))
|
||||
|
||||
const extractRowsFromHtml = (content) => {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(content, 'text/html')
|
||||
return Array.from(doc.querySelectorAll('tr')).map((row) =>
|
||||
Array.from(row.querySelectorAll('th,td')).map((cell) => cleanCell(cell.textContent)),
|
||||
)
|
||||
}
|
||||
|
||||
const extractRowsFromText = (content) => {
|
||||
const lines = content.split(/\r?\n/).filter((line) => line.trim())
|
||||
const headerLine = lines.find((line) => ALIPAY_REQUIRED_HEADERS.every((header) => line.includes(header))) || ''
|
||||
const delimiter = headerLine.split('\t').length >= headerLine.split(',').length ? '\t' : ','
|
||||
return lines.map((line) => parseDelimitedLine(line, delimiter))
|
||||
}
|
||||
|
||||
const parseAlipayRows = (content) => {
|
||||
const rows = /<table/i.test(content) ? extractRowsFromHtml(content) : extractRowsFromText(content)
|
||||
const headerIndex = detectHeaderIndex(rows)
|
||||
if (headerIndex < 0) {
|
||||
throw new Error('未找到支付宝账单表头,请确认导出文件内容完整')
|
||||
}
|
||||
|
||||
const header = rows[headerIndex]
|
||||
const getCell = (row, key) => row[header.indexOf(ALIPAY_HEADERS[key])] || ''
|
||||
|
||||
return rows
|
||||
.slice(headerIndex + 1)
|
||||
.filter((row) => cleanCell(getCell(row, 'occurredAt')))
|
||||
.map((row) => ({
|
||||
occurredAt: cleanCell(getCell(row, 'occurredAt')),
|
||||
tradeCategory: cleanCell(getCell(row, 'tradeCategory')),
|
||||
merchant: cleanCell(getCell(row, 'merchant')),
|
||||
counterpartyAccount: cleanCell(getCell(row, 'counterpartyAccount')),
|
||||
productName: cleanCell(getCell(row, 'productName')),
|
||||
direction: cleanCell(getCell(row, 'direction')),
|
||||
amount: cleanCell(getCell(row, 'amount')),
|
||||
paymentMethod: cleanCell(getCell(row, 'paymentMethod')),
|
||||
status: cleanCell(getCell(row, 'status')),
|
||||
orderId: cleanCell(getCell(row, 'orderId')),
|
||||
merchantOrderId: cleanCell(getCell(row, 'merchantOrderId')),
|
||||
note: cleanCell(getCell(row, 'note')),
|
||||
}))
|
||||
}
|
||||
|
||||
const resolveCategory = (row, direction) => {
|
||||
const text = `${row.tradeCategory} ${row.productName} ${row.merchant}`.toLowerCase()
|
||||
if (direction === 'neutral') return 'Transfer'
|
||||
if (direction === 'income') return 'Income'
|
||||
if (/餐饮|美食|咖啡|奶茶|外卖|饭|面|粉/.test(text)) return 'Food'
|
||||
if (/交通|出行|打车|公交|地铁|单车|骑行|哈啰|滴滴/.test(text)) return 'Transport'
|
||||
if (/超市|便利|百货|买菜|生鲜|7-11|罗森|全家/.test(text)) return 'Groceries'
|
||||
if (/健康|药|医院|诊所|体检/.test(text)) return 'Health'
|
||||
if (/娱乐|电影|游戏|门票|演出|网吧/.test(text)) return 'Entertainment'
|
||||
return direction === 'expense' ? 'Expense' : 'Uncategorized'
|
||||
}
|
||||
|
||||
const resolveFundingSource = (paymentMethod) => {
|
||||
const text = String(paymentMethod || '')
|
||||
if (!text) return { fundSourceType: 'cash', fundSourceName: '现金账户' }
|
||||
if (/信用卡|花呗|白条/.test(text)) {
|
||||
return { fundSourceType: 'credit', fundSourceName: text }
|
||||
}
|
||||
if (/储蓄卡|银行卡|银行/.test(text)) {
|
||||
return { fundSourceType: 'bank', fundSourceName: text }
|
||||
}
|
||||
if (/余额|零钱|账户余额/.test(text)) {
|
||||
return { fundSourceType: 'cash', fundSourceName: text }
|
||||
}
|
||||
if (/卡/.test(text)) {
|
||||
return { fundSourceType: 'stored_value', fundSourceName: text }
|
||||
}
|
||||
return { fundSourceType: 'cash', fundSourceName: text }
|
||||
}
|
||||
|
||||
const buildSourceRecordHash = (payload) =>
|
||||
[
|
||||
payload.sourceType,
|
||||
payload.date,
|
||||
payload.amount.toFixed(2),
|
||||
normalizeMerchantKey(payload.merchant),
|
||||
normalizeMerchantKey(payload.note),
|
||||
].join('|')
|
||||
|
||||
const normalizeImportedRow = (row, importBatchId) => {
|
||||
const normalizedDate = normalizeDate(row.occurredAt)
|
||||
if (!normalizedDate) return null
|
||||
|
||||
const directionText = row.direction
|
||||
const rawAmount = Math.abs(normalizeAmount(row.amount))
|
||||
const direction =
|
||||
directionText.includes('收入') ? 'income' : directionText.includes('支出') ? 'expense' : 'neutral'
|
||||
|
||||
const { fundSourceType, fundSourceName } = resolveFundingSource(row.paymentMethod)
|
||||
const merchant = row.merchant || row.productName || '支付宝账单导入'
|
||||
const note = [row.productName, row.note].filter(Boolean).join(' | ')
|
||||
const amount =
|
||||
direction === 'income' ? rawAmount : direction === 'expense' ? (rawAmount === 0 ? 0 : rawAmount * -1) : rawAmount
|
||||
|
||||
const payload = {
|
||||
merchant,
|
||||
category: resolveCategory(row, direction),
|
||||
note,
|
||||
date: normalizedDate,
|
||||
amount,
|
||||
syncStatus: 'pending',
|
||||
entryType: direction === 'neutral' ? 'transfer' : direction,
|
||||
fundSourceType,
|
||||
fundSourceName,
|
||||
fundTargetType: direction === 'income' ? 'cash' : direction === 'neutral' ? 'stored_value' : 'merchant',
|
||||
fundTargetName:
|
||||
direction === 'income' ? '现金账户' : direction === 'neutral' ? merchant || '储值账户' : merchant,
|
||||
impactExpense: direction === 'expense',
|
||||
impactIncome: direction === 'income',
|
||||
sourceType: IMPORT_SOURCE_ALIPAY,
|
||||
sourceOrderId: row.orderId,
|
||||
sourceMerchantOrderId: row.merchantOrderId,
|
||||
sourceCounterparty: row.merchant,
|
||||
sourceAccount: row.counterpartyAccount,
|
||||
sourcePaymentMethod: row.paymentMethod,
|
||||
importBatchId,
|
||||
}
|
||||
|
||||
payload.sourceRecordHash = buildSourceRecordHash(payload)
|
||||
return payload
|
||||
}
|
||||
|
||||
const isSameAmount = (left, right) => Math.abs(Number(left) - Number(right)) < 0.005
|
||||
|
||||
const isMerchantLikelySame = (left, right) => {
|
||||
const leftKey = normalizeMerchantKey(left)
|
||||
const rightKey = normalizeMerchantKey(right)
|
||||
if (!leftKey || !rightKey) return false
|
||||
return leftKey === rightKey || leftKey.includes(rightKey) || rightKey.includes(leftKey)
|
||||
}
|
||||
|
||||
const findExactDuplicate = (transactions, payload) =>
|
||||
transactions.find((transaction) => {
|
||||
if (payload.sourceOrderId && transaction.sourceType === payload.sourceType) {
|
||||
return transaction.sourceOrderId === payload.sourceOrderId
|
||||
}
|
||||
return payload.sourceRecordHash && transaction.sourceRecordHash === payload.sourceRecordHash
|
||||
})
|
||||
|
||||
const findMergeCandidate = (transactions, payload) => {
|
||||
const payloadTime = new Date(payload.date).getTime()
|
||||
return transactions.find((transaction) => {
|
||||
const diff = Math.abs(new Date(transaction.date).getTime() - payloadTime)
|
||||
return (
|
||||
diff <= 10 * 60 * 1000 &&
|
||||
isSameAmount(transaction.amount, payload.amount) &&
|
||||
isMerchantLikelySame(transaction.merchant, payload.merchant)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const createImportBatch = async ({ source, fileName }) => {
|
||||
const db = await getDb()
|
||||
const id = uuidv4()
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO import_batches (id, source, file_name, imported_at, status)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`,
|
||||
[id, source, fileName || null, new Date().toISOString(), 'running'],
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
const recordImportLine = async (batchId, payload) => {
|
||||
const db = await getDb()
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO import_lines (
|
||||
id,
|
||||
batch_id,
|
||||
source_order_id,
|
||||
merchant,
|
||||
amount,
|
||||
occurred_at,
|
||||
status,
|
||||
transaction_id,
|
||||
reason,
|
||||
raw_data,
|
||||
created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
uuidv4(),
|
||||
batchId,
|
||||
payload.sourceOrderId || null,
|
||||
payload.merchant || null,
|
||||
Number(payload.amount || 0),
|
||||
payload.date || null,
|
||||
payload.status,
|
||||
payload.transactionId || null,
|
||||
payload.reason || null,
|
||||
JSON.stringify(payload.rawData || {}),
|
||||
new Date().toISOString(),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
const finalizeImportBatch = async (batchId, summary) => {
|
||||
const db = await getDb()
|
||||
await db.run(
|
||||
`
|
||||
UPDATE import_batches
|
||||
SET total_count = ?, inserted_count = ?, merged_count = ?, skipped_count = ?, status = ?, summary_json = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[
|
||||
summary.totalCount,
|
||||
summary.insertedCount,
|
||||
summary.mergedCount,
|
||||
summary.skippedCount,
|
||||
'completed',
|
||||
JSON.stringify(summary),
|
||||
batchId,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export const importAlipayStatement = async (file) => {
|
||||
const content = await file.text()
|
||||
const parsedRows = parseAlipayRows(content)
|
||||
const batchId = await createImportBatch({
|
||||
source: IMPORT_SOURCE_ALIPAY,
|
||||
fileName: file?.name || 'alipay-statement',
|
||||
})
|
||||
|
||||
const summary = {
|
||||
batchId,
|
||||
source: IMPORT_SOURCE_ALIPAY,
|
||||
fileName: file?.name || '',
|
||||
totalCount: 0,
|
||||
insertedCount: 0,
|
||||
mergedCount: 0,
|
||||
skippedCount: 0,
|
||||
skippedReasons: [],
|
||||
}
|
||||
|
||||
const existingTransactions = await fetchTransactions()
|
||||
const inBatchKeys = new Set()
|
||||
|
||||
for (const row of parsedRows) {
|
||||
summary.totalCount += 1
|
||||
|
||||
if (row.status && row.status !== IMPORT_SUCCESS_STATUS) {
|
||||
summary.skippedCount += 1
|
||||
summary.skippedReasons.push(`${row.occurredAt} ${row.merchant || row.productName}:状态不是交易成功`)
|
||||
await recordImportLine(batchId, {
|
||||
sourceOrderId: row.orderId,
|
||||
merchant: row.merchant || row.productName,
|
||||
amount: normalizeAmount(row.amount),
|
||||
date: normalizeDate(row.occurredAt),
|
||||
status: 'skipped',
|
||||
reason: '交易状态不是交易成功',
|
||||
rawData: row,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const payload = normalizeImportedRow(row, batchId)
|
||||
if (!payload) {
|
||||
summary.skippedCount += 1
|
||||
summary.skippedReasons.push(`${row.occurredAt}:无法解析交易时间`)
|
||||
await recordImportLine(batchId, {
|
||||
sourceOrderId: row.orderId,
|
||||
merchant: row.merchant || row.productName,
|
||||
amount: normalizeAmount(row.amount),
|
||||
date: '',
|
||||
status: 'skipped',
|
||||
reason: '无法解析交易时间',
|
||||
rawData: row,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const inBatchKey = payload.sourceOrderId || payload.sourceRecordHash
|
||||
if (inBatchKey && inBatchKeys.has(inBatchKey)) {
|
||||
summary.skippedCount += 1
|
||||
summary.skippedReasons.push(`${payload.merchant}:导入文件内重复记录已跳过`)
|
||||
await recordImportLine(batchId, {
|
||||
...payload,
|
||||
status: 'skipped',
|
||||
reason: '导入文件内重复记录',
|
||||
rawData: row,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (inBatchKey) {
|
||||
inBatchKeys.add(inBatchKey)
|
||||
}
|
||||
|
||||
const exactDuplicate = findExactDuplicate(existingTransactions, payload)
|
||||
if (exactDuplicate) {
|
||||
summary.skippedCount += 1
|
||||
await recordImportLine(batchId, {
|
||||
...payload,
|
||||
transactionId: exactDuplicate.id,
|
||||
status: 'skipped',
|
||||
reason: '外部订单号或记录指纹已存在',
|
||||
rawData: row,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const mergeCandidate = findMergeCandidate(existingTransactions, payload)
|
||||
if (mergeCandidate) {
|
||||
const merged = await mergeImportedTransaction(mergeCandidate.id, payload)
|
||||
if (merged) {
|
||||
summary.mergedCount += 1
|
||||
const index = existingTransactions.findIndex((item) => item.id === merged.id)
|
||||
if (index >= 0) existingTransactions[index] = merged
|
||||
await recordImportLine(batchId, {
|
||||
...payload,
|
||||
transactionId: merged.id,
|
||||
status: 'merged',
|
||||
reason: '与现有记录合并',
|
||||
rawData: row,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = await insertTransaction(payload)
|
||||
existingTransactions.unshift(inserted)
|
||||
summary.insertedCount += 1
|
||||
await recordImportLine(batchId, {
|
||||
...payload,
|
||||
transactionId: inserted.id,
|
||||
status: 'inserted',
|
||||
reason: '新记录已导入',
|
||||
rawData: row,
|
||||
})
|
||||
}
|
||||
|
||||
await finalizeImportBatch(batchId, summary)
|
||||
await saveDbToStore()
|
||||
return summary
|
||||
}
|
||||
|
||||
export const exportTransactionsAsCsv = async () => {
|
||||
const transactions = await fetchTransactions()
|
||||
const rows = [
|
||||
[
|
||||
'交易时间',
|
||||
'金额',
|
||||
'方向',
|
||||
'商户',
|
||||
'分类',
|
||||
'备注',
|
||||
'记录类型',
|
||||
'资金来源',
|
||||
'资金去向',
|
||||
'AI 状态',
|
||||
'来源',
|
||||
'外部订单号',
|
||||
],
|
||||
...transactions.map((transaction) => [
|
||||
transaction.date,
|
||||
transaction.amount,
|
||||
transaction.amount > 0 ? '收入' : transaction.amount < 0 ? '支出' : '零额',
|
||||
transaction.merchant,
|
||||
transaction.category,
|
||||
transaction.note,
|
||||
transaction.entryType,
|
||||
transaction.fundSourceName || transaction.fundSourceType,
|
||||
transaction.fundTargetName || transaction.fundTargetType,
|
||||
transaction.aiStatus,
|
||||
transaction.sourceType,
|
||||
transaction.sourceOrderId,
|
||||
]),
|
||||
]
|
||||
|
||||
const content = `\uFEFF${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n')}`
|
||||
downloadText(`echo-ledger-${new Date().toISOString().slice(0, 10)}.csv`, content, 'text/csv;charset=utf-8;')
|
||||
return transactions.length
|
||||
}
|
||||
|
||||
export const exportTransactionsAsJson = async () => {
|
||||
const transactions = await fetchTransactions()
|
||||
const content = JSON.stringify(
|
||||
{
|
||||
exportedAt: new Date().toISOString(),
|
||||
count: transactions.length,
|
||||
transactions,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)
|
||||
downloadText(
|
||||
`echo-ledger-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
content,
|
||||
'application/json;charset=utf-8;',
|
||||
)
|
||||
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 || '',
|
||||
}))
|
||||
}
|
||||
154
src/services/notificationDebugService.js
Normal file
154
src/services/notificationDebugService.js
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,15 @@ const TRANSACTION_SELECT_FIELDS = `
|
||||
ai_reason,
|
||||
ai_status,
|
||||
ai_model,
|
||||
ai_normalized_merchant
|
||||
ai_normalized_merchant,
|
||||
source_type,
|
||||
source_order_id,
|
||||
source_merchant_order_id,
|
||||
source_counterparty,
|
||||
source_account,
|
||||
source_payment_method,
|
||||
source_record_hash,
|
||||
import_batch_id
|
||||
`
|
||||
|
||||
const normalizeStatus = (value) => {
|
||||
@@ -66,6 +74,17 @@ const normalizeFlag = (value, fallback = false) => {
|
||||
|
||||
const inferEntryTypeFromAmount = (amount) => (Number(amount) >= 0 ? 'income' : 'expense')
|
||||
|
||||
const sanitizeSourceMetadata = (payload = {}, existing = null) => ({
|
||||
sourceType: payload.sourceType ?? existing?.sourceType ?? '',
|
||||
sourceOrderId: payload.sourceOrderId ?? existing?.sourceOrderId ?? '',
|
||||
sourceMerchantOrderId: payload.sourceMerchantOrderId ?? existing?.sourceMerchantOrderId ?? '',
|
||||
sourceCounterparty: payload.sourceCounterparty ?? existing?.sourceCounterparty ?? '',
|
||||
sourceAccount: payload.sourceAccount ?? existing?.sourceAccount ?? '',
|
||||
sourcePaymentMethod: payload.sourcePaymentMethod ?? existing?.sourcePaymentMethod ?? '',
|
||||
sourceRecordHash: payload.sourceRecordHash ?? existing?.sourceRecordHash ?? '',
|
||||
importBatchId: payload.importBatchId ?? existing?.importBatchId ?? '',
|
||||
})
|
||||
|
||||
const buildLedgerDefaults = (payload = {}, existing = null) => {
|
||||
const entryType = payload.entryType || existing?.entryType || inferEntryTypeFromAmount(payload.amount ?? existing?.amount ?? 0)
|
||||
const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense
|
||||
@@ -118,6 +137,14 @@ const mapRow = (row) => {
|
||||
aiStatus: normalizeAiStatus(row.ai_status),
|
||||
aiModel: row.ai_model || '',
|
||||
aiNormalizedMerchant: row.ai_normalized_merchant || '',
|
||||
sourceType: row.source_type || '',
|
||||
sourceOrderId: row.source_order_id || '',
|
||||
sourceMerchantOrderId: row.source_merchant_order_id || '',
|
||||
sourceCounterparty: row.source_counterparty || '',
|
||||
sourceAccount: row.source_account || '',
|
||||
sourcePaymentMethod: row.source_payment_method || '',
|
||||
sourceRecordHash: row.source_record_hash || '',
|
||||
importBatchId: row.import_batch_id || '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +276,7 @@ export const insertTransaction = async (payload) => {
|
||||
const id = payload.id || uuidv4()
|
||||
const amount = Number(payload.amount)
|
||||
const ledger = buildLedgerDefaults(payload)
|
||||
const sourceMeta = sanitizeSourceMetadata(payload)
|
||||
const sanitized = {
|
||||
merchant: payload.merchant?.trim() || 'Unknown',
|
||||
category: payload.category?.trim() || 'Uncategorized',
|
||||
@@ -257,6 +285,7 @@ export const insertTransaction = async (payload) => {
|
||||
amount,
|
||||
syncStatus: statusMap[payload.syncStatus] ?? statusMap.pending,
|
||||
...ledger,
|
||||
...sourceMeta,
|
||||
}
|
||||
|
||||
await db.run(
|
||||
@@ -275,9 +304,17 @@ export const insertTransaction = async (payload) => {
|
||||
fund_target_type,
|
||||
fund_target_name,
|
||||
impact_expense,
|
||||
impact_income
|
||||
impact_income,
|
||||
source_type,
|
||||
source_order_id,
|
||||
source_merchant_order_id,
|
||||
source_counterparty,
|
||||
source_account,
|
||||
source_payment_method,
|
||||
source_record_hash,
|
||||
import_batch_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
id,
|
||||
@@ -294,6 +331,14 @@ export const insertTransaction = async (payload) => {
|
||||
sanitized.fundTargetName || null,
|
||||
sanitized.impactExpense ? 1 : 0,
|
||||
sanitized.impactIncome ? 1 : 0,
|
||||
sanitized.sourceType || null,
|
||||
sanitized.sourceOrderId || null,
|
||||
sanitized.sourceMerchantOrderId || null,
|
||||
sanitized.sourceCounterparty || null,
|
||||
sanitized.sourceAccount || null,
|
||||
sanitized.sourcePaymentMethod || null,
|
||||
sanitized.sourceRecordHash || null,
|
||||
sanitized.importBatchId || null,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -310,6 +355,14 @@ export const insertTransaction = async (payload) => {
|
||||
aiStatus: 'idle',
|
||||
aiModel: '',
|
||||
aiNormalizedMerchant: '',
|
||||
sourceType: sanitized.sourceType,
|
||||
sourceOrderId: sanitized.sourceOrderId,
|
||||
sourceMerchantOrderId: sanitized.sourceMerchantOrderId,
|
||||
sourceCounterparty: sanitized.sourceCounterparty,
|
||||
sourceAccount: sanitized.sourceAccount,
|
||||
sourcePaymentMethod: sanitized.sourcePaymentMethod,
|
||||
sourceRecordHash: sanitized.sourceRecordHash,
|
||||
importBatchId: sanitized.importBatchId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,10 +372,11 @@ export const updateTransaction = async (payload) => {
|
||||
|
||||
const db = await getDb()
|
||||
const ledger = buildLedgerDefaults(payload, existing)
|
||||
const sourceMeta = sanitizeSourceMetadata(payload, existing)
|
||||
await db.run(
|
||||
`
|
||||
UPDATE transactions
|
||||
SET amount = ?, merchant = ?, category = ?, date = ?, note = ?, sync_status = ?, entry_type = ?, fund_source_type = ?, fund_source_name = ?, fund_target_type = ?, fund_target_name = ?, impact_expense = ?, impact_income = ?
|
||||
SET amount = ?, merchant = ?, category = ?, date = ?, note = ?, sync_status = ?, entry_type = ?, fund_source_type = ?, fund_source_name = ?, fund_target_type = ?, fund_target_name = ?, impact_expense = ?, impact_income = ?, source_type = ?, source_order_id = ?, source_merchant_order_id = ?, source_counterparty = ?, source_account = ?, source_payment_method = ?, source_record_hash = ?, import_batch_id = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[
|
||||
@@ -339,6 +393,14 @@ export const updateTransaction = async (payload) => {
|
||||
ledger.fundTargetName || null,
|
||||
ledger.impactExpense ? 1 : 0,
|
||||
ledger.impactIncome ? 1 : 0,
|
||||
sourceMeta.sourceType || null,
|
||||
sourceMeta.sourceOrderId || null,
|
||||
sourceMeta.sourceMerchantOrderId || null,
|
||||
sourceMeta.sourceCounterparty || null,
|
||||
sourceMeta.sourceAccount || null,
|
||||
sourceMeta.sourcePaymentMethod || null,
|
||||
sourceMeta.sourceRecordHash || null,
|
||||
sourceMeta.importBatchId || null,
|
||||
payload.id,
|
||||
],
|
||||
)
|
||||
@@ -435,3 +497,51 @@ export const deleteTransaction = async (id) => {
|
||||
await db.run(`DELETE FROM transactions WHERE id = ?`, [id])
|
||||
await saveDbToStore()
|
||||
}
|
||||
|
||||
export const mergeImportedTransaction = async (transactionId, payload) => {
|
||||
const existing = await getTransactionById(transactionId)
|
||||
if (!existing) return null
|
||||
|
||||
const mergedNote = [existing.note, payload.note]
|
||||
.filter(Boolean)
|
||||
.filter((value, index, list) => list.indexOf(value) === index)
|
||||
.join(' | ')
|
||||
.slice(0, 500)
|
||||
|
||||
const nextCategory =
|
||||
existing.category === 'Uncategorized' || existing.category === 'Expense'
|
||||
? payload.category || existing.category
|
||||
: existing.category
|
||||
|
||||
return updateTransaction({
|
||||
...existing,
|
||||
id: transactionId,
|
||||
category: nextCategory,
|
||||
note: mergedNote,
|
||||
fundSourceType:
|
||||
existing.fundSourceType === 'cash' && payload.fundSourceType
|
||||
? payload.fundSourceType
|
||||
: existing.fundSourceType,
|
||||
fundSourceName:
|
||||
existing.fundSourceType === 'cash' && payload.fundSourceName
|
||||
? payload.fundSourceName
|
||||
: existing.fundSourceName,
|
||||
fundTargetType:
|
||||
existing.fundTargetType === 'merchant' && payload.fundTargetType
|
||||
? payload.fundTargetType
|
||||
: existing.fundTargetType,
|
||||
fundTargetName:
|
||||
(!existing.fundTargetName || existing.fundTargetName === existing.merchant) &&
|
||||
payload.fundTargetName
|
||||
? payload.fundTargetName
|
||||
: existing.fundTargetName,
|
||||
sourceType: payload.sourceType || existing.sourceType,
|
||||
sourceOrderId: payload.sourceOrderId || existing.sourceOrderId,
|
||||
sourceMerchantOrderId: payload.sourceMerchantOrderId || existing.sourceMerchantOrderId,
|
||||
sourceCounterparty: payload.sourceCounterparty || existing.sourceCounterparty,
|
||||
sourceAccount: payload.sourceAccount || existing.sourceAccount,
|
||||
sourcePaymentMethod: payload.sourcePaymentMethod || existing.sourcePaymentMethod,
|
||||
sourceRecordHash: payload.sourceRecordHash || existing.sourceRecordHash,
|
||||
importBatchId: payload.importBatchId || existing.importBatchId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 管理全局 UI 状态,例如记一笔弹层的开关和当前编辑项。
|
||||
// 管理全局 UI 状态,例如记一笔弹层开关和当前编辑记录。
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const addEntryVisible = ref(false)
|
||||
const editingTransactionId = ref('')
|
||||
|
||||
@@ -47,4 +47,19 @@
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.ui-chip-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
|
||||
.ui-chip-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
transform: translateY(0.5px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
DEFAULT_TRANSACTION_CATEGORY,
|
||||
TRANSACTION_CATEGORIES,
|
||||
getCategoryLabel,
|
||||
getCategoryMeta,
|
||||
} from '../config/transactionCategories.js'
|
||||
import { useTransactionStore } from '../stores/transactions'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
@@ -19,7 +19,11 @@ const dragStartY = ref(0)
|
||||
const dragging = ref(false)
|
||||
const dragOffset = ref(0)
|
||||
|
||||
const categories = TRANSACTION_CATEGORIES
|
||||
const categories = computed(() =>
|
||||
TRANSACTION_CATEGORIES.filter((category) =>
|
||||
form.type === 'income' ? ['Income', 'Uncategorized'].includes(category.value) : category.value !== 'Income',
|
||||
),
|
||||
)
|
||||
|
||||
const toDatetimeLocal = (value) => {
|
||||
const date = value ? new Date(value) : new Date()
|
||||
@@ -65,6 +69,19 @@ const hydrateForm = () => {
|
||||
|
||||
watch(editingId, hydrateForm, { immediate: true })
|
||||
|
||||
watch(
|
||||
() => form.type,
|
||||
(nextType) => {
|
||||
if (nextType === 'income' && form.category !== 'Income') {
|
||||
form.category = 'Income'
|
||||
return
|
||||
}
|
||||
if (nextType === 'expense' && form.category === 'Income') {
|
||||
form.category = DEFAULT_TRANSACTION_CATEGORY
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const closePanel = () => {
|
||||
uiStore.closeAddEntry()
|
||||
}
|
||||
@@ -181,9 +198,9 @@ const sheetStyle = computed(() => {
|
||||
<button class="text-stone-400 text-sm font-bold" @click="closePanel">关闭</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mb-6 bg-stone-100 rounded-3xl p-1">
|
||||
<div class="grid grid-cols-2 gap-2 mb-6 bg-stone-100 rounded-[26px] p-1.5">
|
||||
<button
|
||||
class="flex-1 py-2 rounded-2xl text-sm font-bold transition"
|
||||
class="inline-flex h-12 items-center justify-center gap-2 rounded-[22px] text-sm font-bold transition"
|
||||
:class="
|
||||
form.type === 'expense'
|
||||
? 'bg-white shadow text-rose-500'
|
||||
@@ -191,10 +208,11 @@ const sheetStyle = computed(() => {
|
||||
"
|
||||
@click="form.type = 'expense'"
|
||||
>
|
||||
<i class="ph-bold ph-arrow-up-right" /> 支出
|
||||
<i class="ph-bold ph-arrow-up-right ui-chip-icon" />
|
||||
<span class="ui-chip-text">支出</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 rounded-2xl text-sm font-bold transition"
|
||||
class="inline-flex h-12 items-center justify-center gap-2 rounded-[22px] text-sm font-bold transition"
|
||||
:class="
|
||||
form.type === 'income'
|
||||
? 'bg-white shadow text-emerald-500'
|
||||
@@ -202,7 +220,8 @@ const sheetStyle = computed(() => {
|
||||
"
|
||||
@click="form.type = 'income'"
|
||||
>
|
||||
<i class="ph-bold ph-arrow-down-left" /> 收入
|
||||
<i class="ph-bold ph-arrow-down-left ui-chip-icon" />
|
||||
<span class="ui-chip-text">收入</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -229,11 +248,11 @@ const sheetStyle = computed(() => {
|
||||
|
||||
<div>
|
||||
<span class="text-xs font-bold text-stone-400">分类</span>
|
||||
<div class="mt-2 flex gap-2 overflow-x-auto hide-scrollbar pb-1">
|
||||
<div class="mt-3 grid grid-cols-3 gap-3 sm:grid-cols-4">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.value"
|
||||
class="px-4 py-2 rounded-2xl text-xs font-bold border transition"
|
||||
class="flex min-h-[78px] flex-col items-center justify-center gap-2 rounded-[24px] border px-3 py-3 text-xs font-bold leading-snug transition text-center"
|
||||
:class="
|
||||
form.category === category.value
|
||||
? 'bg-gradient-warm text-white border-transparent'
|
||||
@@ -241,7 +260,8 @@ const sheetStyle = computed(() => {
|
||||
"
|
||||
@click="form.category = category.value"
|
||||
>
|
||||
{{ getCategoryLabel(category.value) }}
|
||||
<i :class="['ph-fill ui-chip-icon text-lg', getCategoryMeta(category.value).icon]" />
|
||||
<span class="ui-chip-text text-center leading-tight">{{ getCategoryMeta(category.value).label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,38 +113,17 @@ const handleKeydown = (event) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mb-4">
|
||||
<div class="bg-white rounded-2xl border border-stone-100 p-4 shadow-sm">
|
||||
<p class="text-[11px] font-bold text-stone-400">账本样本</p>
|
||||
<p class="text-lg font-extrabold text-stone-800 mt-1">{{ sortedTransactions.length }}</p>
|
||||
<p class="text-[11px] text-stone-400 mt-1">聊天时会带上最近 80 条记录摘要</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-stone-100 p-4 shadow-sm">
|
||||
<p class="text-[11px] font-bold text-stone-400">AI 状态</p>
|
||||
<p class="text-lg font-extrabold mt-1" :class="aiReady ? 'text-emerald-600' : 'text-amber-600'">
|
||||
{{ aiReady ? '已连接' : '待配置' }}
|
||||
</p>
|
||||
<p class="text-[11px] text-stone-400 mt-1">
|
||||
{{ aiReady ? '使用设置页里的 DeepSeek Key 直接调用。' : '先去设置页填写 DeepSeek API Key。' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-wrap mb-4">
|
||||
<button
|
||||
v-for="item in quickPrompts"
|
||||
:key="item"
|
||||
class="px-3 py-2 rounded-full bg-white border border-stone-100 text-xs font-bold text-stone-600 shadow-sm"
|
||||
@click="handleQuickPrompt(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasTransactions" class="mb-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
当前还没有账本记录。聊天功能可以使用,但回答只会提示你先开始记账。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!aiReady"
|
||||
class="mb-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
先去设置页填写 DeepSeek API Key,这里才能真正发送提问。
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto space-y-4 pr-1 pb-2">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
@@ -173,7 +152,19 @@ const handleKeydown = (event) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 relative shrink-0">
|
||||
<div class="mt-4 shrink-0 space-y-3">
|
||||
<div class="flex gap-2 overflow-x-auto hide-scrollbar pb-1">
|
||||
<button
|
||||
v-for="item in quickPrompts"
|
||||
:key="item"
|
||||
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)"
|
||||
>
|
||||
<span class="ui-chip-text">{{ item }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<textarea
|
||||
v-model="prompt"
|
||||
rows="2"
|
||||
@@ -193,6 +184,7 @@ const handleKeydown = (event) => {
|
||||
>
|
||||
<i :class="['ph-bold', sending ? 'ph-circle-notch animate-spin' : 'ph-arrow-up']" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
getEntryTypeLabel,
|
||||
getTransferSummary,
|
||||
isStoredValueAccountType,
|
||||
shouldCountAsExpense,
|
||||
shouldCountAsIncome,
|
||||
} from '../config/ledger.js'
|
||||
import { getCategoryLabel, getCategoryMeta } from '../config/transactionCategories.js'
|
||||
import { bootstrapApp } from '../services/appBootstrap.js'
|
||||
@@ -104,6 +106,28 @@ const periodExpense = computed(() => {
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const currentMonthIncome = computed(() =>
|
||||
(sortedTransactions.value || []).reduce((sum, tx) => {
|
||||
const date = new Date(tx.date)
|
||||
const now = new Date()
|
||||
if (date.getFullYear() !== now.getFullYear() || date.getMonth() !== now.getMonth()) return sum
|
||||
return shouldCountAsIncome(tx) ? sum + tx.amount : sum
|
||||
}, 0),
|
||||
)
|
||||
|
||||
const currentMonthExpense = computed(() =>
|
||||
Math.abs(
|
||||
(sortedTransactions.value || []).reduce((sum, tx) => {
|
||||
const date = new Date(tx.date)
|
||||
const now = new Date()
|
||||
if (date.getFullYear() !== now.getFullYear() || date.getMonth() !== now.getMonth()) return sum
|
||||
return shouldCountAsExpense(tx) ? sum + tx.amount : sum
|
||||
}, 0),
|
||||
),
|
||||
)
|
||||
|
||||
const currentMonthNet = computed(() => currentMonthIncome.value - currentMonthExpense.value)
|
||||
|
||||
const budgetUsage = computed(() => {
|
||||
const expense = Math.abs(periodExpense.value || 0)
|
||||
const budget = monthlyBudget.value || 0
|
||||
@@ -111,7 +135,6 @@ const budgetUsage = computed(() => {
|
||||
return Math.min(expense / budget, 1)
|
||||
})
|
||||
|
||||
const balance = computed(() => (totalIncome.value || 0) + (totalExpense.value || 0))
|
||||
const remainingBudget = computed(() =>
|
||||
Math.max((monthlyBudget.value || 0) - Math.abs(periodExpense.value || 0), 0),
|
||||
)
|
||||
@@ -191,10 +214,6 @@ const goList = () => {
|
||||
router.push({ name: 'list' })
|
||||
}
|
||||
|
||||
const goAnalysis = () => {
|
||||
router.push({ name: 'analysis' })
|
||||
}
|
||||
|
||||
const openTransactionDetail = (tx) => {
|
||||
if (!tx?.id) return
|
||||
uiStore.openAddEntry(tx.id)
|
||||
@@ -223,38 +242,37 @@ const openTransactionDetail = (tx) => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full h-48 rounded-3xl bg-gradient-warm text-white p-6 shadow-xl shadow-orange-200/50 relative overflow-hidden group transition-all hover:shadow-orange-300/50"
|
||||
class="w-full rounded-[32px] bg-gradient-warm text-white p-6 shadow-xl shadow-orange-200/50 relative overflow-hidden"
|
||||
>
|
||||
<div class="absolute -right-10 -top-10 w-40 h-40 bg-white opacity-10 rounded-full blur-2xl" />
|
||||
<div class="absolute left-0 bottom-0 w-full h-1/2 bg-gradient-to-t from-black/10 to-transparent" />
|
||||
|
||||
<div class="relative z-10 flex flex-col h-full justify-between">
|
||||
<div class="relative z-10 space-y-5">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-white/80 text-xs font-bold tracking-widest uppercase">当前结余</p>
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center backdrop-blur-md hover:bg-white/30 transition"
|
||||
>
|
||||
<i class="ph-bold ph-eye" />
|
||||
</button>
|
||||
<div>
|
||||
<p class="text-white/80 text-xs font-bold tracking-widest uppercase">本月净结余</p>
|
||||
<p class="text-[11px] text-white/70 mt-1">仅统计本月已入账的收入和支出</p>
|
||||
</div>
|
||||
<span class="px-3 py-1 rounded-full bg-white/15 text-[11px] font-bold">本月口径</span>
|
||||
</div>
|
||||
<h2 class="text-4xl font-extrabold mt-2">
|
||||
{{ formatCurrency(balance) }}
|
||||
<h2 class="text-4xl font-extrabold mt-3">
|
||||
{{ currentMonthNet >= 0 ? '' : '-' }}{{ formatCurrency(currentMonthNet) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-2xl bg-white/10 px-4 py-3 backdrop-blur-sm">
|
||||
<p class="text-white/70 text-xs mb-0.5 flex items-center gap-1">
|
||||
<i class="ph-bold ph-arrow-down-left" /> 收入
|
||||
</p>
|
||||
<p class="font-bold text-lg">{{ formatCurrency(totalIncome) }}</p>
|
||||
<p class="font-bold text-lg">{{ formatCurrency(currentMonthIncome) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="rounded-2xl bg-white/10 px-4 py-3 backdrop-blur-sm">
|
||||
<p class="text-white/70 text-xs mb-0.5 flex items-center gap-1">
|
||||
<i class="ph-bold ph-arrow-up-right" /> 支出
|
||||
</p>
|
||||
<p class="font-bold text-lg">{{ formatCurrency(Math.abs(totalExpense)) }}</p>
|
||||
<p class="font-bold text-lg">{{ formatCurrency(currentMonthExpense) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,26 +335,28 @@ const openTransactionDetail = (tx) => {
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="recentTransactions.length" class="space-y-4">
|
||||
<div v-if="recentTransactions.length" class="space-y-3.5">
|
||||
<div
|
||||
v-for="tx in recentTransactions"
|
||||
:key="tx.id"
|
||||
class="flex items-center justify-between cursor-pointer active:bg-stone-50 rounded-2xl px-2 -mx-2 py-1 transition"
|
||||
class="flex items-start justify-between gap-4 cursor-pointer active:bg-stone-50 rounded-[22px] px-3 py-3 transition -mx-1"
|
||||
@click="openTransactionDetail(tx)"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex min-w-0 flex-1 items-start gap-3.5">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-2xl flex items-center justify-center',
|
||||
'shrink-0 w-11 h-11 rounded-[18px] flex items-center justify-center',
|
||||
getCategoryMeta(tx.category).bg,
|
||||
getCategoryMeta(tx.category).color,
|
||||
]"
|
||||
>
|
||||
<i :class="['ph-fill text-lg', getCategoryMeta(tx.category).icon]" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<p class="font-bold text-stone-800 text-sm">{{ tx.merchant }}</p>
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="min-w-0 text-[15px] leading-6 font-bold text-stone-800 break-words">
|
||||
{{ tx.merchant }}
|
||||
</p>
|
||||
<span
|
||||
v-if="resolveLedgerBadge(tx)"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-bold"
|
||||
@@ -360,20 +380,20 @@ const openTransactionDetail = (tx) => {
|
||||
{{ resolveAiBadge(tx).label }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-stone-400">
|
||||
<p class="text-[11px] leading-5 text-stone-400 break-words">
|
||||
{{ formatTime(tx.date) }} · {{ getCategoryLabel(tx.category) }}
|
||||
</p>
|
||||
<p v-if="formatLedgerHint(tx)" class="text-[10px] text-stone-400 mt-1">
|
||||
<p v-if="formatLedgerHint(tx)" class="text-[11px] leading-5 text-stone-400">
|
||||
{{ formatLedgerHint(tx) }}
|
||||
</p>
|
||||
<p v-if="formatAiHint(tx)" class="text-[10px] text-stone-400 mt-1">
|
||||
<p v-if="formatAiHint(tx)" class="text-[11px] leading-5 text-stone-400">
|
||||
{{ formatAiHint(tx) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'text-sm font-bold',
|
||||
'shrink-0 pl-2 text-sm font-bold leading-none',
|
||||
tx.amount < 0 ? 'text-stone-800' : 'text-emerald-600',
|
||||
]"
|
||||
>
|
||||
@@ -435,22 +455,5 @@ const openTransactionDetail = (tx) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white p-5 rounded-3xl shadow-sm border border-stone-100 flex flex-col justify-between h-32 relative overflow-hidden group"
|
||||
@click="goAnalysis"
|
||||
>
|
||||
<div
|
||||
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-purple-50 rounded-full blur-xl group-hover:bg-purple-100 transition"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center z-10"
|
||||
>
|
||||
<i class="ph-fill ph-robot text-xl" />
|
||||
</div>
|
||||
<div class="z-10">
|
||||
<h4 class="font-bold text-stone-700">AI 财务顾问</h4>
|
||||
<p class="text-xs text-stone-400 mt-1">现在可直接对话分析账本,并查看自动分类建议和预算提醒。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
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>
|
||||
@@ -102,38 +102,38 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
|
||||
|
||||
<template>
|
||||
<div class="space-y-5 pb-10 animate-fade-in">
|
||||
<div class="sticky top-0 z-20 bg-warmOffwhite/95 backdrop-blur-sm py-2">
|
||||
<div class="sticky top-0 z-20 bg-warmOffwhite/95 backdrop-blur-sm py-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-extrabold text-stone-800">账单明细</h2>
|
||||
<button
|
||||
class="flex items-center gap-2 bg-white border border-stone-100 px-3 py-1.5 rounded-full shadow-sm text-xs font-bold text-stone-500"
|
||||
class="flex items-center gap-2 bg-white border border-stone-100 px-3.5 py-2 rounded-full shadow-sm text-xs font-bold text-stone-500"
|
||||
@click="uiStore.openAddEntry()"
|
||||
>
|
||||
<i class="ph-bold ph-plus" /> 新增
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 hide-scrollbar">
|
||||
<div class="flex gap-3 overflow-x-auto pb-3 hide-scrollbar">
|
||||
<button
|
||||
v-for="chip in categoryChips"
|
||||
:key="chip.value"
|
||||
class="px-3 py-1.5 rounded-full text-xs font-bold flex items-center gap-1 border transition"
|
||||
class="shrink-0 h-11 px-4 rounded-[22px] text-sm font-bold inline-flex items-center justify-center gap-2 border transition whitespace-nowrap"
|
||||
:class="
|
||||
filters.category === chip.value
|
||||
? 'bg-gradient-warm text-white border-transparent shadow-sm'
|
||||
? 'bg-gradient-warm text-white border-transparent shadow-sm shadow-orange-200/40'
|
||||
: 'bg-white text-stone-500 border-stone-100'
|
||||
"
|
||||
@click="setCategory(chip.value)"
|
||||
>
|
||||
<i :class="['text-sm', chip.icon]" />
|
||||
{{ chip.label }}
|
||||
<i :class="['ui-chip-icon text-base', chip.icon]" />
|
||||
<span class="ui-chip-text">{{ chip.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-[11px] text-stone-400 font-bold px-1">
|
||||
<span>点按一条记录可快速编辑</span>
|
||||
<button
|
||||
class="flex items-center gap-1 px-2 py-1 rounded-full border border-stone-200"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full border border-stone-200"
|
||||
:class="filters.showOnlyToday ? 'bg-gradient-warm text-white border-transparent' : 'bg-white'"
|
||||
@click="toggleTodayOnly"
|
||||
>
|
||||
@@ -163,16 +163,16 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
|
||||
<span class="text-xs text-stone-300 font-bold">{{ group.dayKey }}</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-3xl p-1 shadow-sm border border-stone-50 divide-y divide-stone-50">
|
||||
<div class="bg-white rounded-[28px] p-2 shadow-sm border border-stone-50 divide-y divide-stone-50">
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="flex items-center justify-between p-4 hover:bg-stone-50 rounded-2xl transition group"
|
||||
class="flex items-start justify-between gap-4 px-4 py-4 hover:bg-stone-50 rounded-[22px] transition group"
|
||||
@click="handleEdit(item)"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex min-w-0 flex-1 items-start gap-4">
|
||||
<div
|
||||
class="w-11 h-11 rounded-2xl bg-stone-100 flex items-center justify-center text-stone-500 group-active:scale-95 transition"
|
||||
class="shrink-0 w-11 h-11 rounded-[18px] bg-stone-100 flex items-center justify-center text-stone-500 group-active:scale-95 transition"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
@@ -181,62 +181,62 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<p class="font-bold text-stone-800 text-sm">
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="min-w-0 text-[15px] leading-6 font-bold text-stone-800 break-words">
|
||||
{{ item.merchant }}
|
||||
</p>
|
||||
<span
|
||||
v-if="resolveLedgerBadge(item)"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-bold"
|
||||
class="inline-flex h-6 items-center justify-center gap-1 px-2 rounded-full border text-[10px] font-bold"
|
||||
:class="resolveLedgerBadge(item).className"
|
||||
>
|
||||
<i :class="['ph-bold text-[10px]', resolveLedgerBadge(item).icon]" />
|
||||
{{ resolveLedgerBadge(item).label }}
|
||||
<i :class="['ph-bold ui-chip-icon text-[10px]', resolveLedgerBadge(item).icon]" />
|
||||
<span class="ui-chip-text">{{ resolveLedgerBadge(item).label }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="resolveAiBadge(item)"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-bold"
|
||||
class="inline-flex h-6 items-center justify-center gap-1 px-2 rounded-full border text-[10px] font-bold"
|
||||
:class="resolveAiBadge(item).className"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'ph-bold text-[10px]',
|
||||
'ph-bold ui-chip-icon text-[10px]',
|
||||
resolveAiBadge(item).icon,
|
||||
resolveAiBadge(item).iconClassName,
|
||||
]"
|
||||
/>
|
||||
{{ resolveAiBadge(item).label }}
|
||||
<span class="ui-chip-text">{{ resolveAiBadge(item).label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-stone-400 mt-0.5">
|
||||
<p class="text-xs leading-5 text-stone-400">
|
||||
{{ formatTime(item.date) }}
|
||||
<span v-if="item.category" class="text-stone-300">
|
||||
· {{ getCategoryLabel(item.category) }}
|
||||
</span>
|
||||
<span v-if="item.note" class="text-stone-300">
|
||||
<span v-if="item.note" class="text-stone-300 break-words">
|
||||
· {{ item.note }}
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="formatLedgerHint(item)" class="text-[10px] text-stone-400 mt-1">
|
||||
<p v-if="formatLedgerHint(item)" class="text-[11px] leading-5 text-stone-400">
|
||||
{{ formatLedgerHint(item) }}
|
||||
</p>
|
||||
<p v-if="formatAiHint(item)" class="text-[10px] text-stone-400 mt-1">
|
||||
<p v-if="formatAiHint(item)" class="text-[11px] leading-5 text-stone-400">
|
||||
{{ formatAiHint(item) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="shrink-0 pl-2 text-right space-y-1.5">
|
||||
<p
|
||||
:class="[
|
||||
'font-bold text-base',
|
||||
'font-bold text-base leading-none',
|
||||
item.amount < 0 ? 'text-stone-800' : 'text-emerald-600',
|
||||
]"
|
||||
>
|
||||
{{ formatAmount(item.amount) }}
|
||||
</p>
|
||||
<button
|
||||
class="text-[11px] text-stone-400 mt-1"
|
||||
class="text-[11px] text-stone-400"
|
||||
:disabled="deletingId === item.id"
|
||||
@click.stop="handleDelete(item)"
|
||||
>
|
||||
|
||||
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,24 +1,36 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useTransactionStore } from '../stores/transactions'
|
||||
import EchoInput from '../components/EchoInput.vue'
|
||||
import { BUDGET_CATEGORY_OPTIONS } from '../config/transactionCategories.js'
|
||||
import { AI_MODEL_OPTIONS, AI_PROVIDER_OPTIONS } from '../config/transactionTags.js'
|
||||
import {
|
||||
exportTransactionsAsCsv,
|
||||
exportTransactionsAsJson,
|
||||
importAlipayStatement,
|
||||
} from '../services/dataTransferService.js'
|
||||
|
||||
// 从 Vite 注入的版本号(来源于 package.json),用于在设置页展示
|
||||
// eslint-disable-next-line no-undef
|
||||
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'
|
||||
|
||||
const router = useRouter()
|
||||
const settingsStore = useSettingsStore()
|
||||
const transactionStore = useTransactionStore()
|
||||
const fileInputRef = ref(null)
|
||||
const importFileInputRef = ref(null)
|
||||
const editingProfileName = ref(false)
|
||||
const transferBusy = ref(false)
|
||||
const lastImportSummary = ref(null)
|
||||
|
||||
const notificationCaptureEnabled = computed({
|
||||
get: () => settingsStore.notificationCaptureEnabled,
|
||||
set: (value) => settingsStore.setNotificationCaptureEnabled(value),
|
||||
})
|
||||
|
||||
|
||||
const aiAutoCategoryEnabled = computed({
|
||||
get: () => settingsStore.aiAutoCategoryEnabled,
|
||||
set: (value) => settingsStore.setAiAutoCategoryEnabled(value),
|
||||
@@ -120,6 +132,19 @@ const handleAvatarClick = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const openImportPicker = () => {
|
||||
if (transferBusy.value) return
|
||||
importFileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const goImportHistory = () => {
|
||||
router.push({ name: 'imports' })
|
||||
}
|
||||
|
||||
const goNotificationDebug = () => {
|
||||
router.push({ name: 'notifications' })
|
||||
}
|
||||
|
||||
const handleAvatarChange = (event) => {
|
||||
const [file] = event.target.files || []
|
||||
if (!file) return
|
||||
@@ -136,8 +161,53 @@ const handleAvatarChange = (event) => {
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleExportData = () => {
|
||||
window.alert('导出功能即将上线:届时可以一键导出 CSV / Excel。当前版本建议先通过截图或复制方式备份关键信息。')
|
||||
const handleExportCsv = async () => {
|
||||
if (transferBusy.value) return
|
||||
transferBusy.value = true
|
||||
try {
|
||||
await transactionStore.ensureInitialized()
|
||||
const count = await exportTransactionsAsCsv()
|
||||
window.alert(`已导出 ${count} 条账本记录(CSV)。`)
|
||||
} catch (error) {
|
||||
window.alert(error?.message || '导出 CSV 失败,请稍后重试。')
|
||||
} finally {
|
||||
transferBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportJson = async () => {
|
||||
if (transferBusy.value) return
|
||||
transferBusy.value = true
|
||||
try {
|
||||
await transactionStore.ensureInitialized()
|
||||
const count = await exportTransactionsAsJson()
|
||||
window.alert(`已导出 ${count} 条账本记录(JSON 备份)。`)
|
||||
} catch (error) {
|
||||
window.alert(error?.message || '导出 JSON 失败,请稍后重试。')
|
||||
} finally {
|
||||
transferBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportStatement = async (event) => {
|
||||
const [file] = event.target.files || []
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
|
||||
transferBusy.value = true
|
||||
try {
|
||||
await transactionStore.ensureInitialized()
|
||||
const summary = await importAlipayStatement(file)
|
||||
lastImportSummary.value = summary
|
||||
await transactionStore.hydrateTransactions()
|
||||
window.alert(
|
||||
`导入完成:新增 ${summary.insertedCount} 条,合并 ${summary.mergedCount} 条,跳过 ${summary.skippedCount} 条。`,
|
||||
)
|
||||
} catch (error) {
|
||||
window.alert(error?.message || '导入失败,请确认文件格式后重试。')
|
||||
} finally {
|
||||
transferBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearCache = () => {
|
||||
@@ -186,6 +256,13 @@ onMounted(() => {
|
||||
class="hidden"
|
||||
@change="handleAvatarChange"
|
||||
/>
|
||||
<input
|
||||
ref="importFileInputRef"
|
||||
type="file"
|
||||
accept=".csv,.txt,.xls,.html,.htm"
|
||||
class="hidden"
|
||||
@change="handleImportStatement"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
@@ -369,9 +446,9 @@ onMounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="notificationCaptureEnabled" class="px-4 pb-4">
|
||||
<div class="px-4 pb-4">
|
||||
<div
|
||||
v-if="!notificationPermissionGranted"
|
||||
v-if="notificationCaptureEnabled && !notificationPermissionGranted"
|
||||
class="bg-orange-50 border border-orange-200 rounded-2xl px-3 py-2 flex items-start gap-2"
|
||||
>
|
||||
<i class="ph-bold ph-warning text-orange-500 mt-0.5" />
|
||||
@@ -385,10 +462,24 @@ onMounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-[11px] text-emerald-600 flex items-center gap-1">
|
||||
<p
|
||||
v-else-if="notificationCaptureEnabled"
|
||||
class="text-[11px] text-emerald-600 flex items-center gap-1"
|
||||
>
|
||||
<i class="ph-bold ph-check-circle" />
|
||||
通知权限已开启,Echo 会在收到新通知后自动刷新首页。
|
||||
</p>
|
||||
<p v-else class="text-[11px] text-stone-400 leading-5">
|
||||
调试台仍可查看历史命中记录,也能在浏览器环境下注入模拟通知验证规则。
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="mt-3 inline-flex h-9 items-center justify-center gap-2 rounded-full border border-stone-200 bg-white px-4 text-[11px] font-bold text-stone-500"
|
||||
@click="goNotificationDebug"
|
||||
>
|
||||
<i class="ph-bold ph-bug" />
|
||||
<span>查看通知调试台</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -463,8 +554,9 @@ onMounted(() => {
|
||||
</h3>
|
||||
<div class="bg-white rounded-3xl overflow-hidden shadow-sm border border-stone-100">
|
||||
<button
|
||||
class="flex w-full items-center justify-between p-4 border-b border-stone-50 active:bg-stone-50 transition text-left"
|
||||
@click="handleExportData"
|
||||
class="flex w-full items-center justify-between p-4 border-b border-stone-50 active:bg-stone-50 transition text-left disabled:opacity-60"
|
||||
:disabled="transferBusy"
|
||||
@click="handleExportCsv"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
@@ -473,8 +565,86 @@ onMounted(() => {
|
||||
<i class="ph-fill ph-export" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-stone-700 text-sm">导出账单数据(预留)</p>
|
||||
<p class="text-[11px] text-stone-400 mt-0.5">未来支持导出为 CSV / Excel 文件。</p>
|
||||
<p class="font-bold text-stone-700 text-sm">导出账本 CSV</p>
|
||||
<p class="text-[11px] text-stone-400 mt-0.5">导出当前账本的标准 CSV,用于备份或迁移。</p>
|
||||
</div>
|
||||
</div>
|
||||
<i class="ph-bold ph-caret-right text-stone-300" />
|
||||
</button>
|
||||
<button
|
||||
class="flex w-full items-center justify-between p-4 border-b border-stone-50 active:bg-stone-50 transition text-left disabled:opacity-60"
|
||||
:disabled="transferBusy"
|
||||
@click="handleExportJson"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-stone-100 text-stone-500 flex items-center justify-center"
|
||||
>
|
||||
<i class="ph-fill ph-file-code" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-stone-700 text-sm">导出完整 JSON</p>
|
||||
<p class="text-[11px] text-stone-400 mt-0.5">保留 AI、储值账户与导入来源等完整字段。</p>
|
||||
</div>
|
||||
</div>
|
||||
<i class="ph-bold ph-caret-right text-stone-300" />
|
||||
</button>
|
||||
<button
|
||||
class="flex w-full items-center justify-between p-4 border-b border-stone-50 active:bg-stone-50 transition text-left disabled:opacity-60"
|
||||
:disabled="transferBusy"
|
||||
@click="openImportPicker"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-blue-50 text-blue-500 flex items-center justify-center"
|
||||
>
|
||||
<i class="ph-fill ph-download-simple" />
|
||||
</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>
|
||||
<div
|
||||
v-if="lastImportSummary"
|
||||
class="px-4 py-3 border-b border-stone-50 bg-stone-50/70"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<span class="px-3 py-1 rounded-full bg-emerald-50 text-emerald-600 text-[11px] font-bold">
|
||||
新增 {{ lastImportSummary.insertedCount }}
|
||||
</span>
|
||||
<span class="px-3 py-1 rounded-full bg-blue-50 text-blue-600 text-[11px] font-bold">
|
||||
合并 {{ lastImportSummary.mergedCount }}
|
||||
</span>
|
||||
<span class="px-3 py-1 rounded-full bg-stone-100 text-stone-500 text-[11px] font-bold">
|
||||
跳过 {{ lastImportSummary.skippedCount }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[11px] text-stone-500">
|
||||
最近导入:{{ lastImportSummary.fileName || '支付宝账单' }},共处理 {{ lastImportSummary.totalCount }} 条记录。
|
||||
</p>
|
||||
<p
|
||||
v-if="lastImportSummary.skippedReasons?.length"
|
||||
class="text-[11px] text-stone-400 mt-1 leading-5"
|
||||
>
|
||||
{{ lastImportSummary.skippedReasons.slice(0, 2).join(';') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex w-full items-center justify-between p-4 border-b border-stone-50 active:bg-stone-50 transition text-left"
|
||||
@click="goImportHistory"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-purple-50 text-purple-500 flex items-center justify-center"
|
||||
>
|
||||
<i class="ph-fill ph-clock-counter-clockwise" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-stone-700 text-sm">导入历史与异常</p>
|
||||
<p class="text-[11px] text-stone-400 mt-0.5">查看每一批导入的新增、合并、跳过结果和异常原因。</p>
|
||||
</div>
|
||||
</div>
|
||||
<i class="ph-bold ph-caret-right text-stone-300" />
|
||||
@@ -506,3 +676,4 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user