feat:新增账单导出、支付宝账单导入

This commit is contained in:
2026-03-12 15:09:25 +08:00
parent e70b87a212
commit 6c209d8781
9 changed files with 886 additions and 69 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 ```bash
npm run rules:sync
npm run dev npm run dev
npm run build npm run build
npm run rules:sync
npm run rule:smoke 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 - `package.json.version` 是单一版本源。
- `scripts/`: release and local verification scripts - `npm run version:sync` 会同步 Android `versionCode/versionName`
- `src/config/notificationRules.js`: single source of truth for notification rules - `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

@@ -1,5 +1,4 @@
/* 自定义发布脚本:接管 npm version + Android 版本同步 + git 提交与打 tag */ const { execSync } = require('child_process')
const { execSync } = require('child_process')
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
@@ -10,23 +9,34 @@ const run = (cmd) => {
execSync(cmd, { stdio: 'inherit', cwd: rootDir }) execSync(cmd, { stdio: 'inherit', cwd: rootDir })
} }
const read = (cmd) => execSync(cmd, { cwd: rootDir, encoding: 'utf8' }).trim()
const readJson = (file) => const readJson = (file) =>
JSON.parse(fs.readFileSync(path.join(rootDir, file), 'utf8')) 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 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`) run(`npm version ${type} --no-git-tag-version`)
// 2同步 Android 版本versionCode / versionName
run('npm run version:sync') run('npm run version:sync')
// 3读取新版本号
const pkg = readJson('package.json') const pkg = readJson('package.json')
const version = pkg.version const version = pkg.version
// 4只提交与版本相关的文件避免误提交其它开发中的改动
const filesToAdd = [ const filesToAdd = [
'package.json', 'package.json',
'package-lock.json', 'package-lock.json',
@@ -34,7 +44,6 @@ const main = () => {
] ]
run(`git add ${filesToAdd.join(' ')}`) run(`git add ${filesToAdd.join(' ')}`)
// 5提交并打 tag
run(`git commit -m "chore: release v${version}"`) run(`git commit -m "chore: release v${version}"`)
run(`git tag v${version}`) run(`git tag v${version}`)
@@ -42,4 +51,3 @@ const main = () => {
} }
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 path = require('path')
const rootDir = path.resolve(__dirname, '..') const rootDir = path.resolve(__dirname, '..')
@@ -16,7 +15,7 @@ const calcVersionCode = (version) => {
const [major = 0, minor = 0, patch = 0] = String(version) const [major = 0, minor = 0, patch = 0] = String(version)
.split('.') .split('.')
.map((n) => Number.parseInt(n, 10) || 0) .map((n) => Number.parseInt(n, 10) || 0)
// 简单规则MMmmpp → 1.2.3 => 10203足够覆盖 099 范围
return major * 10000 + minor * 100 + patch return major * 10000 + minor * 100 + patch
} }
@@ -49,4 +48,3 @@ const main = () => {
} }
main() main()

View File

@@ -26,7 +26,15 @@ const TRANSACTION_TABLE_SQL = `
ai_reason TEXT, ai_reason TEXT,
ai_status TEXT DEFAULT 'idle', ai_status TEXT DEFAULT 'idle',
ai_model TEXT, 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 = ` const MERCHANT_PROFILE_TABLE_SQL = `
@@ -39,6 +47,35 @@ const MERCHANT_PROFILE_TABLE_SQL = `
updated_at TEXT NOT NULL 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 = { const TRANSACTION_REQUIRED_COLUMNS = {
entry_type: "TEXT DEFAULT 'expense'", entry_type: "TEXT DEFAULT 'expense'",
fund_source_type: "TEXT DEFAULT 'cash'", fund_source_type: "TEXT DEFAULT 'cash'",
@@ -54,6 +91,14 @@ const TRANSACTION_REQUIRED_COLUMNS = {
ai_status: "TEXT DEFAULT 'idle'", ai_status: "TEXT DEFAULT 'idle'",
ai_model: 'TEXT', 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',
} }
let sqliteConnection let sqliteConnection
@@ -141,6 +186,8 @@ const prepareConnection = async () => {
await db.open() await db.open()
await db.execute(TRANSACTION_TABLE_SQL) await db.execute(TRANSACTION_TABLE_SQL)
await db.execute(MERCHANT_PROFILE_TABLE_SQL) await db.execute(MERCHANT_PROFILE_TABLE_SQL)
await db.execute(IMPORT_BATCH_TABLE_SQL)
await db.execute(IMPORT_LINE_TABLE_SQL)
await ensureTableColumns('transactions', TRANSACTION_REQUIRED_COLUMNS) await ensureTableColumns('transactions', TRANSACTION_REQUIRED_COLUMNS)
initialized = true initialized = true
} }

View File

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

View File

@@ -32,7 +32,15 @@ const TRANSACTION_SELECT_FIELDS = `
ai_reason, ai_reason,
ai_status, ai_status,
ai_model, 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) => { const normalizeStatus = (value) => {
@@ -66,6 +74,17 @@ const normalizeFlag = (value, fallback = false) => {
const inferEntryTypeFromAmount = (amount) => (Number(amount) >= 0 ? 'income' : 'expense') 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 buildLedgerDefaults = (payload = {}, existing = null) => {
const entryType = payload.entryType || existing?.entryType || inferEntryTypeFromAmount(payload.amount ?? existing?.amount ?? 0) 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 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), aiStatus: normalizeAiStatus(row.ai_status),
aiModel: row.ai_model || '', aiModel: row.ai_model || '',
aiNormalizedMerchant: row.ai_normalized_merchant || '', 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 id = payload.id || uuidv4()
const amount = Number(payload.amount) const amount = Number(payload.amount)
const ledger = buildLedgerDefaults(payload) const ledger = buildLedgerDefaults(payload)
const sourceMeta = sanitizeSourceMetadata(payload)
const sanitized = { const sanitized = {
merchant: payload.merchant?.trim() || 'Unknown', merchant: payload.merchant?.trim() || 'Unknown',
category: payload.category?.trim() || 'Uncategorized', category: payload.category?.trim() || 'Uncategorized',
@@ -257,6 +285,7 @@ export const insertTransaction = async (payload) => {
amount, amount,
syncStatus: statusMap[payload.syncStatus] ?? statusMap.pending, syncStatus: statusMap[payload.syncStatus] ?? statusMap.pending,
...ledger, ...ledger,
...sourceMeta,
} }
await db.run( await db.run(
@@ -275,9 +304,17 @@ export const insertTransaction = async (payload) => {
fund_target_type, fund_target_type,
fund_target_name, fund_target_name,
impact_expense, 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, id,
@@ -294,6 +331,14 @@ export const insertTransaction = async (payload) => {
sanitized.fundTargetName || null, sanitized.fundTargetName || null,
sanitized.impactExpense ? 1 : 0, sanitized.impactExpense ? 1 : 0,
sanitized.impactIncome ? 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', aiStatus: 'idle',
aiModel: '', aiModel: '',
aiNormalizedMerchant: '', 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 db = await getDb()
const ledger = buildLedgerDefaults(payload, existing) const ledger = buildLedgerDefaults(payload, existing)
const sourceMeta = sanitizeSourceMetadata(payload, existing)
await db.run( await db.run(
` `
UPDATE transactions 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 = ? WHERE id = ?
`, `,
[ [
@@ -339,6 +393,14 @@ export const updateTransaction = async (payload) => {
ledger.fundTargetName || null, ledger.fundTargetName || null,
ledger.impactExpense ? 1 : 0, ledger.impactExpense ? 1 : 0,
ledger.impactIncome ? 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, payload.id,
], ],
) )
@@ -435,3 +497,51 @@ export const deleteTransaction = async (id) => {
await db.run(`DELETE FROM transactions WHERE id = ?`, [id]) await db.run(`DELETE FROM transactions WHERE id = ?`, [id])
await saveDbToStore() 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

@@ -317,26 +317,28 @@ const openTransactionDetail = (tx) => {
查看全部 查看全部
</button> </button>
</div> </div>
<div v-if="recentTransactions.length" class="space-y-4"> <div v-if="recentTransactions.length" class="space-y-3.5">
<div <div
v-for="tx in recentTransactions" v-for="tx in recentTransactions"
:key="tx.id" :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)" @click="openTransactionDetail(tx)"
> >
<div class="flex items-center gap-3"> <div class="flex min-w-0 flex-1 items-start gap-3.5">
<div <div
:class="[ :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).bg,
getCategoryMeta(tx.category).color, getCategoryMeta(tx.category).color,
]" ]"
> >
<i :class="['ph-fill text-lg', getCategoryMeta(tx.category).icon]" /> <i :class="['ph-fill text-lg', getCategoryMeta(tx.category).icon]" />
</div> </div>
<div> <div class="min-w-0 flex-1 space-y-1.5">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex flex-wrap items-center gap-2">
<p class="font-bold text-stone-800 text-sm">{{ tx.merchant }}</p> <p class="min-w-0 text-[15px] leading-6 font-bold text-stone-800 break-words">
{{ tx.merchant }}
</p>
<span <span
v-if="resolveLedgerBadge(tx)" v-if="resolveLedgerBadge(tx)"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-bold" class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-bold"
@@ -360,20 +362,20 @@ const openTransactionDetail = (tx) => {
{{ resolveAiBadge(tx).label }} {{ resolveAiBadge(tx).label }}
</span> </span>
</div> </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) }} {{ formatTime(tx.date) }} · {{ getCategoryLabel(tx.category) }}
</p> </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) }} {{ formatLedgerHint(tx) }}
</p> </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) }} {{ formatAiHint(tx) }}
</p> </p>
</div> </div>
</div> </div>
<span <span
:class="[ :class="[
'text-sm font-bold', 'shrink-0 pl-2 text-sm font-bold leading-none',
tx.amount < 0 ? 'text-stone-800' : 'text-emerald-600', tx.amount < 0 ? 'text-stone-800' : 'text-emerald-600',
]" ]"
> >

View File

@@ -102,30 +102,30 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
<template> <template>
<div class="space-y-5 pb-10 animate-fade-in"> <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"> <div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-extrabold text-stone-800">账单明细</h2> <h2 class="text-2xl font-extrabold text-stone-800">账单明细</h2>
<button <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()" @click="uiStore.openAddEntry()"
> >
<i class="ph-bold ph-plus" /> 新增 <i class="ph-bold ph-plus" /> 新增
</button> </button>
</div> </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 <button
v-for="chip in categoryChips" v-for="chip in categoryChips"
:key="chip.value" :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 min-h-[44px] px-4 py-2.5 rounded-[22px] text-sm font-bold flex items-center gap-2 border transition whitespace-nowrap"
:class=" :class="
filters.category === chip.value filters.category === chip.value
? 'bg-gradient-warm text-white border-transparent shadow-sm' ? 'bg-gradient-warm text-white border-transparent shadow-sm shadow-orange-200/40'
: 'bg-white text-stone-500 border-stone-100' : 'bg-white text-stone-500 border-stone-100'
" "
@click="setCategory(chip.value)" @click="setCategory(chip.value)"
> >
<i :class="['text-sm', chip.icon]" /> <i :class="['text-base', chip.icon]" />
{{ chip.label }} {{ chip.label }}
</button> </button>
</div> </div>
@@ -133,7 +133,7 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
<div class="flex items-center justify-between text-[11px] text-stone-400 font-bold px-1"> <div class="flex items-center justify-between text-[11px] text-stone-400 font-bold px-1">
<span>点按一条记录可快速编辑</span> <span>点按一条记录可快速编辑</span>
<button <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'" :class="filters.showOnlyToday ? 'bg-gradient-warm text-white border-transparent' : 'bg-white'"
@click="toggleTodayOnly" @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> <span class="text-xs text-stone-300 font-bold">{{ group.dayKey }}</span>
</div> </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 <div
v-for="item in group.items" v-for="item in group.items"
:key="item.id" :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)" @click="handleEdit(item)"
> >
<div class="flex items-center gap-4"> <div class="flex min-w-0 flex-1 items-start gap-4">
<div <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 <i
:class="[ :class="[
@@ -181,9 +181,9 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
]" ]"
/> />
</div> </div>
<div> <div class="min-w-0 flex-1 space-y-1.5">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex flex-wrap items-center gap-2">
<p class="font-bold text-stone-800 text-sm"> <p class="min-w-0 text-[15px] leading-6 font-bold text-stone-800 break-words">
{{ item.merchant }} {{ item.merchant }}
</p> </p>
<span <span
@@ -209,34 +209,34 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
{{ resolveAiBadge(item).label }} {{ resolveAiBadge(item).label }}
</span> </span>
</div> </div>
<p class="text-xs text-stone-400 mt-0.5"> <p class="text-xs leading-5 text-stone-400">
{{ formatTime(item.date) }} {{ formatTime(item.date) }}
<span v-if="item.category" class="text-stone-300"> <span v-if="item.category" class="text-stone-300">
· {{ getCategoryLabel(item.category) }} · {{ getCategoryLabel(item.category) }}
</span> </span>
<span v-if="item.note" class="text-stone-300"> <span v-if="item.note" class="text-stone-300 break-words">
· {{ item.note }} · {{ item.note }}
</span> </span>
</p> </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) }} {{ formatLedgerHint(item) }}
</p> </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) }} {{ formatAiHint(item) }}
</p> </p>
</div> </div>
</div> </div>
<div class="text-right"> <div class="shrink-0 pl-2 text-right space-y-1.5">
<p <p
:class="[ :class="[
'font-bold text-base', 'font-bold text-base leading-none',
item.amount < 0 ? 'text-stone-800' : 'text-emerald-600', item.amount < 0 ? 'text-stone-800' : 'text-emerald-600',
]" ]"
> >
{{ formatAmount(item.amount) }} {{ formatAmount(item.amount) }}
</p> </p>
<button <button
class="text-[11px] text-stone-400 mt-1" class="text-[11px] text-stone-400"
:disabled="deletingId === item.id" :disabled="deletingId === item.id"
@click.stop="handleDelete(item)" @click.stop="handleDelete(item)"
> >

View File

@@ -2,17 +2,27 @@
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge' import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { useTransactionStore } from '../stores/transactions'
import EchoInput from '../components/EchoInput.vue' import EchoInput from '../components/EchoInput.vue'
import { BUDGET_CATEGORY_OPTIONS } from '../config/transactionCategories.js' import { BUDGET_CATEGORY_OPTIONS } from '../config/transactionCategories.js'
import { AI_MODEL_OPTIONS, AI_PROVIDER_OPTIONS } from '../config/transactionTags.js' import { AI_MODEL_OPTIONS, AI_PROVIDER_OPTIONS } from '../config/transactionTags.js'
import {
exportTransactionsAsCsv,
exportTransactionsAsJson,
importAlipayStatement,
} from '../services/dataTransferService.js'
// 从 Vite 注入的版本号(来源于 package.json用于在设置页展示 // 从 Vite 注入的版本号(来源于 package.json用于在设置页展示
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0' const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const transactionStore = useTransactionStore()
const fileInputRef = ref(null) const fileInputRef = ref(null)
const importFileInputRef = ref(null)
const editingProfileName = ref(false) const editingProfileName = ref(false)
const transferBusy = ref(false)
const lastImportSummary = ref(null)
const notificationCaptureEnabled = computed({ const notificationCaptureEnabled = computed({
get: () => settingsStore.notificationCaptureEnabled, get: () => settingsStore.notificationCaptureEnabled,
@@ -120,6 +130,11 @@ const handleAvatarClick = () => {
} }
} }
const openImportPicker = () => {
if (transferBusy.value) return
importFileInputRef.value?.click()
}
const handleAvatarChange = (event) => { const handleAvatarChange = (event) => {
const [file] = event.target.files || [] const [file] = event.target.files || []
if (!file) return if (!file) return
@@ -136,8 +151,53 @@ const handleAvatarChange = (event) => {
reader.readAsDataURL(file) reader.readAsDataURL(file)
} }
const handleExportData = () => { const handleExportCsv = async () => {
window.alert('导出功能即将上线:届时可以一键导出 CSV / Excel。当前版本建议先通过截图或复制方式备份关键信息。') 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 = () => { const handleClearCache = () => {
@@ -186,6 +246,13 @@ onMounted(() => {
class="hidden" class="hidden"
@change="handleAvatarChange" @change="handleAvatarChange"
/> />
<input
ref="importFileInputRef"
type="file"
accept=".csv,.txt,.xls,.html,.htm"
class="hidden"
@change="handleImportStatement"
/>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class="flex items-center gap-2 mb-1"> <div class="flex items-center gap-2 mb-1">
@@ -463,8 +530,9 @@ onMounted(() => {
</h3> </h3>
<div class="bg-white rounded-3xl overflow-hidden shadow-sm border border-stone-100"> <div class="bg-white rounded-3xl overflow-hidden shadow-sm border border-stone-100">
<button <button
class="flex w-full items-center justify-between p-4 border-b border-stone-50 active:bg-stone-50 transition text-left" 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"
@click="handleExportData" :disabled="transferBusy"
@click="handleExportCsv"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
@@ -473,12 +541,73 @@ onMounted(() => {
<i class="ph-fill ph-export" /> <i class="ph-fill ph-export" />
</div> </div>
<div> <div>
<p class="font-bold text-stone-700 text-sm">导出账单数据预留</p> <p class="font-bold text-stone-700 text-sm">导出账 CSV</p>
<p class="text-[11px] text-stone-400 mt-0.5">未来支持导出为 CSV / Excel 文件</p> <p class="text-[11px] text-stone-400 mt-0.5">导出当前账本的标准 CSV用于备份或迁移</p>
</div> </div>
</div> </div>
<i class="ph-bold ph-caret-right text-stone-300" /> <i class="ph-bold ph-caret-right text-stone-300" />
</button> </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 <button
class="flex w-full items-center justify-between p-4 active:bg-stone-50 transition text-left" class="flex w-full items-center justify-between p-4 active:bg-stone-50 transition text-left"
@click="handleClearCache" @click="handleClearCache"