diff --git a/README.md b/README.md
index ffd13c1..2105b8f 100644
--- a/README.md
+++ b/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,正式发布前仍建议切到后端代理模式
diff --git a/scripts/release.cjs b/scripts/release.cjs
index e15d4fa..d55da3f 100644
--- a/scripts/release.cjs
+++ b/scripts/release.cjs
@@ -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()
-
diff --git a/scripts/sync-version.cjs b/scripts/sync-version.cjs
index 4b4d0ce..0c8a343 100644
--- a/scripts/sync-version.cjs
+++ b/scripts/sync-version.cjs
@@ -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()
-
diff --git a/src/lib/sqlite.js b/src/lib/sqlite.js
index 4183589..3824f58 100644
--- a/src/lib/sqlite.js
+++ b/src/lib/sqlite.js
@@ -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,35 @@ const MERCHANT_PROFILE_TABLE_SQL = `
updated_at TEXT NOT NULL
);
`
+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 +91,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 +186,8 @@ const prepareConnection = async () => {
await db.open()
await db.execute(TRANSACTION_TABLE_SQL)
await db.execute(MERCHANT_PROFILE_TABLE_SQL)
+ await db.execute(IMPORT_BATCH_TABLE_SQL)
+ await db.execute(IMPORT_LINE_TABLE_SQL)
await ensureTableColumns('transactions', TRANSACTION_REQUIRED_COLUMNS)
initialized = true
}
diff --git a/src/services/dataTransferService.js b/src/services/dataTransferService.js
new file mode 100644
index 0000000..bccf610
--- /dev/null
+++ b/src/services/dataTransferService.js
@@ -0,0 +1,495 @@
+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 = /
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
+}
diff --git a/src/services/transactionService.js b/src/services/transactionService.js
index d7954c0..1753c7b 100644
--- a/src/services/transactionService.js
+++ b/src/services/transactionService.js
@@ -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,
+ })
+}
diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue
index edf51d2..5a40df5 100644
--- a/src/views/HomeView.vue
+++ b/src/views/HomeView.vue
@@ -317,26 +317,28 @@ const openTransactionDetail = (tx) => {
查看全部
-
+
-
+
-
-
-
{{ tx.merchant }}
+
+
+
+ {{ tx.merchant }}
+
{
{{ resolveAiBadge(tx).label }}
-
+
{{ formatTime(tx.date) }} · {{ getCategoryLabel(tx.category) }}
-
+
{{ formatLedgerHint(tx) }}
-
+
{{ formatAiHint(tx) }}
diff --git a/src/views/ListView.vue b/src/views/ListView.vue
index 6674fc5..a18be92 100644
--- a/src/views/ListView.vue
+++ b/src/views/ListView.vue
@@ -102,30 +102,30 @@ const hasData = computed(() => groupedTransactions.value.length > 0)