feat:新增账单导出、支付宝账单导入
This commit is contained in:
52
README.md
52
README.md
@@ -1,24 +1,52 @@
|
|||||||
# Echo
|
# Echo
|
||||||
|
|
||||||
Local-first personal finance app built with Vue 3, Vite, Capacitor, and SQLite.
|
Echo 是一个本地优先的个人记账 App,当前重点是自动记账、储值账户语义、AI 分类与本地账本分析。
|
||||||
|
|
||||||
## Commands
|
## 当前文档入口
|
||||||
|
|
||||||
|
- 主说明:[design/README.md](design/README.md)
|
||||||
|
- 项目现状与阶段规划:[design/echo-阶段总结与规划.md](design/echo-%E9%98%B6%E6%AE%B5%E6%80%BB%E7%BB%93%E4%B8%8E%E8%A7%84%E5%88%92.md)
|
||||||
|
- 设计/规划整理结论:[design/设计整理-2026-03-12.md](design/%E8%AE%BE%E8%AE%A1%E6%95%B4%E7%90%86-2026-03-12.md)
|
||||||
|
- 版本与协作约定:[design/版本与协作约定.md](design/%E7%89%88%E6%9C%AC%E4%B8%8E%E5%8D%8F%E4%BD%9C%E7%BA%A6%E5%AE%9A.md)
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
```bash
|
```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,正式发布前仍建议切到后端代理模式
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,足够覆盖 0–99 范围
|
|
||||||
return major * 10000 + minor * 100 + patch
|
return major * 10000 + minor * 100 + patch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,4 +48,3 @@ const main = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
495
src/services/dataTransferService.js
Normal file
495
src/services/dataTransferService.js
Normal 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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user