Compare commits

...

4 Commits

Author SHA1 Message Date
c8b30319e3 chore: release v0.5.0 2026-03-13 10:10:42 +08:00
601cbd7576 feat:新增通知调试台和导入历史页 2026-03-13 10:00:21 +08:00
5d45ba4731 fix:页面优化 2026-03-12 15:36:36 +08:00
6c209d8781 feat:新增账单导出、支付宝账单导入 2026-03-12 15:09:25 +08:00
23 changed files with 2080 additions and 214 deletions

View File

@@ -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正式发布前仍建议切到后端代理模式

View File

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

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

@@ -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足够覆盖 099 范围
return major * 10000 + minor * 100 + patch
}
@@ -49,4 +48,3 @@ const main = () => {
}
main()

View File

@@ -8,7 +8,7 @@ const route = useRoute()
const router = useRouter()
const uiStore = useUiStore()
const currentTab = computed(() => route.name)
const currentTab = computed(() => route.meta?.tab || route.name)
const goTab = (name) => {
if (route.name === name) return

View File

@@ -11,6 +11,11 @@ import {
} from '../services/notificationRuleService'
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
import { useSettingsStore } from '../stores/settings'
import {
ensureNotificationRaw,
NOTIFICATION_DEBUG_STATUS,
updateNotificationRawStatus,
} from '../services/notificationDebugService.js'
const notifications = ref([])
const processingId = ref('')
@@ -40,7 +45,6 @@ export const useTransactionEntry = () => {
const transactionStore = useTransactionStore()
const settingsStore = useSettingsStore()
// 原生端:在首次同步前做一次权限校验与请求
const ensureNativePermission = async () => {
if (!nativeBridgeReady || permissionChecked) return
try {
@@ -49,7 +53,7 @@ export const useTransactionEntry = () => {
await NotificationBridge.requestPermission()
}
} catch (error) {
console.warn('[notifications] 原生权限检查失败', error)
console.warn('[notifications] native permission check failed', error)
} finally {
permissionChecked = true
}
@@ -62,15 +66,25 @@ export const useTransactionEntry = () => {
const confirmNotification = async (id) => {
const target = notifications.value.find((item) => item.id === id)
if (!target || processingId.value) return
processingId.value = id
try {
await ensureRulesReady()
await transactionStore.ensureInitialized()
const { transaction } = transformNotificationToTransaction(target, {
const { transaction, rule } = transformNotificationToTransaction(target, {
ruleSet: ruleSet.value,
date: target.createdAt,
})
await transactionStore.addTransaction(transaction)
const created = await transactionStore.addTransaction(transaction)
await updateNotificationRawStatus(id, {
status: NOTIFICATION_DEBUG_STATUS.matched,
ruleId: target.ruleId || rule?.id || '',
transactionId: created?.id || '',
errorMessage: null,
})
removeNotification(id)
await acknowledgeNotification(id)
} finally {
@@ -79,64 +93,87 @@ export const useTransactionEntry = () => {
}
const dismissNotification = async (id) => {
await updateNotificationRawStatus(id, {
status: NOTIFICATION_DEBUG_STATUS.ignored,
errorMessage: '\u7528\u6237\u624b\u52a8\u5ffd\u7565',
})
removeNotification(id)
await acknowledgeNotification(id)
}
const syncNotifications = async () => {
if (syncing.value) return
syncing.value = true
try {
// 若用户关闭了通知自动捕获,则不从原生/模拟队列拉取新通知,只清空待确认列表
if (!settingsStore.notificationCaptureEnabled) {
notifications.value = []
return
}
if (nativeBridgeReady) {
await ensureNativePermission()
}
await ensureRulesReady()
await transactionStore.ensureInitialized()
const queue = await fetchNotificationQueue()
const manualQueue = []
for (const item of queue) {
await ensureNotificationRaw(item, {
status: NOTIFICATION_DEBUG_STATUS.unmatched,
})
const { transaction, rule, requiresConfirmation } = transformNotificationToTransaction(item, {
ruleSet: ruleSet.value,
date: item.createdAt,
})
// 如果没有任何规则命中rule 为空),默认忽略该通知,避免系统通知等噪音进入待确认列表
if (!rule) {
await updateNotificationRawStatus(item.id, {
status: NOTIFICATION_DEBUG_STATUS.unmatched,
errorMessage: '\u672a\u547d\u4e2d\u4efb\u4f55\u901a\u77e5\u89c4\u5219',
})
await acknowledgeNotification(item.id)
continue
}
if (nativeBridgeReady) {
// 原生环境:规则在 Kotlin 原生层已经执行并写入 SQLite这里只负责「待确认」列表和状态同步
if (!requiresConfirmation) {
// 已由原生自动入账,只需从原生通知队列中移除该条
await acknowledgeNotification(item.id)
if (!requiresConfirmation) {
if (!nativeBridgeReady) {
const created = await transactionStore.addTransaction(transaction)
await updateNotificationRawStatus(item.id, {
status: NOTIFICATION_DEBUG_STATUS.matched,
ruleId: rule?.id || '',
transactionId: created?.id || '',
errorMessage: null,
})
} else {
manualQueue.push({
...item,
suggestion: transaction,
ruleId: rule?.id || null,
})
}
} else {
// Web / 模拟环境:沿用原有逻辑,由 JS 侧写入 SQLite
if (!requiresConfirmation) {
await transactionStore.addTransaction(transaction)
await acknowledgeNotification(item.id)
} else {
manualQueue.push({
...item,
suggestion: transaction,
ruleId: rule?.id || null,
await updateNotificationRawStatus(item.id, {
status: NOTIFICATION_DEBUG_STATUS.matched,
ruleId: rule?.id || '',
errorMessage: null,
})
}
await acknowledgeNotification(item.id)
continue
}
await updateNotificationRawStatus(item.id, {
status: NOTIFICATION_DEBUG_STATUS.review,
ruleId: rule?.id || '',
errorMessage: null,
})
manualQueue.push({
...item,
suggestion: transaction,
ruleId: rule?.id || null,
})
}
notifications.value = manualQueue
} finally {
syncing.value = false
@@ -150,9 +187,8 @@ export const useTransactionEntry = () => {
if (!bootstrapped) {
bootstrapped = true
syncNotifications()
void syncNotifications()
// 原生环境:监听 NotificationBridgePlugin 推送的 notificationPosted 事件,做到“通知一到就同步一次”
if (nativeBridgeReady && !nativeNotificationListener && typeof NotificationBridge?.addListener === 'function') {
NotificationBridge.addListener('notificationPosted', async () => {
await syncNotifications()
@@ -161,11 +197,10 @@ export const useTransactionEntry = () => {
nativeNotificationListener = handle
})
.catch((error) => {
console.warn('[notifications] 无法注册 notificationPosted 监听', error)
console.warn('[notifications] failed to register notificationPosted listener', error)
})
}
// 前台轮询兜底:即使偶发漏掉原生事件,也能定期从队列补拉一次
if (!pollTimer && typeof setInterval === 'function') {
pollTimer = setInterval(() => {
if (!syncing.value) {

View File

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

View File

@@ -3,7 +3,8 @@ import HomeView from '../views/HomeView.vue'
import ListView from '../views/ListView.vue'
import SettingsView from '../views/SettingsView.vue'
// TODO: 后续补充真实的分析页实现,这里先占位
const ImportHistoryView = () => import('../views/ImportHistoryView.vue')
const NotificationDebugView = () => import('../views/NotificationDebugView.vue')
const AnalysisView = () => import('../views/AnalysisView.vue').catch(() => null)
const router = createRouter({
@@ -33,8 +34,19 @@ const router = createRouter({
component: SettingsView,
meta: { tab: 'settings' },
},
{
path: '/imports',
name: 'imports',
component: ImportHistoryView,
meta: { tab: 'settings' },
},
{
path: '/notifications',
name: 'notifications',
component: NotificationDebugView,
meta: { tab: 'settings' },
},
],
})
export default router

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 管理全局 UI 状态,例如记一笔弹层开关和当前编辑
// 管理全局 UI 状态,例如记一笔弹层开关和当前编辑记录
export const useUiStore = defineStore('ui', () => {
const addEntryVisible = ref(false)
const editingTransactionId = ref('')

View File

@@ -47,4 +47,19 @@
.slide-up-leave-to {
transform: translateY(100%);
}
.ui-chip-text {
display: inline-flex;
align-items: center;
line-height: 1;
transform: translateY(0.5px);
}
.ui-chip-icon {
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
transform: translateY(0.5px);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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