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

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

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

@@ -317,26 +317,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 +362,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',
]"
>

View File

@@ -102,30 +102,30 @@ 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 min-h-[44px] px-4 py-2.5 rounded-[22px] text-sm font-bold flex items-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]" />
<i :class="['text-base', chip.icon]" />
{{ chip.label }}
</button>
</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">
<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,9 +181,9 @@ 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
@@ -209,34 +209,34 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
{{ resolveAiBadge(item).label }}
</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

@@ -2,17 +2,27 @@
import { computed, onMounted, ref } from 'vue'
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 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,
@@ -120,6 +130,11 @@ const handleAvatarClick = () => {
}
}
const openImportPicker = () => {
if (transferBusy.value) return
importFileInputRef.value?.click()
}
const handleAvatarChange = (event) => {
const [file] = event.target.files || []
if (!file) return
@@ -136,8 +151,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 +246,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">
@@ -463,8 +530,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,12 +541,73 @@ 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 active:bg-stone-50 transition text-left"
@click="handleClearCache"