Compare commits
5 Commits
8cbe01dd9c
...
feature
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d45ba4731 | |||
| 6c209d8781 | |||
| e70b87a212 | |||
| 6ca962a187 | |||
| 6a00875246 |
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
|
||||
npm run rules:sync
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run rules:sync
|
||||
npm run rule:smoke
|
||||
npm run version:sync
|
||||
npm run release:patch
|
||||
npm run release:minor
|
||||
npm run release:major
|
||||
```
|
||||
|
||||
## Structure
|
||||
## 版本管理
|
||||
|
||||
- `src/`: web app, stores, services, views, and SQLite bridge
|
||||
- `android/`: Capacitor Android shell and notification listener implementation
|
||||
- `scripts/`: release and local verification scripts
|
||||
- `src/config/notificationRules.js`: single source of truth for notification rules
|
||||
当前版本管理机制保留这套:
|
||||
- `package.json.version` 是单一版本源。
|
||||
- `npm run version:sync` 会同步 Android `versionCode/versionName`。
|
||||
- `npm run release:*` 负责 bump 版本、同步 Android、提交 release commit 并打 tag。
|
||||
|
||||
## Notes
|
||||
建议按下面的方式使用:
|
||||
- 日常开发不要直接在 `main` 上堆改动,开 `feat/*` 或 `fix/*` 分支。
|
||||
- `main` 只保留已验证、准备发版的内容。
|
||||
- release 必须在干净工作区执行。
|
||||
|
||||
- Transaction data is stored locally.
|
||||
- After editing notification rules, run `npm run rules:sync` to regenerate the Android rule file.
|
||||
当前仓库已经加了两条保护:
|
||||
- 只能在 `main` 分支执行 release。
|
||||
- 工作区有未提交改动时禁止 release。
|
||||
|
||||
## 目录说明
|
||||
|
||||
- `src/`:前端页面、状态、服务、SQLite 与 AI/通知逻辑。
|
||||
- `android/`:Capacitor Android 工程和通知监听原生实现。
|
||||
- `scripts/`:规则同步、版本同步、发布脚本。
|
||||
- `design/`:当前说明文档、历史设计稿和整理索引。
|
||||
|
||||
## 说明
|
||||
|
||||
- 通知规则单一来源:`src/config/notificationRules.js`
|
||||
- 修改通知规则后,需要执行一次 `npm run rules:sync`
|
||||
- 当前 AI 第一阶段使用本地 DeepSeek Key,正式发布前仍建议切到后端代理模式
|
||||
|
||||
@@ -4,13 +4,15 @@ apply plugin: 'kotlin-android'
|
||||
android {
|
||||
namespace "com.echo.app"
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.echo.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 301
|
||||
versionName "0.3.1"
|
||||
versionCode 400
|
||||
versionName "0.4.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||
@@ -18,14 +20,14 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// 显式统一 Java / Kotlin 的编译目标版本,保持与 Capacitor 生成配置一致(Java 21)
|
||||
// Keep Java and Kotlin on the same JVM target to avoid Gradle compatibility errors.
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '21'
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -37,7 +39,7 @@ android {
|
||||
}
|
||||
|
||||
repositories {
|
||||
flatDir{
|
||||
flatDir {
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
}
|
||||
@@ -54,4 +56,3 @@ dependencies {
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
package com.echo.app.notification
|
||||
package com.echo.app.notification
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.util.Log
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.math.abs
|
||||
|
||||
// 原生 SQLite 访问封装:与 @capacitor-community/sqlite 共用同一个 echo_local 数据库
|
||||
// Native SQLite access shared with @capacitor-community/sqlite.
|
||||
object NotificationDb {
|
||||
|
||||
private const val TAG = "NotificationDb"
|
||||
|
||||
// 注意:前端通过 @capacitor-community/sqlite 使用的 DB 名为 "echo_local"
|
||||
// 插件在 Android 上实际创建的文件名为 "<dbName>SQLite.db"
|
||||
private const val DB_FILE_NAME = "echo_localSQLite.db"
|
||||
private const val DB_VERSION = 1
|
||||
|
||||
// transactions 表结构需与 src/lib/sqlite.js 保持完全一致
|
||||
private const val SQL_CREATE_TRANSACTIONS = """
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -27,11 +25,24 @@ object NotificationDb {
|
||||
category TEXT DEFAULT 'Uncategorized',
|
||||
date TEXT NOT NULL,
|
||||
note TEXT,
|
||||
sync_status INTEGER DEFAULT 0
|
||||
sync_status INTEGER DEFAULT 0,
|
||||
entry_type TEXT DEFAULT 'expense',
|
||||
fund_source_type TEXT DEFAULT 'cash',
|
||||
fund_source_name TEXT,
|
||||
fund_target_type TEXT DEFAULT 'merchant',
|
||||
fund_target_name TEXT,
|
||||
impact_expense INTEGER DEFAULT 1,
|
||||
impact_income INTEGER DEFAULT 0,
|
||||
ai_category TEXT,
|
||||
ai_tags TEXT,
|
||||
ai_confidence REAL,
|
||||
ai_reason TEXT,
|
||||
ai_status TEXT DEFAULT 'idle',
|
||||
ai_model TEXT,
|
||||
ai_normalized_merchant TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
// notifications_raw 表:用于调试与规则命中记录
|
||||
private const val SQL_CREATE_NOTIFICATIONS_RAW = """
|
||||
CREATE TABLE IF NOT EXISTS notifications_raw (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -47,18 +58,33 @@ object NotificationDb {
|
||||
);
|
||||
"""
|
||||
|
||||
// 简单 SQLiteOpenHelper:主要用于定位与创建数据库文件
|
||||
private val TRANSACTION_REQUIRED_COLUMNS = linkedMapOf(
|
||||
"entry_type" to "TEXT DEFAULT 'expense'",
|
||||
"fund_source_type" to "TEXT DEFAULT 'cash'",
|
||||
"fund_source_name" to "TEXT",
|
||||
"fund_target_type" to "TEXT DEFAULT 'merchant'",
|
||||
"fund_target_name" to "TEXT",
|
||||
"impact_expense" to "INTEGER DEFAULT 1",
|
||||
"impact_income" to "INTEGER DEFAULT 0",
|
||||
"ai_category" to "TEXT",
|
||||
"ai_tags" to "TEXT",
|
||||
"ai_confidence" to "REAL",
|
||||
"ai_reason" to "TEXT",
|
||||
"ai_status" to "TEXT DEFAULT 'idle'",
|
||||
"ai_model" to "TEXT",
|
||||
"ai_normalized_merchant" to "TEXT",
|
||||
)
|
||||
|
||||
private class EchoDbHelper(context: Context) :
|
||||
SQLiteOpenHelper(context, DB_FILE_NAME, null, DB_VERSION) {
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
// 正常情况下,数据表由前端首次启动时创建;此处作为兜底
|
||||
db.execSQL(SQL_CREATE_TRANSACTIONS)
|
||||
db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
// 当前仅有 v1,暂不实现升级逻辑;未来如有版本变更再补充
|
||||
// No-op for now.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,22 +95,37 @@ object NotificationDb {
|
||||
private var schemaInitialized = false
|
||||
|
||||
private fun getHelper(context: Context): EchoDbHelper {
|
||||
// 使用 applicationContext 避免泄漏 Activity
|
||||
val appContext = context.applicationContext
|
||||
return helper ?: synchronized(this) {
|
||||
helper ?: EchoDbHelper(appContext).also { helper = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureTableColumns(db: SQLiteDatabase, tableName: String, requiredColumns: Map<String, String>) {
|
||||
val existingColumns = mutableSetOf<String>()
|
||||
val cursor: Cursor = db.rawQuery("PRAGMA table_info($tableName)", null)
|
||||
cursor.use {
|
||||
val nameIndex = it.getColumnIndex("name")
|
||||
while (it.moveToNext()) {
|
||||
existingColumns += it.getString(nameIndex)
|
||||
}
|
||||
}
|
||||
|
||||
requiredColumns.forEach { (columnName, definition) ->
|
||||
if (!existingColumns.contains(columnName)) {
|
||||
db.execSQL("ALTER TABLE $tableName ADD COLUMN $columnName $definition")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureSchema(db: SQLiteDatabase) {
|
||||
if (schemaInitialized) return
|
||||
// 多线程下可能执行多次,但都是幂等的 IF NOT EXISTS,性能成本极低
|
||||
db.execSQL(SQL_CREATE_TRANSACTIONS)
|
||||
db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW)
|
||||
ensureTableColumns(db, "transactions", TRANSACTION_REQUIRED_COLUMNS)
|
||||
schemaInitialized = true
|
||||
}
|
||||
|
||||
// 用于规则引擎输入的原始通知模型
|
||||
data class RawNotification(
|
||||
val sourceId: String,
|
||||
val packageName: String,
|
||||
@@ -94,16 +135,6 @@ object NotificationDb {
|
||||
val postedAt: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 处理单条通知:
|
||||
* 1. 写入 notifications_raw,初始 status = 'unmatched'
|
||||
* 2. 调用 NotificationRuleEngine 做规则解析
|
||||
* 3. 命中规则时:
|
||||
* - 写入一条 transactions 记录
|
||||
* - 更新 notifications_raw.status = 'matched',补充 rule_id、transaction_id
|
||||
* 4. 解析异常时:
|
||||
* - 更新 notifications_raw.status = 'error',写入 error_message
|
||||
*/
|
||||
fun processAndStoreNotification(context: Context, raw: RawNotification) {
|
||||
lock.withLock {
|
||||
val db = getHelper(context).writableDatabase
|
||||
@@ -117,7 +148,6 @@ object NotificationDb {
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
// 写入 notifications_raw(初始 unmatched)
|
||||
db.compileStatement(
|
||||
"""
|
||||
INSERT INTO notifications_raw (
|
||||
@@ -127,16 +157,8 @@ object NotificationDb {
|
||||
).apply {
|
||||
bindString(1, rawId)
|
||||
bindString(2, raw.sourceId)
|
||||
if (raw.channel != null) {
|
||||
bindString(3, raw.channel)
|
||||
} else {
|
||||
bindNull(3)
|
||||
}
|
||||
if (raw.title != null) {
|
||||
bindString(4, raw.title)
|
||||
} else {
|
||||
bindNull(4)
|
||||
}
|
||||
if (raw.channel != null) bindString(3, raw.channel) else bindNull(3)
|
||||
if (raw.title != null) bindString(4, raw.title) else bindNull(4)
|
||||
bindString(5, raw.text)
|
||||
bindString(6, raw.postedAt)
|
||||
bindString(7, status)
|
||||
@@ -144,34 +166,37 @@ object NotificationDb {
|
||||
close()
|
||||
}
|
||||
|
||||
// 调用规则引擎
|
||||
val parsed = NotificationRuleEngine.parse(raw)
|
||||
|
||||
if (parsed != null) {
|
||||
ruleId = parsed.ruleId
|
||||
|
||||
// 与 JS 侧逻辑保持一致:
|
||||
// requiresConfirmation = !(autoCapture && amount!=0 && merchant!='Unknown')
|
||||
val hasAmount = parsed.amount != 0.0
|
||||
val merchantKnown =
|
||||
parsed.merchant.isNotBlank() && parsed.merchant != "Unknown"
|
||||
val merchantKnown = parsed.merchant.isNotBlank() && parsed.merchant != "Unknown"
|
||||
val requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown)
|
||||
|
||||
if (!requiresConfirmation) {
|
||||
// 统一按照设计文档:支出为负数,收入为正数
|
||||
val signedAmount = if (parsed.isExpense) {
|
||||
-kotlin.math.abs(parsed.amount)
|
||||
} else {
|
||||
kotlin.math.abs(parsed.amount)
|
||||
}
|
||||
val signedAmount = if (parsed.isExpense) -abs(parsed.amount) else abs(parsed.amount)
|
||||
transactionId = UUID.randomUUID().toString()
|
||||
status = "matched"
|
||||
|
||||
db.compileStatement(
|
||||
"""
|
||||
INSERT INTO transactions (
|
||||
id, amount, merchant, category, date, note, sync_status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
id,
|
||||
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
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
).apply {
|
||||
bindString(1, transactionId)
|
||||
@@ -179,23 +204,21 @@ object NotificationDb {
|
||||
bindString(3, parsed.merchant)
|
||||
bindString(4, parsed.category)
|
||||
bindString(5, raw.postedAt)
|
||||
if (parsed.note != null) {
|
||||
bindString(6, parsed.note)
|
||||
} else {
|
||||
bindNull(6)
|
||||
}
|
||||
// sync_status: 0 = pending(本地生成,尚未同步)
|
||||
if (parsed.note != null) bindString(6, parsed.note) else bindNull(6)
|
||||
bindLong(7, 0L)
|
||||
bindString(8, parsed.entryType)
|
||||
bindString(9, parsed.fundSourceType)
|
||||
bindString(10, parsed.fundSourceName)
|
||||
bindString(11, parsed.fundTargetType)
|
||||
bindString(12, parsed.fundTargetName)
|
||||
bindLong(13, if (parsed.impactExpense) 1L else 0L)
|
||||
bindLong(14, if (parsed.impactIncome) 1L else 0L)
|
||||
executeInsert()
|
||||
close()
|
||||
}
|
||||
} else {
|
||||
// 保持 status = 'unmatched',但记录命中的 rule_id,方便前端调试视图展示“有推荐规则”
|
||||
status = "unmatched"
|
||||
}
|
||||
}
|
||||
|
||||
// 根据结果更新 notifications_raw 状态
|
||||
db.compileStatement(
|
||||
"""
|
||||
UPDATE notifications_raw
|
||||
@@ -204,21 +227,9 @@ object NotificationDb {
|
||||
""".trimIndent()
|
||||
).apply {
|
||||
bindString(1, status)
|
||||
if (ruleId != null) {
|
||||
bindString(2, ruleId)
|
||||
} else {
|
||||
bindNull(2)
|
||||
}
|
||||
if (transactionId != null) {
|
||||
bindString(3, transactionId)
|
||||
} else {
|
||||
bindNull(3)
|
||||
}
|
||||
if (errorMessage != null) {
|
||||
bindString(4, errorMessage)
|
||||
} else {
|
||||
bindNull(4)
|
||||
}
|
||||
if (ruleId != null) bindString(2, ruleId) else bindNull(2)
|
||||
if (transactionId != null) bindString(3, transactionId) else bindNull(3)
|
||||
if (errorMessage != null) bindString(4, errorMessage) else bindNull(4)
|
||||
bindString(5, rawId)
|
||||
executeUpdateDelete()
|
||||
close()
|
||||
@@ -226,8 +237,7 @@ object NotificationDb {
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
} catch (e: Exception) {
|
||||
// 若解析阶段抛异常,尽量在同一事务中回写 error 状态
|
||||
Log.e(TAG, "处理通知并写入 SQLite 失败", e)
|
||||
Log.e(TAG, "processAndStoreNotification failed", e)
|
||||
status = "error"
|
||||
errorMessage = e.message ?: e.toString()
|
||||
try {
|
||||
@@ -239,13 +249,13 @@ object NotificationDb {
|
||||
""".trimIndent()
|
||||
).apply {
|
||||
bindString(1, status)
|
||||
bindString(2, errorMessage!!)
|
||||
bindString(2, errorMessage)
|
||||
bindString(3, rawId)
|
||||
executeUpdateDelete()
|
||||
close()
|
||||
}
|
||||
} catch (inner: Exception) {
|
||||
Log.e(TAG, "更新 notifications_raw 错误状态失败", inner)
|
||||
Log.e(TAG, "failed to update notifications_raw error state", inner)
|
||||
}
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
|
||||
@@ -10,6 +10,15 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("支付", "扣款", "支出", "消费", "成功支付"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
entryType = "expense",
|
||||
fundSourceType = "cash",
|
||||
fundSourceName = "支付宝余额",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "merchant",
|
||||
fundTargetName = null,
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = true,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Food",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
|
||||
@@ -22,6 +31,15 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("到账", "收入", "收款", "入账"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
entryType = "income",
|
||||
fundSourceType = "external",
|
||||
fundSourceName = "外部转入",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "cash",
|
||||
fundTargetName = "支付宝余额",
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = false,
|
||||
impactIncome = true,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
|
||||
@@ -34,6 +52,15 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("支付", "支出", "扣款", "消费成功"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
entryType = "expense",
|
||||
fundSourceType = "cash",
|
||||
fundSourceName = "微信余额",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "merchant",
|
||||
fundTargetName = null,
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = true,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Groceries",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
@@ -46,6 +73,15 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("收款", "到账", "入账", "转入"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
entryType = "income",
|
||||
fundSourceType = "external",
|
||||
fundSourceName = "外部转入",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "cash",
|
||||
fundTargetName = "微信余额",
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = false,
|
||||
impactIncome = true,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
@@ -58,6 +94,15 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("工资", "薪资", "代发", "到账", "入账", "收入"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
entryType = "income",
|
||||
fundSourceType = "external",
|
||||
fundSourceName = "外部转入",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "bank",
|
||||
fundTargetName = "银行卡",
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = false,
|
||||
impactIncome = true,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
@@ -70,11 +115,62 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("支出", "消费", "扣款", "刷卡消费", "银联消费"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
entryType = "expense",
|
||||
fundSourceType = "bank",
|
||||
fundSourceName = "银行卡",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "merchant",
|
||||
fundTargetName = null,
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = true,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Expense",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "stored-value-topup",
|
||||
label = "储值账户充值",
|
||||
channels = listOf("杭州通互联互通卡", "杭州通", "北京一卡通", "上海公共交通卡", "深圳通", "苏州公交卡", "交通联合", "乘车码", "地铁", "公交", "Mi Pay", "Huawei Pay", "华为钱包", "小米钱包", "OPPO 钱包"),
|
||||
keywords = listOf("充值", "充值成功", "充值到账", "钱包充值", "卡内充值"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
entryType = "transfer",
|
||||
fundSourceType = "cash",
|
||||
fundSourceName = "现金账户",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "stored_value",
|
||||
fundTargetName = null,
|
||||
fundTargetNameFromChannel = true,
|
||||
impactExpense = false,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Transfer",
|
||||
autoCapture = true,
|
||||
merchantPattern = null,
|
||||
defaultMerchant = "储值账户充值",
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "stored-value-refund",
|
||||
label = "储值账户退款",
|
||||
channels = listOf("杭州通互联互通卡", "杭州通", "北京一卡通", "上海公共交通卡", "深圳通", "苏州公交卡", "交通联合", "乘车码", "地铁", "公交", "Mi Pay", "Huawei Pay", "华为钱包", "小米钱包", "OPPO 钱包"),
|
||||
keywords = listOf("退款", "退资", "退卡", "余额退回", "退款成功"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
entryType = "transfer",
|
||||
fundSourceType = "stored_value",
|
||||
fundSourceName = null,
|
||||
fundSourceNameFromChannel = true,
|
||||
fundTargetType = "cash",
|
||||
fundTargetName = "现金账户",
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = false,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Transfer",
|
||||
autoCapture = true,
|
||||
merchantPattern = null,
|
||||
defaultMerchant = "储值账户退款",
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "transit-card-expense",
|
||||
label = "公交出行",
|
||||
@@ -82,6 +178,15 @@ internal object NotificationRuleData {
|
||||
keywords = emptyList(),
|
||||
requiredTextPatterns = listOf("扣费", "扣款", "支付", "乘车码", "车费", "票价", "本次乘车", "实付", "优惠后"),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
entryType = "expense",
|
||||
fundSourceType = "stored_value",
|
||||
fundSourceName = null,
|
||||
fundSourceNameFromChannel = true,
|
||||
fundTargetType = "merchant",
|
||||
fundTargetName = null,
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = true,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Transport",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})"),
|
||||
|
||||
@@ -14,6 +14,15 @@ internal data class NotificationRuleDefinition(
|
||||
val keywords: List<String> = emptyList(),
|
||||
val requiredTextPatterns: List<String> = emptyList(),
|
||||
val direction: NotificationRuleDirection,
|
||||
val entryType: String = "expense",
|
||||
val fundSourceType: String = "cash",
|
||||
val fundSourceName: String? = null,
|
||||
val fundSourceNameFromChannel: Boolean = false,
|
||||
val fundTargetType: String = "merchant",
|
||||
val fundTargetName: String? = null,
|
||||
val fundTargetNameFromChannel: Boolean = false,
|
||||
val impactExpense: Boolean = true,
|
||||
val impactIncome: Boolean = false,
|
||||
val defaultCategory: String,
|
||||
val autoCapture: Boolean,
|
||||
val merchantPattern: Regex? = null,
|
||||
@@ -30,6 +39,13 @@ object NotificationRuleEngine {
|
||||
val category: String,
|
||||
val amount: Double,
|
||||
val isExpense: Boolean,
|
||||
val entryType: String,
|
||||
val fundSourceType: String,
|
||||
val fundSourceName: String,
|
||||
val fundTargetType: String,
|
||||
val fundTargetName: String,
|
||||
val impactExpense: Boolean,
|
||||
val impactIncome: Boolean,
|
||||
val note: String?,
|
||||
val autoCapture: Boolean,
|
||||
)
|
||||
@@ -60,11 +76,24 @@ object NotificationRuleEngine {
|
||||
category = rule.defaultCategory.ifBlank { "Uncategorized" },
|
||||
amount = amount,
|
||||
isExpense = rule.direction == NotificationRuleDirection.EXPENSE,
|
||||
entryType = rule.entryType,
|
||||
fundSourceType = rule.fundSourceType,
|
||||
fundSourceName = resolveLedgerName(raw.channel, rule.fundSourceName, rule.fundSourceNameFromChannel),
|
||||
fundTargetType = rule.fundTargetType,
|
||||
fundTargetName = resolveLedgerName(raw.channel, rule.fundTargetName, rule.fundTargetNameFromChannel)
|
||||
.ifBlank { if (rule.entryType == "expense") merchant else "" },
|
||||
impactExpense = rule.impactExpense,
|
||||
impactIncome = rule.impactIncome,
|
||||
note = content.take(200),
|
||||
autoCapture = rule.autoCapture,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveLedgerName(channel: String?, configuredName: String?, fromChannel: Boolean): String {
|
||||
if (fromChannel && !channel.isNullOrBlank()) return channel
|
||||
return configuredName.orEmpty()
|
||||
}
|
||||
|
||||
private fun matchRule(
|
||||
raw: NotificationDb.RawNotification,
|
||||
text: String,
|
||||
@@ -80,17 +109,25 @@ object NotificationRuleEngine {
|
||||
val keywordHit =
|
||||
rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) }
|
||||
val requiredTextHit =
|
||||
rule.requiredTextPatterns.isEmpty() || rule.requiredTextPatterns.any { text.contains(it) }
|
||||
rule.requiredTextPatterns.isEmpty() ||
|
||||
rule.requiredTextPatterns.any { text.contains(it) } ||
|
||||
hasTransitRouteAmountFallback(rule, text)
|
||||
|
||||
return channelHit && keywordHit && requiredTextHit
|
||||
}
|
||||
|
||||
private fun hasTransitRouteAmountFallback(rule: NotificationRuleDefinition, text: String): Boolean {
|
||||
if (rule.id != "transit-card-expense") return false
|
||||
return Regex("""[\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,}[::]?\s*\d+(?:\.\d{1,2})?\s*(?:元|块|块钱)""")
|
||||
.containsMatchIn(text)
|
||||
}
|
||||
|
||||
private fun extractAmount(text: String): Double {
|
||||
val normalized = text.replace(",", "").trim()
|
||||
val patterns = listOf(
|
||||
Regex("""(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)""", RegexOption.IGNORE_CASE),
|
||||
Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)"""),
|
||||
Regex("""(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车)\D{0,8}?(-?\d+(?:\.\d{1,2})?)"""),
|
||||
Regex("""(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车|充值)\D{0,8}?(-?\d+(?:\.\d{1,2})?)"""),
|
||||
Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)"""),
|
||||
)
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "echo-app",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "echo-app",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"dependencies": {
|
||||
"@capacitor-community/sqlite": "^5.7.2",
|
||||
"@capacitor/android": "^5.7.8",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "echo-app",
|
||||
"private": true,
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* 自定义发布脚本:接管 npm version + Android 版本同步 + git 提交与打 tag */
|
||||
const { execSync } = require('child_process')
|
||||
const { execSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
@@ -10,23 +9,34 @@ const run = (cmd) => {
|
||||
execSync(cmd, { stdio: 'inherit', cwd: rootDir })
|
||||
}
|
||||
|
||||
const read = (cmd) => execSync(cmd, { cwd: rootDir, encoding: 'utf8' }).trim()
|
||||
|
||||
const readJson = (file) =>
|
||||
JSON.parse(fs.readFileSync(path.join(rootDir, file), 'utf8'))
|
||||
|
||||
const ensureReleaseReady = () => {
|
||||
const branch = read('git branch --show-current')
|
||||
if (branch !== 'main') {
|
||||
throw new Error(`release 只能在 main 分支执行,当前分支:${branch}`)
|
||||
}
|
||||
|
||||
const status = read('git status --short')
|
||||
if (status) {
|
||||
throw new Error('release 前请先提交或清理当前改动,工作区必须保持干净')
|
||||
}
|
||||
}
|
||||
|
||||
const main = () => {
|
||||
const type = process.argv[2] || 'patch' // patch / minor / major
|
||||
const type = process.argv[2] || 'patch'
|
||||
|
||||
ensureReleaseReady()
|
||||
|
||||
// 1)仅更新 version 字段,不让 npm 自动 commit / tag(.npmrc 已全局禁止,但这里再显式加一层保险)
|
||||
run(`npm version ${type} --no-git-tag-version`)
|
||||
|
||||
// 2)同步 Android 版本(versionCode / versionName)
|
||||
run('npm run version:sync')
|
||||
|
||||
// 3)读取新版本号
|
||||
const pkg = readJson('package.json')
|
||||
const version = pkg.version
|
||||
|
||||
// 4)只提交与版本相关的文件,避免误提交其它开发中的改动
|
||||
const filesToAdd = [
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
@@ -34,7 +44,6 @@ const main = () => {
|
||||
]
|
||||
run(`git add ${filesToAdd.join(' ')}`)
|
||||
|
||||
// 5)提交并打 tag
|
||||
run(`git commit -m "chore: release v${version}"`)
|
||||
run(`git tag v${version}`)
|
||||
|
||||
@@ -42,4 +51,3 @@ const main = () => {
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
|
||||
@@ -25,6 +25,56 @@ const cases = [
|
||||
verify(result) {
|
||||
assert.equal(result.rule?.id, 'transit-card-expense')
|
||||
assert.equal(result.transaction.amount, -2)
|
||||
assert.equal(result.transaction.fundSourceType, 'stored_value')
|
||||
assert.equal(result.transaction.impactExpense, true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'capture transit expense from route and fare only',
|
||||
notification: {
|
||||
id: 'n2b',
|
||||
channel: '杭州通互联互通卡',
|
||||
text: '武林广场->古荡:2.10元',
|
||||
createdAt: '2026-03-11T00:00:00.000Z',
|
||||
},
|
||||
verify(result) {
|
||||
assert.equal(result.rule?.id, 'transit-card-expense')
|
||||
assert.equal(result.transaction.amount, -2.1)
|
||||
assert.equal(result.transaction.category, 'Transport')
|
||||
assert.equal(result.transaction.fundSourceName, '杭州通互联互通卡')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'capture stored value topup as transfer',
|
||||
notification: {
|
||||
id: 'n2c',
|
||||
channel: '杭州通互联互通卡',
|
||||
text: '充值成功20元',
|
||||
createdAt: '2026-03-11T00:00:00.000Z',
|
||||
},
|
||||
verify(result) {
|
||||
assert.equal(result.rule?.id, 'stored-value-topup')
|
||||
assert.equal(result.transaction.entryType, 'transfer')
|
||||
assert.equal(result.transaction.category, 'Transfer')
|
||||
assert.equal(result.transaction.amount, -20)
|
||||
assert.equal(result.transaction.impactExpense, false)
|
||||
assert.equal(result.transaction.fundTargetName, '杭州通互联互通卡')
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'capture stored value refund as transfer',
|
||||
notification: {
|
||||
id: 'n2d',
|
||||
channel: '杭州通互联互通卡',
|
||||
text: '退款成功5元',
|
||||
createdAt: '2026-03-11T00:00:00.000Z',
|
||||
},
|
||||
verify(result) {
|
||||
assert.equal(result.rule?.id, 'stored-value-refund')
|
||||
assert.equal(result.transaction.entryType, 'transfer')
|
||||
assert.equal(result.transaction.amount, 5)
|
||||
assert.equal(result.transaction.impactIncome, false)
|
||||
assert.equal(result.transaction.fundSourceName, '杭州通互联互通卡')
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import fs from 'node:fs'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import notificationRules from '../src/config/notificationRules.js'
|
||||
|
||||
@@ -28,6 +28,8 @@ const toKotlinRegex = (spec) => {
|
||||
return `Regex(${toKotlinString(spec.pattern)}${flags})`
|
||||
}
|
||||
|
||||
const toOptionalString = (value) => (value ? toKotlinString(value) : 'null')
|
||||
|
||||
const toRuleBlock = (rule) => ` NotificationRuleDefinition(
|
||||
id = ${toKotlinString(rule.id)},
|
||||
label = ${toKotlinString(rule.label)},
|
||||
@@ -35,12 +37,19 @@ const toRuleBlock = (rule) => ` NotificationRuleDefinition(
|
||||
keywords = ${toKotlinStringList(rule.keywords)},
|
||||
requiredTextPatterns = ${toKotlinStringList(rule.requiredTextPatterns)},
|
||||
direction = NotificationRuleDirection.${rule.direction === 'income' ? 'INCOME' : 'EXPENSE'},
|
||||
entryType = ${toKotlinString(rule.entryType || 'expense')},
|
||||
fundSourceType = ${toKotlinString(rule.fundSourceType || 'cash')},
|
||||
fundSourceName = ${toOptionalString(rule.fundSourceName)},
|
||||
fundSourceNameFromChannel = ${rule.fundSourceNameFromChannel ? 'true' : 'false'},
|
||||
fundTargetType = ${toKotlinString(rule.fundTargetType || 'merchant')},
|
||||
fundTargetName = ${toOptionalString(rule.fundTargetName)},
|
||||
fundTargetNameFromChannel = ${rule.fundTargetNameFromChannel ? 'true' : 'false'},
|
||||
impactExpense = ${rule.impactExpense === false ? 'false' : 'true'},
|
||||
impactIncome = ${rule.impactIncome ? 'true' : 'false'},
|
||||
defaultCategory = ${toKotlinString(rule.defaultCategory)},
|
||||
autoCapture = ${rule.autoCapture ? 'true' : 'false'},
|
||||
merchantPattern = ${toKotlinRegex(rule.merchantPattern)},
|
||||
defaultMerchant = ${
|
||||
rule.defaultMerchant ? toKotlinString(rule.defaultMerchant) : 'null'
|
||||
},
|
||||
defaultMerchant = ${toOptionalString(rule.defaultMerchant)},
|
||||
)`
|
||||
|
||||
const content = `package com.echo.app.notification
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* 同步前端 package.json 的版本号到 Android 工程(versionCode / versionName) */
|
||||
const fs = require('fs')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
@@ -16,7 +15,7 @@ const calcVersionCode = (version) => {
|
||||
const [major = 0, minor = 0, patch = 0] = String(version)
|
||||
.split('.')
|
||||
.map((n) => Number.parseInt(n, 10) || 0)
|
||||
// 简单规则:MMmmpp → 1.2.3 => 10203,足够覆盖 0–99 范围
|
||||
|
||||
return major * 10000 + minor * 100 + patch
|
||||
}
|
||||
|
||||
@@ -49,4 +48,3 @@ const main = () => {
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
|
||||
28
src/config/aiStatus.js
Normal file
28
src/config/aiStatus.js
Normal file
@@ -0,0 +1,28 @@
|
||||
export const AI_STATUS_META = {
|
||||
running: {
|
||||
label: 'AI 分类中',
|
||||
icon: 'ph-circle-notch',
|
||||
className: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
iconClassName: 'animate-spin',
|
||||
},
|
||||
applied: {
|
||||
label: 'AI 已补全',
|
||||
icon: 'ph-sparkle',
|
||||
className: 'bg-violet-50 text-violet-700 border-violet-200',
|
||||
iconClassName: '',
|
||||
},
|
||||
suggested: {
|
||||
label: 'AI 建议',
|
||||
icon: 'ph-lightbulb',
|
||||
className: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||
iconClassName: '',
|
||||
},
|
||||
failed: {
|
||||
label: 'AI 失败',
|
||||
icon: 'ph-warning',
|
||||
className: 'bg-stone-100 text-stone-500 border-stone-200',
|
||||
iconClassName: '',
|
||||
},
|
||||
}
|
||||
|
||||
export const getAiStatusMeta = (status) => AI_STATUS_META[status] || null
|
||||
76
src/config/ledger.js
Normal file
76
src/config/ledger.js
Normal file
@@ -0,0 +1,76 @@
|
||||
export const TRANSACTION_ENTRY_TYPES = [
|
||||
{ value: 'expense', label: '支出' },
|
||||
{ value: 'income', label: '收入' },
|
||||
{ value: 'transfer', label: '转账' },
|
||||
]
|
||||
|
||||
export const ACCOUNT_TYPE_META = {
|
||||
cash: { label: '现金账户' },
|
||||
bank: { label: '银行卡' },
|
||||
credit: { label: '信用账户' },
|
||||
stored_value: { label: '储值账户' },
|
||||
merchant: { label: '商户' },
|
||||
income_source: { label: '收入来源' },
|
||||
external: { label: '外部账户' },
|
||||
unknown: { label: '未分类账户' },
|
||||
}
|
||||
|
||||
export const STORED_VALUE_WALLET_KINDS = [
|
||||
{ value: 'transit', label: '交通卡 / 乘车码' },
|
||||
{ value: 'campus', label: '校园卡' },
|
||||
{ value: 'meal', label: '饭卡' },
|
||||
{ value: 'parking', label: '停车卡' },
|
||||
{ value: 'fuel', label: '加油卡' },
|
||||
{ value: 'toll', label: '通行卡' },
|
||||
{ value: 'gift', label: '礼品卡 / 会员储值' },
|
||||
{ value: 'other', label: '其他储值' },
|
||||
]
|
||||
|
||||
export const DEFAULT_LEDGER_BY_ENTRY_TYPE = {
|
||||
expense: {
|
||||
entryType: 'expense',
|
||||
fundSourceType: 'cash',
|
||||
fundSourceName: '现金账户',
|
||||
fundTargetType: 'merchant',
|
||||
fundTargetName: '',
|
||||
impactExpense: true,
|
||||
impactIncome: false,
|
||||
},
|
||||
income: {
|
||||
entryType: 'income',
|
||||
fundSourceType: 'income_source',
|
||||
fundSourceName: '收入来源',
|
||||
fundTargetType: 'cash',
|
||||
fundTargetName: '现金账户',
|
||||
impactExpense: false,
|
||||
impactIncome: true,
|
||||
},
|
||||
transfer: {
|
||||
entryType: 'transfer',
|
||||
fundSourceType: 'cash',
|
||||
fundSourceName: '现金账户',
|
||||
fundTargetType: 'stored_value',
|
||||
fundTargetName: '储值账户',
|
||||
impactExpense: false,
|
||||
impactIncome: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const getEntryTypeLabel = (value) =>
|
||||
TRANSACTION_ENTRY_TYPES.find((item) => item.value === value)?.label || '记录'
|
||||
|
||||
export const getAccountTypeLabel = (value) => ACCOUNT_TYPE_META[value]?.label || ACCOUNT_TYPE_META.unknown.label
|
||||
|
||||
export const isStoredValueAccountType = (value) => value === 'stored_value'
|
||||
|
||||
export const shouldCountAsExpense = (transaction) =>
|
||||
transaction?.impactExpense !== false && transaction?.entryType !== 'transfer' && Number(transaction?.amount) < 0
|
||||
|
||||
export const shouldCountAsIncome = (transaction) =>
|
||||
transaction?.impactIncome !== false && transaction?.entryType !== 'transfer' && Number(transaction?.amount) > 0
|
||||
|
||||
export const getTransferSummary = (transaction) => {
|
||||
const source = transaction?.fundSourceName || getAccountTypeLabel(transaction?.fundSourceType)
|
||||
const target = transaction?.fundTargetName || getAccountTypeLabel(transaction?.fundTargetType)
|
||||
return `${source} → ${target}`
|
||||
}
|
||||
@@ -1,4 +1,22 @@
|
||||
const notificationRules = [
|
||||
const transitStoredValueChannels = [
|
||||
'杭州通互联互通卡',
|
||||
'杭州通',
|
||||
'北京一卡通',
|
||||
'上海公共交通卡',
|
||||
'深圳通',
|
||||
'苏州公交卡',
|
||||
'交通联合',
|
||||
'乘车码',
|
||||
'地铁',
|
||||
'公交',
|
||||
'Mi Pay',
|
||||
'Huawei Pay',
|
||||
'华为钱包',
|
||||
'小米钱包',
|
||||
'OPPO 钱包',
|
||||
]
|
||||
|
||||
const notificationRules = [
|
||||
{
|
||||
id: 'alipay-expense',
|
||||
label: '支付宝消费',
|
||||
@@ -6,6 +24,13 @@
|
||||
keywords: ['支付', '扣款', '支出', '消费', '成功支付'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'expense',
|
||||
entryType: 'expense',
|
||||
fundSourceType: 'cash',
|
||||
fundSourceName: '支付宝余额',
|
||||
fundTargetType: 'merchant',
|
||||
fundTargetName: '',
|
||||
impactExpense: true,
|
||||
impactIncome: false,
|
||||
defaultCategory: 'Food',
|
||||
autoCapture: false,
|
||||
merchantPattern: {
|
||||
@@ -21,6 +46,13 @@
|
||||
keywords: ['到账', '收入', '收款', '入账'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'income',
|
||||
entryType: 'income',
|
||||
fundSourceType: 'external',
|
||||
fundSourceName: '外部转入',
|
||||
fundTargetType: 'cash',
|
||||
fundTargetName: '支付宝余额',
|
||||
impactExpense: false,
|
||||
impactIncome: true,
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: {
|
||||
@@ -36,6 +68,13 @@
|
||||
keywords: ['支付', '支出', '扣款', '消费成功'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'expense',
|
||||
entryType: 'expense',
|
||||
fundSourceType: 'cash',
|
||||
fundSourceName: '微信余额',
|
||||
fundTargetType: 'merchant',
|
||||
fundTargetName: '',
|
||||
impactExpense: true,
|
||||
impactIncome: false,
|
||||
defaultCategory: 'Groceries',
|
||||
autoCapture: false,
|
||||
merchantPattern: {
|
||||
@@ -51,6 +90,13 @@
|
||||
keywords: ['收款', '到账', '入账', '转入'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'income',
|
||||
entryType: 'income',
|
||||
fundSourceType: 'external',
|
||||
fundSourceName: '外部转入',
|
||||
fundTargetType: 'cash',
|
||||
fundTargetName: '微信余额',
|
||||
impactExpense: false,
|
||||
impactIncome: true,
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: {
|
||||
@@ -79,6 +125,13 @@
|
||||
keywords: ['工资', '薪资', '代发', '到账', '入账', '收入'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'income',
|
||||
entryType: 'income',
|
||||
fundSourceType: 'external',
|
||||
fundSourceName: '外部转入',
|
||||
fundTargetType: 'bank',
|
||||
fundTargetName: '银行卡',
|
||||
impactExpense: false,
|
||||
impactIncome: true,
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: {
|
||||
@@ -109,6 +162,13 @@
|
||||
keywords: ['支出', '消费', '扣款', '刷卡消费', '银联消费'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'expense',
|
||||
entryType: 'expense',
|
||||
fundSourceType: 'bank',
|
||||
fundSourceName: '银行卡',
|
||||
fundTargetType: 'merchant',
|
||||
fundTargetName: '',
|
||||
impactExpense: true,
|
||||
impactIncome: false,
|
||||
defaultCategory: 'Expense',
|
||||
autoCapture: false,
|
||||
merchantPattern: {
|
||||
@@ -117,29 +177,61 @@
|
||||
},
|
||||
defaultMerchant: '',
|
||||
},
|
||||
{
|
||||
id: 'stored-value-topup',
|
||||
label: '储值账户充值',
|
||||
channels: transitStoredValueChannels,
|
||||
keywords: ['充值', '充值成功', '充值到账', '钱包充值', '卡内充值'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'expense',
|
||||
entryType: 'transfer',
|
||||
fundSourceType: 'cash',
|
||||
fundSourceName: '现金账户',
|
||||
fundTargetType: 'stored_value',
|
||||
fundTargetName: '',
|
||||
fundTargetNameFromChannel: true,
|
||||
impactExpense: false,
|
||||
impactIncome: false,
|
||||
defaultCategory: 'Transfer',
|
||||
autoCapture: true,
|
||||
merchantPattern: null,
|
||||
defaultMerchant: '储值账户充值',
|
||||
},
|
||||
{
|
||||
id: 'stored-value-refund',
|
||||
label: '储值账户退款',
|
||||
channels: transitStoredValueChannels,
|
||||
keywords: ['退款', '退资', '退卡', '余额退回', '退款成功'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'income',
|
||||
entryType: 'transfer',
|
||||
fundSourceType: 'stored_value',
|
||||
fundSourceName: '',
|
||||
fundSourceNameFromChannel: true,
|
||||
fundTargetType: 'cash',
|
||||
fundTargetName: '现金账户',
|
||||
impactExpense: false,
|
||||
impactIncome: false,
|
||||
defaultCategory: 'Transfer',
|
||||
autoCapture: true,
|
||||
merchantPattern: null,
|
||||
defaultMerchant: '储值账户退款',
|
||||
},
|
||||
{
|
||||
id: 'transit-card-expense',
|
||||
label: '公交出行',
|
||||
channels: [
|
||||
'杭州通互联互通卡',
|
||||
'杭州通',
|
||||
'北京一卡通',
|
||||
'上海公共交通卡',
|
||||
'深圳通',
|
||||
'苏州公交卡',
|
||||
'交通联合',
|
||||
'乘车码',
|
||||
'地铁',
|
||||
'公交',
|
||||
'Mi Pay',
|
||||
'Huawei Pay',
|
||||
'华为钱包',
|
||||
'小米钱包',
|
||||
'OPPO 钱包',
|
||||
],
|
||||
channels: transitStoredValueChannels,
|
||||
keywords: [],
|
||||
requiredTextPatterns: ['扣费', '扣款', '支付', '乘车码', '车费', '票价', '本次乘车', '实付', '优惠后'],
|
||||
direction: 'expense',
|
||||
entryType: 'expense',
|
||||
fundSourceType: 'stored_value',
|
||||
fundSourceName: '',
|
||||
fundSourceNameFromChannel: true,
|
||||
fundTargetType: 'merchant',
|
||||
fundTargetName: '',
|
||||
impactExpense: true,
|
||||
impactIncome: false,
|
||||
defaultCategory: 'Transport',
|
||||
autoCapture: true,
|
||||
merchantPattern: {
|
||||
|
||||
@@ -47,6 +47,14 @@
|
||||
color: 'text-stone-600',
|
||||
budgetable: true,
|
||||
},
|
||||
{
|
||||
value: 'Transfer',
|
||||
label: '转账',
|
||||
icon: 'ph-arrows-left-right',
|
||||
bg: 'bg-cyan-100',
|
||||
color: 'text-cyan-700',
|
||||
budgetable: false,
|
||||
},
|
||||
{
|
||||
value: 'Income',
|
||||
label: '收入',
|
||||
@@ -84,7 +92,6 @@ export const DEFAULT_CATEGORY_BUDGETS = BUDGET_CATEGORY_OPTIONS.reduce((acc, cat
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
export const getCategoryMeta = (category) =>
|
||||
CATEGORY_META[category] || CATEGORY_META.Uncategorized
|
||||
export const getCategoryMeta = (category) => CATEGORY_META[category] || CATEGORY_META.Uncategorized
|
||||
|
||||
export const getCategoryLabel = (category) => getCategoryMeta(category).label
|
||||
|
||||
21
src/config/transactionTags.js
Normal file
21
src/config/transactionTags.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export const AI_TRANSACTION_TAGS = [
|
||||
'餐饮',
|
||||
'通勤',
|
||||
'买菜',
|
||||
'娱乐',
|
||||
'订阅',
|
||||
'家庭',
|
||||
'工作',
|
||||
'报销',
|
||||
'夜间消费',
|
||||
'固定支出',
|
||||
'冲动消费',
|
||||
]
|
||||
|
||||
export const AI_PROVIDER_OPTIONS = [
|
||||
{ label: 'DeepSeek', value: 'deepseek' },
|
||||
]
|
||||
|
||||
export const AI_MODEL_OPTIONS = [
|
||||
{ label: 'deepseek-chat', value: 'deepseek-chat' },
|
||||
]
|
||||
172
src/lib/aiPrompt.js
Normal file
172
src/lib/aiPrompt.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
getCategoryLabel,
|
||||
TRANSACTION_CATEGORIES,
|
||||
} from '../config/transactionCategories.js'
|
||||
import {
|
||||
getTransferSummary,
|
||||
shouldCountAsExpense,
|
||||
shouldCountAsIncome,
|
||||
} from '../config/ledger.js'
|
||||
import { AI_TRANSACTION_TAGS } from '../config/transactionTags.js'
|
||||
|
||||
const categoryValues = TRANSACTION_CATEGORIES.map((item) => item.value)
|
||||
|
||||
const formatAmount = (value) => Number(value || 0).toFixed(2)
|
||||
|
||||
const buildExpenseCategoryBreakdown = (transactions) => {
|
||||
const categoryMap = new Map()
|
||||
|
||||
transactions.forEach((transaction) => {
|
||||
if (!shouldCountAsExpense(transaction)) return
|
||||
const category = transaction.category || 'Uncategorized'
|
||||
const nextTotal = (categoryMap.get(category) || 0) + Math.abs(Number(transaction.amount) || 0)
|
||||
categoryMap.set(category, nextTotal)
|
||||
})
|
||||
|
||||
return Array.from(categoryMap.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([category, total]) => ({
|
||||
category,
|
||||
label: getCategoryLabel(category),
|
||||
total: Number(total.toFixed(2)),
|
||||
}))
|
||||
}
|
||||
|
||||
const buildTransferSample = (transactions) =>
|
||||
transactions
|
||||
.filter((transaction) => transaction.entryType === 'transfer')
|
||||
.slice(0, 10)
|
||||
.map((transaction) => ({
|
||||
date: transaction.date,
|
||||
amount: Number(transaction.amount || 0),
|
||||
summary: getTransferSummary(transaction),
|
||||
merchant: transaction.merchant || '',
|
||||
}))
|
||||
|
||||
const buildRecentTransactionSample = (transactions) =>
|
||||
transactions.slice(0, 20).map((transaction) => ({
|
||||
date: transaction.date,
|
||||
merchant: transaction.merchant || 'Unknown',
|
||||
amount: Number(transaction.amount || 0),
|
||||
category: transaction.category || 'Uncategorized',
|
||||
categoryLabel: getCategoryLabel(transaction.category || 'Uncategorized'),
|
||||
note: transaction.note || '',
|
||||
entryType: transaction.entryType || (transaction.amount >= 0 ? 'income' : 'expense'),
|
||||
transferSummary: transaction.entryType === 'transfer' ? getTransferSummary(transaction) : '',
|
||||
aiCategory: transaction.aiCategory || '',
|
||||
aiTags: transaction.aiTags || [],
|
||||
}))
|
||||
|
||||
const buildLedgerSummary = (transactions = []) => {
|
||||
const normalizedTransactions = [...transactions]
|
||||
.sort((a, b) => new Date(b.date) - new Date(a.date))
|
||||
.slice(0, 80)
|
||||
|
||||
const totalIncome = normalizedTransactions.reduce(
|
||||
(sum, item) => (shouldCountAsIncome(item) ? sum + Number(item.amount || 0) : sum),
|
||||
0,
|
||||
)
|
||||
const totalExpense = normalizedTransactions.reduce(
|
||||
(sum, item) => (shouldCountAsExpense(item) ? sum + Math.abs(Number(item.amount || 0)) : sum),
|
||||
0,
|
||||
)
|
||||
|
||||
return {
|
||||
currentDate: new Date().toISOString(),
|
||||
transactionCount: normalizedTransactions.length,
|
||||
expenseTransactionCount: normalizedTransactions.filter((item) => shouldCountAsExpense(item)).length,
|
||||
incomeTransactionCount: normalizedTransactions.filter((item) => shouldCountAsIncome(item)).length,
|
||||
transferTransactionCount: normalizedTransactions.filter((item) => item.entryType === 'transfer').length,
|
||||
totalIncome: Number(formatAmount(totalIncome)),
|
||||
totalExpense: Number(formatAmount(totalExpense)),
|
||||
netBalance: Number(formatAmount(totalIncome - totalExpense)),
|
||||
topExpenseCategories: buildExpenseCategoryBreakdown(normalizedTransactions),
|
||||
recentTransfers: buildTransferSample(normalizedTransactions),
|
||||
recentTransactions: buildRecentTransactionSample(normalizedTransactions),
|
||||
}
|
||||
}
|
||||
|
||||
export const buildTransactionEnrichmentMessages = ({ transaction, similarTransactions = [] }) => {
|
||||
const payload = {
|
||||
transaction: {
|
||||
id: transaction.id,
|
||||
merchant: transaction.merchant || 'Unknown',
|
||||
amount: transaction.amount,
|
||||
direction: transaction.amount >= 0 ? 'income' : 'expense',
|
||||
note: transaction.note || '',
|
||||
currentCategory: transaction.category || 'Uncategorized',
|
||||
date: transaction.date,
|
||||
},
|
||||
categories: categoryValues.filter((value) => value !== 'Transfer'),
|
||||
tags: AI_TRANSACTION_TAGS,
|
||||
similarTransactions: similarTransactions.slice(0, 6).map((item) => ({
|
||||
merchant: item.aiNormalizedMerchant || item.merchant,
|
||||
category: item.category,
|
||||
tags: item.aiTags || [],
|
||||
note: item.note || '',
|
||||
})),
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是记账应用的交易分类引擎。你必须只输出 JSON 对象,不要输出 markdown。category 必须从 categories 中选择一个。tags 必须从 tags 中选择零到三个。confidence 必须是 0 到 1 的数字。若信息不足,请降低 confidence。',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: JSON.stringify(payload),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const buildFinanceChatMessages = ({ question, transactions = [], conversation = [] }) => {
|
||||
const summary = buildLedgerSummary(transactions)
|
||||
const history = conversation
|
||||
.filter((item) => ['user', 'assistant'].includes(item.role) && item.content)
|
||||
.slice(-6)
|
||||
.map((item) => ({
|
||||
role: item.role,
|
||||
content: item.content,
|
||||
}))
|
||||
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是 Echo 记账应用里的 AI 财务顾问。你只能基于用户提供的本地账本摘要回答问题,不能编造不存在的交易。回答使用简体中文,先给结论,再给理由和建议。金额统一写成人民币元。充值储值卡、卡间转账等内部转移不算消费支出,分析时要和真实消费分开。若数据不足,要明确说明。',
|
||||
},
|
||||
{
|
||||
role: 'system',
|
||||
content: `以下是用户本地账本摘要 JSON:${JSON.stringify(summary)}`,
|
||||
},
|
||||
...history,
|
||||
{
|
||||
role: 'user',
|
||||
content: question,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const normalizeEnrichmentResult = (rawResult) => {
|
||||
const result = rawResult && typeof rawResult === 'object' ? rawResult : {}
|
||||
const category = categoryValues.includes(result.category) && result.category !== 'Transfer'
|
||||
? result.category
|
||||
: 'Uncategorized'
|
||||
const tags = Array.isArray(result.tags)
|
||||
? result.tags.filter((tag) => AI_TRANSACTION_TAGS.includes(tag)).slice(0, 3)
|
||||
: []
|
||||
const confidenceValue = Number(result.confidence)
|
||||
const confidence = Number.isFinite(confidenceValue)
|
||||
? Math.min(Math.max(confidenceValue, 0), 1)
|
||||
: 0
|
||||
|
||||
return {
|
||||
normalizedMerchant: String(result.normalizedMerchant || '').trim() || '',
|
||||
category,
|
||||
tags,
|
||||
confidence,
|
||||
reason: String(result.reason || '').trim().slice(0, 200),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite'
|
||||
import { defineCustomElements as defineJeep } from 'jeep-sqlite/loader'
|
||||
|
||||
@@ -12,9 +12,94 @@ const TRANSACTION_TABLE_SQL = `
|
||||
category TEXT DEFAULT 'Uncategorized',
|
||||
date TEXT NOT NULL,
|
||||
note TEXT,
|
||||
sync_status INTEGER DEFAULT 0
|
||||
sync_status INTEGER DEFAULT 0,
|
||||
entry_type TEXT DEFAULT 'expense',
|
||||
fund_source_type TEXT DEFAULT 'cash',
|
||||
fund_source_name TEXT,
|
||||
fund_target_type TEXT DEFAULT 'merchant',
|
||||
fund_target_name TEXT,
|
||||
impact_expense INTEGER DEFAULT 1,
|
||||
impact_income INTEGER DEFAULT 0,
|
||||
ai_category TEXT,
|
||||
ai_tags TEXT,
|
||||
ai_confidence REAL,
|
||||
ai_reason TEXT,
|
||||
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
|
||||
);
|
||||
`
|
||||
const MERCHANT_PROFILE_TABLE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS merchant_profiles (
|
||||
merchant_key TEXT PRIMARY KEY NOT NULL,
|
||||
normalized_merchant TEXT NOT NULL,
|
||||
category TEXT,
|
||||
tags TEXT,
|
||||
hit_count INTEGER DEFAULT 0,
|
||||
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'",
|
||||
fund_source_name: 'TEXT',
|
||||
fund_target_type: "TEXT DEFAULT 'merchant'",
|
||||
fund_target_name: 'TEXT',
|
||||
impact_expense: 'INTEGER DEFAULT 1',
|
||||
impact_income: 'INTEGER DEFAULT 0',
|
||||
ai_category: 'TEXT',
|
||||
ai_tags: 'TEXT',
|
||||
ai_confidence: 'REAL',
|
||||
ai_reason: 'TEXT',
|
||||
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
|
||||
let db
|
||||
@@ -37,7 +122,7 @@ const ensureStoreReady = async (jeepEl, retries = 5) => {
|
||||
}
|
||||
await waitFor(200 * (attempt + 1))
|
||||
}
|
||||
throw new Error('jeep-sqlite IndexedDB store 未初始化成功')
|
||||
throw new Error('jeep-sqlite IndexedDB store failed to initialize')
|
||||
}
|
||||
|
||||
const ensureJeepElement = async () => {
|
||||
@@ -66,6 +151,17 @@ const ensureJeepElement = async () => {
|
||||
await CapacitorSQLite.initWebStore()
|
||||
}
|
||||
|
||||
const ensureTableColumns = async (tableName, requiredColumns) => {
|
||||
const result = await db.query(`PRAGMA table_info(${tableName});`)
|
||||
const existingColumns = new Set((result?.values || []).map((row) => row.name))
|
||||
|
||||
for (const [columnName, definition] of Object.entries(requiredColumns)) {
|
||||
if (!existingColumns.has(columnName)) {
|
||||
await db.execute(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition};`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prepareConnection = async () => {
|
||||
if (!sqliteConnection) {
|
||||
sqliteConnection = new SQLiteConnection(CapacitorSQLite)
|
||||
@@ -89,6 +185,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -112,7 +212,7 @@ export const saveDbToStore = async () => {
|
||||
try {
|
||||
await sqliteConnection.saveToStore(DB_NAME)
|
||||
} catch (error) {
|
||||
console.warn('[sqlite] saveToStore 执行失败,数据将保留在内存中', error)
|
||||
console.warn('[sqlite] saveToStore failed, data remains in memory until next sync', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
5
src/services/ai/aiProvider.js
Normal file
5
src/services/ai/aiProvider.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export class AiProvider {
|
||||
async enrichTransaction() {
|
||||
throw new Error('AI provider is not implemented')
|
||||
}
|
||||
}
|
||||
90
src/services/ai/aiService.js
Normal file
90
src/services/ai/aiService.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
buildFinanceChatMessages,
|
||||
buildTransactionEnrichmentMessages,
|
||||
} from '../../lib/aiPrompt.js'
|
||||
import { useSettingsStore } from '../../stores/settings.js'
|
||||
import {
|
||||
applyAiEnrichment,
|
||||
fetchRecentTransactionsForAi,
|
||||
markTransactionAiFailed,
|
||||
markTransactionAiRunning,
|
||||
} from '../transactionService.js'
|
||||
import { DeepSeekProvider } from './deepseekProvider.js'
|
||||
|
||||
const clampThreshold = (value) => {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) return 0.9
|
||||
return Math.min(Math.max(numeric, 0), 1)
|
||||
}
|
||||
|
||||
const createProvider = (settingsStore) => {
|
||||
if (settingsStore.aiProvider !== 'deepseek') {
|
||||
throw new Error(`Unsupported AI provider: ${settingsStore.aiProvider}`)
|
||||
}
|
||||
|
||||
return new DeepSeekProvider({
|
||||
apiKey: settingsStore.aiApiKey,
|
||||
model: settingsStore.aiModel,
|
||||
})
|
||||
}
|
||||
|
||||
export const hasAiAccess = (settingsStore = useSettingsStore()) => !!settingsStore.aiApiKey
|
||||
|
||||
export const isAiReady = (settingsStore = useSettingsStore()) =>
|
||||
!!settingsStore.aiAutoCategoryEnabled && hasAiAccess(settingsStore)
|
||||
|
||||
export const maybeEnrichTransactionWithAi = async (transaction, options = {}) => {
|
||||
if (!transaction?.id || transaction.entryType === 'transfer') return transaction
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
if (!isAiReady(settingsStore)) {
|
||||
return transaction
|
||||
}
|
||||
|
||||
try {
|
||||
const running = await markTransactionAiRunning(transaction.id, settingsStore.aiModel)
|
||||
if (running) {
|
||||
options.onProgress?.(running)
|
||||
}
|
||||
|
||||
const provider = createProvider(settingsStore)
|
||||
const similarTransactions = await fetchRecentTransactionsForAi(6, transaction.id)
|
||||
const messages = buildTransactionEnrichmentMessages({
|
||||
transaction,
|
||||
similarTransactions: similarTransactions.filter((item) => item.entryType !== 'transfer'),
|
||||
})
|
||||
const result = await provider.enrichTransaction({ messages })
|
||||
const threshold = clampThreshold(settingsStore.aiAutoApplyThreshold)
|
||||
|
||||
const updated = await applyAiEnrichment(transaction.id, result.normalized, {
|
||||
applyCategory: result.normalized.confidence >= threshold,
|
||||
model: result.model,
|
||||
})
|
||||
|
||||
return updated || transaction
|
||||
} catch (error) {
|
||||
console.warn('[ai] transaction enrichment failed', error)
|
||||
const failed = await markTransactionAiFailed(
|
||||
transaction.id,
|
||||
error?.message || 'AI enrichment failed',
|
||||
settingsStore.aiModel,
|
||||
)
|
||||
return failed || transaction
|
||||
}
|
||||
}
|
||||
|
||||
export const askFinanceAssistant = async ({ question, transactions = [], conversation = [] }) => {
|
||||
const settingsStore = useSettingsStore()
|
||||
if (!hasAiAccess(settingsStore)) {
|
||||
throw new Error('请先在设置页填写 DeepSeek API Key')
|
||||
}
|
||||
|
||||
const provider = createProvider(settingsStore)
|
||||
const messages = buildFinanceChatMessages({
|
||||
question,
|
||||
transactions,
|
||||
conversation,
|
||||
})
|
||||
|
||||
return provider.chat({ messages })
|
||||
}
|
||||
91
src/services/ai/deepseekProvider.js
Normal file
91
src/services/ai/deepseekProvider.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { normalizeEnrichmentResult } from '../../lib/aiPrompt.js'
|
||||
import { AiProvider } from './aiProvider.js'
|
||||
|
||||
const extractMessageContent = (data) => {
|
||||
const content = data?.choices?.[0]?.message?.content
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((item) => (typeof item?.text === 'string' ? item.text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
return typeof content === 'string' ? content.trim() : ''
|
||||
}
|
||||
|
||||
export class DeepSeekProvider extends AiProvider {
|
||||
constructor({ apiKey, baseUrl = 'https://api.deepseek.com', model = 'deepseek-chat' }) {
|
||||
super()
|
||||
this.apiKey = apiKey
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '')
|
||||
this.model = model
|
||||
}
|
||||
|
||||
async createCompletion(payload) {
|
||||
if (!this.apiKey) {
|
||||
throw new Error('DeepSeek API key is required')
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
...payload,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`DeepSeek request failed: ${response.status} ${detail}`.trim())
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async enrichTransaction({ messages }) {
|
||||
const data = await this.createCompletion({
|
||||
temperature: 0.2,
|
||||
response_format: { type: 'json_object' },
|
||||
messages,
|
||||
})
|
||||
|
||||
const content = extractMessageContent(data)
|
||||
if (!content) {
|
||||
throw new Error('DeepSeek returned an empty response')
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
} catch {
|
||||
throw new Error('DeepSeek returned invalid JSON')
|
||||
}
|
||||
|
||||
return {
|
||||
raw: parsed,
|
||||
normalized: normalizeEnrichmentResult(parsed),
|
||||
model: data?.model || this.model,
|
||||
}
|
||||
}
|
||||
|
||||
async chat({ messages, temperature = 0.6, maxTokens = 1200 }) {
|
||||
const data = await this.createCompletion({
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
messages,
|
||||
})
|
||||
|
||||
const content = extractMessageContent(data)
|
||||
if (!content) {
|
||||
throw new Error('DeepSeek returned an empty response')
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
model: data?.model || this.model,
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import notificationRulesSource from '../config/notificationRules.js'
|
||||
import { DEFAULT_LEDGER_BY_ENTRY_TYPE } from '../config/ledger.js'
|
||||
import notificationRulesSource from '../config/notificationRules.js'
|
||||
|
||||
const fallbackRules = notificationRulesSource.map((rule) => ({
|
||||
...rule,
|
||||
@@ -10,13 +11,36 @@ const fallbackRules = notificationRulesSource.map((rule) => ({
|
||||
const amountPatterns = [
|
||||
/(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)/i,
|
||||
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)/,
|
||||
/(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车)\D{0,8}?(-?\d+(?:\.\d{1,2})?)/,
|
||||
/(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车|充值)\D{0,8}?(-?\d+(?:\.\d{1,2})?)/,
|
||||
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)/,
|
||||
]
|
||||
|
||||
let cachedRules = [...fallbackRules]
|
||||
let remoteRuleFetcher = async () => []
|
||||
|
||||
const buildLedgerPayload = (rule, notification, merchant) => {
|
||||
const entryType = rule?.entryType || 'expense'
|
||||
const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense
|
||||
const channelName = notification?.channel || ''
|
||||
|
||||
const resolveName = (field, fromChannelFlag) => {
|
||||
if (rule?.[fromChannelFlag] && channelName) return channelName
|
||||
return rule?.[field] || defaults[field] || ''
|
||||
}
|
||||
|
||||
return {
|
||||
entryType,
|
||||
fundSourceType: rule?.fundSourceType || defaults.fundSourceType,
|
||||
fundSourceName: resolveName('fundSourceName', 'fundSourceNameFromChannel'),
|
||||
fundTargetType: rule?.fundTargetType || defaults.fundTargetType,
|
||||
fundTargetName:
|
||||
resolveName('fundTargetName', 'fundTargetNameFromChannel') ||
|
||||
(entryType === 'expense' ? merchant : defaults.fundTargetName),
|
||||
impactExpense: rule?.impactExpense ?? defaults.impactExpense,
|
||||
impactIncome: rule?.impactIncome ?? defaults.impactIncome,
|
||||
}
|
||||
}
|
||||
|
||||
const buildEmptyTransaction = (notification, options = {}) => ({
|
||||
id: options.id,
|
||||
merchant: options.merchant || 'Unknown',
|
||||
@@ -25,6 +49,7 @@ const buildEmptyTransaction = (notification, options = {}) => ({
|
||||
date: new Date(options.date || notification?.createdAt || new Date().toISOString()).toISOString(),
|
||||
note: notification?.text || '',
|
||||
syncStatus: 'pending',
|
||||
...DEFAULT_LEDGER_BY_ENTRY_TYPE.expense,
|
||||
})
|
||||
|
||||
export const setRemoteRuleFetcher = (fetcher) => {
|
||||
@@ -78,6 +103,13 @@ const looksLikeAmount = (raw = '') => {
|
||||
return /^[-\d.,]+\s*(?:元|块|块钱|¥|¥|人民币)?$/i.test(value)
|
||||
}
|
||||
|
||||
const hasTransitRouteAmountFallback = (rule, text = '') => {
|
||||
if (rule?.id !== 'transit-card-expense') return false
|
||||
return /[\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,}[::]?\s*\d+(?:\.\d{1,2})?\s*(?:元|块|块钱)/.test(
|
||||
text,
|
||||
)
|
||||
}
|
||||
|
||||
const extractMerchant = (text, rule) => {
|
||||
const content = text || ''
|
||||
|
||||
@@ -134,7 +166,8 @@ const matchRule = (notification, rule) => {
|
||||
const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word))
|
||||
const requiredTextHit =
|
||||
!rule.requiredTextPatterns?.length ||
|
||||
rule.requiredTextPatterns.some((word) => text.includes(word))
|
||||
rule.requiredTextPatterns.some((word) => text.includes(word)) ||
|
||||
hasTransitRouteAmountFallback(rule, text)
|
||||
|
||||
return channelHit && keywordHit && requiredTextHit
|
||||
}
|
||||
@@ -161,8 +194,7 @@ export const transformNotificationToTransaction = (notification, options = {}) =
|
||||
}
|
||||
|
||||
const direction = options.direction || rule.direction || 'expense'
|
||||
const normalizedAmount =
|
||||
direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText)
|
||||
const normalizedAmount = direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText)
|
||||
|
||||
let merchant = options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown'
|
||||
if (merchant === 'Unknown' && rule.defaultMerchant) {
|
||||
@@ -170,6 +202,7 @@ export const transformNotificationToTransaction = (notification, options = {}) =
|
||||
}
|
||||
|
||||
const date = options.date || notification?.createdAt || new Date().toISOString()
|
||||
const ledger = buildLedgerPayload(rule, notification, merchant)
|
||||
|
||||
const transaction = {
|
||||
id: options.id,
|
||||
@@ -179,6 +212,7 @@ export const transformNotificationToTransaction = (notification, options = {}) =
|
||||
date: new Date(date).toISOString(),
|
||||
note: notification?.text || '',
|
||||
syncStatus: 'pending',
|
||||
...ledger,
|
||||
}
|
||||
|
||||
const requiresConfirmation =
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import {
|
||||
DEFAULT_LEDGER_BY_ENTRY_TYPE,
|
||||
getAccountTypeLabel,
|
||||
} from '../config/ledger.js'
|
||||
import { getDb, saveDbToStore } from '../lib/sqlite'
|
||||
|
||||
const statusMap = {
|
||||
@@ -7,13 +11,111 @@ const statusMap = {
|
||||
error: 2,
|
||||
}
|
||||
|
||||
const TRANSACTION_SELECT_FIELDS = `
|
||||
id,
|
||||
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,
|
||||
ai_category,
|
||||
ai_tags,
|
||||
ai_confidence,
|
||||
ai_reason,
|
||||
ai_status,
|
||||
ai_model,
|
||||
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) => {
|
||||
if (value === 1) return 'synced'
|
||||
if (value === 2) return 'error'
|
||||
return 'pending'
|
||||
}
|
||||
|
||||
const mapRow = (row) => ({
|
||||
const normalizeAiStatus = (value) => {
|
||||
if (['running', 'applied', 'suggested', 'failed'].includes(value)) {
|
||||
return value
|
||||
}
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
const parseAiTags = (value) => {
|
||||
if (!value) return []
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeFlag = (value, fallback = false) => {
|
||||
if (value === 1 || value === true || value === '1') return true
|
||||
if (value === 0 || value === false || value === '0') return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return {
|
||||
entryType,
|
||||
fundSourceType: payload.fundSourceType || existing?.fundSourceType || defaults.fundSourceType,
|
||||
fundSourceName:
|
||||
payload.fundSourceName ||
|
||||
existing?.fundSourceName ||
|
||||
defaults.fundSourceName ||
|
||||
getAccountTypeLabel(defaults.fundSourceType),
|
||||
fundTargetType: payload.fundTargetType || existing?.fundTargetType || defaults.fundTargetType,
|
||||
fundTargetName:
|
||||
payload.fundTargetName ||
|
||||
existing?.fundTargetName ||
|
||||
defaults.fundTargetName ||
|
||||
getAccountTypeLabel(defaults.fundTargetType),
|
||||
impactExpense:
|
||||
payload.impactExpense ?? existing?.impactExpense ?? defaults.impactExpense ?? entryType === 'expense',
|
||||
impactIncome:
|
||||
payload.impactIncome ?? existing?.impactIncome ?? defaults.impactIncome ?? entryType === 'income',
|
||||
}
|
||||
}
|
||||
|
||||
const mapRow = (row) => {
|
||||
const aiConfidenceValue = Number(row.ai_confidence)
|
||||
const entryType = row.entry_type || inferEntryTypeFromAmount(row.amount)
|
||||
const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
amount: Number(row.amount),
|
||||
merchant: row.merchant,
|
||||
@@ -21,13 +123,108 @@ const mapRow = (row) => ({
|
||||
date: row.date,
|
||||
note: row.note || '',
|
||||
syncStatus: normalizeStatus(row.sync_status),
|
||||
})
|
||||
entryType,
|
||||
fundSourceType: row.fund_source_type || defaults.fundSourceType,
|
||||
fundSourceName: row.fund_source_name || defaults.fundSourceName || '',
|
||||
fundTargetType: row.fund_target_type || defaults.fundTargetType,
|
||||
fundTargetName: row.fund_target_name || defaults.fundTargetName || '',
|
||||
impactExpense: normalizeFlag(row.impact_expense, defaults.impactExpense),
|
||||
impactIncome: normalizeFlag(row.impact_income, defaults.impactIncome),
|
||||
aiCategory: row.ai_category || '',
|
||||
aiTags: parseAiTags(row.ai_tags),
|
||||
aiConfidence: Number.isFinite(aiConfidenceValue) ? aiConfidenceValue : 0,
|
||||
aiReason: row.ai_reason || '',
|
||||
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 || '',
|
||||
}
|
||||
}
|
||||
|
||||
const getMerchantKey = (merchant) =>
|
||||
String(merchant || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '')
|
||||
.slice(0, 120)
|
||||
|
||||
const upsertMerchantProfile = async ({ merchant, normalizedMerchant, category, tags }) => {
|
||||
const sourceName = normalizedMerchant || merchant
|
||||
const merchantKey = getMerchantKey(sourceName)
|
||||
if (!merchantKey) return
|
||||
|
||||
const db = await getDb()
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT merchant_key, hit_count
|
||||
FROM merchant_profiles
|
||||
WHERE merchant_key = ?
|
||||
`,
|
||||
[merchantKey],
|
||||
)
|
||||
|
||||
const hitCount = Number(result?.values?.[0]?.hit_count || 0)
|
||||
const payload = {
|
||||
normalizedMerchant: sourceName,
|
||||
category: category || 'Uncategorized',
|
||||
tags: JSON.stringify(Array.isArray(tags) ? tags : []),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (result?.values?.length) {
|
||||
await db.run(
|
||||
`
|
||||
UPDATE merchant_profiles
|
||||
SET normalized_merchant = ?, category = ?, tags = ?, hit_count = ?, updated_at = ?
|
||||
WHERE merchant_key = ?
|
||||
`,
|
||||
[
|
||||
payload.normalizedMerchant,
|
||||
payload.category,
|
||||
payload.tags,
|
||||
hitCount + 1,
|
||||
payload.updatedAt,
|
||||
merchantKey,
|
||||
],
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO merchant_profiles (
|
||||
merchant_key,
|
||||
normalized_merchant,
|
||||
category,
|
||||
tags,
|
||||
hit_count,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
merchantKey,
|
||||
payload.normalizedMerchant,
|
||||
payload.category,
|
||||
payload.tags,
|
||||
1,
|
||||
payload.updatedAt,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export const fetchTransactions = async () => {
|
||||
const db = await getDb()
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT id, amount, merchant, category, date, note, sync_status
|
||||
SELECT ${TRANSACTION_SELECT_FIELDS}
|
||||
FROM transactions
|
||||
ORDER BY date DESC
|
||||
`,
|
||||
@@ -35,22 +232,89 @@ export const fetchTransactions = async () => {
|
||||
return (result?.values || []).map(mapRow)
|
||||
}
|
||||
|
||||
export const getTransactionById = async (id) => {
|
||||
const db = await getDb()
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT ${TRANSACTION_SELECT_FIELDS}
|
||||
FROM transactions
|
||||
WHERE id = ?
|
||||
`,
|
||||
[id],
|
||||
)
|
||||
|
||||
const row = result?.values?.[0]
|
||||
return row ? mapRow(row) : null
|
||||
}
|
||||
|
||||
export const fetchRecentTransactionsForAi = async (limit = 6, excludeId = '') => {
|
||||
const db = await getDb()
|
||||
const safeLimit = Math.max(1, Math.min(Number(limit) || 6, 20))
|
||||
const params = []
|
||||
const whereClause = excludeId ? 'WHERE id != ?' : ''
|
||||
if (excludeId) {
|
||||
params.push(excludeId)
|
||||
}
|
||||
params.push(safeLimit)
|
||||
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT ${TRANSACTION_SELECT_FIELDS}
|
||||
FROM transactions
|
||||
${whereClause}
|
||||
ORDER BY date DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
params,
|
||||
)
|
||||
|
||||
return (result?.values || []).map(mapRow)
|
||||
}
|
||||
|
||||
export const insertTransaction = async (payload) => {
|
||||
const db = await getDb()
|
||||
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',
|
||||
note: payload.note?.trim() || '',
|
||||
date: payload.date,
|
||||
amount: Number(payload.amount),
|
||||
amount,
|
||||
syncStatus: statusMap[payload.syncStatus] ?? statusMap.pending,
|
||||
...ledger,
|
||||
...sourceMeta,
|
||||
}
|
||||
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO transactions (id, amount, merchant, category, date, note, sync_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO transactions (
|
||||
id,
|
||||
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
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
id,
|
||||
@@ -60,6 +324,21 @@ export const insertTransaction = async (payload) => {
|
||||
sanitized.date,
|
||||
sanitized.note || null,
|
||||
sanitized.syncStatus,
|
||||
sanitized.entryType,
|
||||
sanitized.fundSourceType,
|
||||
sanitized.fundSourceName || null,
|
||||
sanitized.fundTargetType,
|
||||
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,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -69,41 +348,148 @@ export const insertTransaction = async (payload) => {
|
||||
id,
|
||||
...sanitized,
|
||||
syncStatus: payload.syncStatus || 'pending',
|
||||
aiCategory: '',
|
||||
aiTags: [],
|
||||
aiConfidence: 0,
|
||||
aiReason: '',
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
export const updateTransaction = async (payload) => {
|
||||
const existing = await getTransactionById(payload.id)
|
||||
if (!existing) return null
|
||||
|
||||
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 = ?
|
||||
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 = ?
|
||||
`,
|
||||
[
|
||||
Number(payload.amount),
|
||||
payload.merchant?.trim() || 'Unknown',
|
||||
payload.category?.trim() || 'Uncategorized',
|
||||
payload.merchant?.trim() || existing.merchant || 'Unknown',
|
||||
payload.category?.trim() || existing.category || 'Uncategorized',
|
||||
payload.date,
|
||||
payload.note?.trim() || null,
|
||||
statusMap[payload.syncStatus] ?? statusMap.pending,
|
||||
ledger.entryType,
|
||||
ledger.fundSourceType,
|
||||
ledger.fundSourceName || null,
|
||||
ledger.fundTargetType,
|
||||
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,
|
||||
],
|
||||
)
|
||||
|
||||
await saveDbToStore()
|
||||
return getTransactionById(payload.id)
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
export const markTransactionAiRunning = async (transactionId, model = '') => {
|
||||
const db = await getDb()
|
||||
await db.run(
|
||||
`
|
||||
SELECT id, amount, merchant, category, date, note, sync_status
|
||||
FROM transactions
|
||||
UPDATE transactions
|
||||
SET ai_status = ?, ai_model = ?, ai_reason = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[payload.id],
|
||||
['running', model || null, null, transactionId],
|
||||
)
|
||||
|
||||
const updated = result?.values?.[0]
|
||||
return updated ? mapRow(updated) : null
|
||||
await saveDbToStore()
|
||||
return getTransactionById(transactionId)
|
||||
}
|
||||
|
||||
export const applyAiEnrichment = async (transactionId, enrichment, options = {}) => {
|
||||
const existing = await getTransactionById(transactionId)
|
||||
if (!existing) return null
|
||||
|
||||
const applyCategory = !!options.applyCategory
|
||||
const nextCategory = applyCategory ? enrichment.category || existing.category : existing.category
|
||||
const nextStatus = options.status || (applyCategory ? 'applied' : 'suggested')
|
||||
const db = await getDb()
|
||||
|
||||
await db.run(
|
||||
`
|
||||
UPDATE transactions
|
||||
SET category = ?, ai_category = ?, ai_tags = ?, ai_confidence = ?, ai_reason = ?, ai_status = ?, ai_model = ?, ai_normalized_merchant = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[
|
||||
nextCategory,
|
||||
enrichment.category || null,
|
||||
JSON.stringify(Array.isArray(enrichment.tags) ? enrichment.tags : []),
|
||||
Number(enrichment.confidence || 0),
|
||||
enrichment.reason || null,
|
||||
nextStatus,
|
||||
options.model || null,
|
||||
enrichment.normalizedMerchant || null,
|
||||
transactionId,
|
||||
],
|
||||
)
|
||||
|
||||
if (
|
||||
!options.skipMerchantProfile &&
|
||||
(enrichment.normalizedMerchant || enrichment.category || enrichment.tags?.length)
|
||||
) {
|
||||
await upsertMerchantProfile({
|
||||
merchant: existing.merchant,
|
||||
normalizedMerchant: enrichment.normalizedMerchant,
|
||||
category: enrichment.category,
|
||||
tags: enrichment.tags,
|
||||
})
|
||||
}
|
||||
|
||||
await saveDbToStore()
|
||||
return getTransactionById(transactionId)
|
||||
}
|
||||
|
||||
export const markTransactionAiFailed = async (transactionId, reason, model = '') => {
|
||||
const db = await getDb()
|
||||
await db.run(
|
||||
`
|
||||
UPDATE transactions
|
||||
SET ai_category = ?, ai_tags = ?, ai_confidence = ?, ai_reason = ?, ai_status = ?, ai_model = ?, ai_normalized_merchant = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[
|
||||
null,
|
||||
JSON.stringify([]),
|
||||
0,
|
||||
reason || 'AI enrichment failed',
|
||||
'failed',
|
||||
model || null,
|
||||
null,
|
||||
transactionId,
|
||||
],
|
||||
)
|
||||
|
||||
await saveDbToStore()
|
||||
return getTransactionById(transactionId)
|
||||
}
|
||||
|
||||
export const deleteTransaction = async (id) => {
|
||||
@@ -111,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { DEFAULT_CATEGORY_BUDGETS } from '../config/transactionCategories.js'
|
||||
import { AI_MODEL_OPTIONS, AI_PROVIDER_OPTIONS } from '../config/transactionTags.js'
|
||||
|
||||
const AI_PROVIDER_VALUES = AI_PROVIDER_OPTIONS.map((item) => item.value)
|
||||
const AI_MODEL_VALUES = AI_MODEL_OPTIONS.map((item) => item.value)
|
||||
|
||||
export const useSettingsStore = defineStore(
|
||||
'settings',
|
||||
() => {
|
||||
const notificationCaptureEnabled = ref(true)
|
||||
const aiAutoCategoryEnabled = ref(false)
|
||||
const aiProvider = ref('deepseek')
|
||||
const aiApiKey = ref('')
|
||||
const aiModel = ref('deepseek-chat')
|
||||
const aiAutoApplyThreshold = ref(0.9)
|
||||
const monthlyBudget = ref(12000)
|
||||
const budgetResetCycle = ref('monthly')
|
||||
const budgetMonthlyResetDay = ref(1)
|
||||
@@ -24,6 +32,27 @@ export const useSettingsStore = defineStore(
|
||||
aiAutoCategoryEnabled.value = !!value
|
||||
}
|
||||
|
||||
const setAiProvider = (value) => {
|
||||
aiProvider.value = AI_PROVIDER_VALUES.includes(value) ? value : 'deepseek'
|
||||
}
|
||||
|
||||
const setAiApiKey = (value) => {
|
||||
aiApiKey.value = String(value || '').trim()
|
||||
}
|
||||
|
||||
const setAiModel = (value) => {
|
||||
aiModel.value = AI_MODEL_VALUES.includes(value) ? value : 'deepseek-chat'
|
||||
}
|
||||
|
||||
const setAiAutoApplyThreshold = (value) => {
|
||||
const numeric = Number(value)
|
||||
if (!Number.isFinite(numeric)) {
|
||||
aiAutoApplyThreshold.value = 0.9
|
||||
return
|
||||
}
|
||||
aiAutoApplyThreshold.value = Math.min(Math.max(numeric, 0), 1)
|
||||
}
|
||||
|
||||
const setMonthlyBudget = (value) => {
|
||||
const numeric = Number(value)
|
||||
monthlyBudget.value = Number.isFinite(numeric) && numeric >= 0 ? numeric : 0
|
||||
@@ -72,6 +101,10 @@ export const useSettingsStore = defineStore(
|
||||
return {
|
||||
notificationCaptureEnabled,
|
||||
aiAutoCategoryEnabled,
|
||||
aiProvider,
|
||||
aiApiKey,
|
||||
aiModel,
|
||||
aiAutoApplyThreshold,
|
||||
monthlyBudget,
|
||||
budgetResetCycle,
|
||||
budgetMonthlyResetDay,
|
||||
@@ -82,6 +115,10 @@ export const useSettingsStore = defineStore(
|
||||
profileAvatar,
|
||||
setNotificationCaptureEnabled,
|
||||
setAiAutoCategoryEnabled,
|
||||
setAiProvider,
|
||||
setAiApiKey,
|
||||
setAiModel,
|
||||
setAiAutoApplyThreshold,
|
||||
setMonthlyBudget,
|
||||
setBudgetResetCycle,
|
||||
setBudgetMonthlyResetDay,
|
||||
@@ -97,6 +134,10 @@ export const useSettingsStore = defineStore(
|
||||
paths: [
|
||||
'notificationCaptureEnabled',
|
||||
'aiAutoCategoryEnabled',
|
||||
'aiProvider',
|
||||
'aiApiKey',
|
||||
'aiModel',
|
||||
'aiAutoApplyThreshold',
|
||||
'monthlyBudget',
|
||||
'budgetResetCycle',
|
||||
'budgetMonthlyResetDay',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { shouldCountAsExpense, shouldCountAsIncome } from '../config/ledger.js'
|
||||
import { maybeEnrichTransactionWithAi } from '../services/ai/aiService.js'
|
||||
import {
|
||||
deleteTransaction,
|
||||
fetchTransactions,
|
||||
@@ -26,6 +28,9 @@ const formatDayLabel = (dayKey) => {
|
||||
}).format(localDate)
|
||||
}
|
||||
|
||||
const replaceTransaction = (list, nextTransaction) =>
|
||||
list.map((tx) => (tx.id === nextTransaction.id ? nextTransaction : tx))
|
||||
|
||||
export const useTransactionStore = defineStore(
|
||||
'transactions',
|
||||
() => {
|
||||
@@ -48,19 +53,19 @@ export const useTransactionStore = defineStore(
|
||||
})
|
||||
|
||||
const totalExpense = computed(() =>
|
||||
sortedTransactions.value.reduce((sum, tx) => (tx.amount < 0 ? sum + tx.amount : sum), 0),
|
||||
sortedTransactions.value.reduce((sum, tx) => (shouldCountAsExpense(tx) ? sum + tx.amount : sum), 0),
|
||||
)
|
||||
|
||||
const totalIncome = computed(() =>
|
||||
sortedTransactions.value.reduce((sum, tx) => (tx.amount > 0 ? sum + tx.amount : sum), 0),
|
||||
sortedTransactions.value.reduce((sum, tx) => (shouldCountAsIncome(tx) ? sum + tx.amount : sum), 0),
|
||||
)
|
||||
|
||||
const todaysExpense = computed(() =>
|
||||
todaysTransactions.value.reduce((sum, tx) => (tx.amount < 0 ? sum + tx.amount : sum), 0),
|
||||
todaysTransactions.value.reduce((sum, tx) => (shouldCountAsExpense(tx) ? sum + tx.amount : sum), 0),
|
||||
)
|
||||
|
||||
const todaysIncome = computed(() =>
|
||||
todaysTransactions.value.reduce((sum, tx) => (tx.amount > 0 ? sum + tx.amount : sum), 0),
|
||||
todaysTransactions.value.reduce((sum, tx) => (shouldCountAsIncome(tx) ? sum + tx.amount : sum), 0),
|
||||
)
|
||||
|
||||
const latestTransactions = computed(() => sortedTransactions.value.slice(0, 5))
|
||||
@@ -86,7 +91,7 @@ export const useTransactionStore = defineStore(
|
||||
dayKey,
|
||||
label: formatDayLabel(dayKey),
|
||||
totalExpense: records
|
||||
.filter((tx) => tx.amount < 0)
|
||||
.filter((tx) => shouldCountAsExpense(tx))
|
||||
.reduce((sum, tx) => sum + Math.abs(tx.amount), 0),
|
||||
items: records,
|
||||
}))
|
||||
@@ -113,6 +118,23 @@ export const useTransactionStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
const upsertTransaction = (nextTransaction) => {
|
||||
transactions.value = replaceTransaction(transactions.value, nextTransaction)
|
||||
}
|
||||
|
||||
const enrichTransactionInBackground = async (transaction) => {
|
||||
const enriched = await maybeEnrichTransactionWithAi(transaction, {
|
||||
onProgress: (nextTransaction) => {
|
||||
if (nextTransaction?.id === transaction.id) {
|
||||
upsertTransaction(nextTransaction)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (!enriched || enriched.id !== transaction.id) return
|
||||
upsertTransaction(enriched)
|
||||
}
|
||||
|
||||
const addTransaction = async (payload) => {
|
||||
const normalized = {
|
||||
...payload,
|
||||
@@ -120,6 +142,7 @@ export const useTransactionStore = defineStore(
|
||||
}
|
||||
const created = await insertTransaction(normalized)
|
||||
transactions.value = [created, ...transactions.value]
|
||||
void enrichTransactionInBackground(created)
|
||||
return created
|
||||
}
|
||||
|
||||
@@ -130,7 +153,8 @@ export const useTransactionStore = defineStore(
|
||||
}
|
||||
const updated = await updateTransaction(normalized)
|
||||
if (updated) {
|
||||
transactions.value = transactions.value.map((tx) => (tx.id === updated.id ? updated : tx))
|
||||
upsertTransaction(updated)
|
||||
void enrichTransactionInBackground(updated)
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// 负责全局 UI 状态,例如新增记录的底部弹窗
|
||||
// 管理全局 UI 状态,例如记一笔弹层的开关和当前编辑项。
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const addEntryVisible = ref(false)
|
||||
const editingTransactionId = ref('')
|
||||
@@ -23,4 +23,3 @@ export const useUiStore = defineStore('ui', () => {
|
||||
closeAddEntry,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
16
src/types/transaction.d.ts
vendored
16
src/types/transaction.d.ts
vendored
@@ -1,4 +1,4 @@
|
||||
export interface Transaction {
|
||||
export interface Transaction {
|
||||
id: string;
|
||||
amount: number;
|
||||
merchant: string;
|
||||
@@ -6,4 +6,18 @@ export interface Transaction {
|
||||
date: string;
|
||||
note?: string;
|
||||
syncStatus: 'pending' | 'synced' | 'error';
|
||||
entryType?: 'expense' | 'income' | 'transfer';
|
||||
fundSourceType?: 'cash' | 'bank' | 'credit' | 'stored_value' | 'merchant' | 'income_source' | 'external' | 'unknown';
|
||||
fundSourceName?: string;
|
||||
fundTargetType?: 'cash' | 'bank' | 'credit' | 'stored_value' | 'merchant' | 'income_source' | 'external' | 'unknown';
|
||||
fundTargetName?: string;
|
||||
impactExpense?: boolean;
|
||||
impactIncome?: boolean;
|
||||
aiCategory?: string;
|
||||
aiTags?: string[];
|
||||
aiConfidence?: number;
|
||||
aiReason?: string;
|
||||
aiStatus?: 'idle' | 'running' | 'applied' | 'suggested' | 'failed';
|
||||
aiModel?: string;
|
||||
aiNormalizedMerchant?: string;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
DEFAULT_TRANSACTION_CATEGORY,
|
||||
TRANSACTION_CATEGORIES,
|
||||
getCategoryLabel,
|
||||
getCategoryMeta,
|
||||
} from '../config/transactionCategories.js'
|
||||
import { useTransactionStore } from '../stores/transactions'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
@@ -19,7 +19,11 @@ const dragStartY = ref(0)
|
||||
const dragging = ref(false)
|
||||
const dragOffset = ref(0)
|
||||
|
||||
const categories = TRANSACTION_CATEGORIES
|
||||
const categories = computed(() =>
|
||||
TRANSACTION_CATEGORIES.filter((category) =>
|
||||
form.type === 'income' ? ['Income', 'Uncategorized'].includes(category.value) : category.value !== 'Income',
|
||||
),
|
||||
)
|
||||
|
||||
const toDatetimeLocal = (value) => {
|
||||
const date = value ? new Date(value) : new Date()
|
||||
@@ -65,6 +69,19 @@ const hydrateForm = () => {
|
||||
|
||||
watch(editingId, hydrateForm, { immediate: true })
|
||||
|
||||
watch(
|
||||
() => form.type,
|
||||
(nextType) => {
|
||||
if (nextType === 'income' && form.category !== 'Income') {
|
||||
form.category = 'Income'
|
||||
return
|
||||
}
|
||||
if (nextType === 'expense' && form.category === 'Income') {
|
||||
form.category = DEFAULT_TRANSACTION_CATEGORY
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const closePanel = () => {
|
||||
uiStore.closeAddEntry()
|
||||
}
|
||||
@@ -181,9 +198,9 @@ const sheetStyle = computed(() => {
|
||||
<button class="text-stone-400 text-sm font-bold" @click="closePanel">关闭</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mb-6 bg-stone-100 rounded-3xl p-1">
|
||||
<div class="grid grid-cols-2 gap-2 mb-6 bg-stone-100 rounded-[26px] p-1.5">
|
||||
<button
|
||||
class="flex-1 py-2 rounded-2xl text-sm font-bold transition"
|
||||
class="inline-flex items-center justify-center gap-2 min-h-[48px] rounded-[22px] text-sm font-bold transition"
|
||||
:class="
|
||||
form.type === 'expense'
|
||||
? 'bg-white shadow text-rose-500'
|
||||
@@ -194,7 +211,7 @@ const sheetStyle = computed(() => {
|
||||
<i class="ph-bold ph-arrow-up-right" /> 支出
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2 rounded-2xl text-sm font-bold transition"
|
||||
class="inline-flex items-center justify-center gap-2 min-h-[48px] rounded-[22px] text-sm font-bold transition"
|
||||
:class="
|
||||
form.type === 'income'
|
||||
? 'bg-white shadow text-emerald-500'
|
||||
@@ -217,7 +234,7 @@ const sheetStyle = computed(() => {
|
||||
input-class="text-2xl font-bold"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="text-stone-400 text-sm">楼</span>
|
||||
<span class="text-stone-400 text-sm">¥</span>
|
||||
</template>
|
||||
</EchoInput>
|
||||
|
||||
@@ -229,11 +246,11 @@ const sheetStyle = computed(() => {
|
||||
|
||||
<div>
|
||||
<span class="text-xs font-bold text-stone-400">分类</span>
|
||||
<div class="mt-2 flex gap-2 overflow-x-auto hide-scrollbar pb-1">
|
||||
<div class="mt-3 grid grid-cols-3 gap-3 sm:grid-cols-4">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.value"
|
||||
class="px-4 py-2 rounded-2xl text-xs font-bold border transition"
|
||||
class="flex min-h-[78px] flex-col items-center justify-center gap-2 rounded-[24px] border px-3 py-3 text-xs font-bold leading-snug transition text-center"
|
||||
:class="
|
||||
form.category === category.value
|
||||
? 'bg-gradient-warm text-white border-transparent'
|
||||
@@ -241,7 +258,8 @@ const sheetStyle = computed(() => {
|
||||
"
|
||||
@click="form.category = category.value"
|
||||
>
|
||||
{{ getCategoryLabel(category.value) }}
|
||||
<i :class="['ph-fill text-lg leading-none', getCategoryMeta(category.value).icon]" />
|
||||
<span class="block leading-tight">{{ getCategoryMeta(category.value).label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,102 @@
|
||||
<script setup>
|
||||
// 分析页当前仍是占位页,先展示未来 AI 财务顾问的交互方向。
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { askFinanceAssistant, hasAiAccess } from '../services/ai/aiService.js'
|
||||
import { useSettingsStore } from '../stores/settings.js'
|
||||
import { useTransactionStore } from '../stores/transactions.js'
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const transactionStore = useTransactionStore()
|
||||
const { sortedTransactions } = storeToRefs(transactionStore)
|
||||
|
||||
const prompt = ref('')
|
||||
const sending = ref(false)
|
||||
let messageSeed = 0
|
||||
|
||||
const quickPrompts = [
|
||||
'帮我总结这周花钱最多的三类项目。',
|
||||
'最近有哪些支出值得我留意?',
|
||||
'我这个月的餐饮支出高吗?',
|
||||
]
|
||||
|
||||
const createMessage = (role, content, extra = {}) => ({
|
||||
id: `msg-${Date.now()}-${messageSeed += 1}`,
|
||||
role,
|
||||
content,
|
||||
...extra,
|
||||
})
|
||||
|
||||
const messages = ref([
|
||||
createMessage(
|
||||
'assistant',
|
||||
'可以直接问我账本问题,比如“这周花钱最多的三类项目是什么?”或“最近有哪些支出值得注意?”。',
|
||||
),
|
||||
])
|
||||
|
||||
const aiReady = computed(() => hasAiAccess(settingsStore))
|
||||
const hasTransactions = computed(() => sortedTransactions.value.length > 0)
|
||||
const canSend = computed(() => !!prompt.value.trim() && !sending.value)
|
||||
|
||||
const replaceMessage = (messageId, nextMessage) => {
|
||||
messages.value = messages.value.map((item) => (item.id === messageId ? nextMessage : item))
|
||||
}
|
||||
|
||||
const sendPrompt = async (rawPrompt = prompt.value) => {
|
||||
const question = String(rawPrompt || '').trim()
|
||||
if (!question || sending.value) return
|
||||
|
||||
const conversation = messages.value
|
||||
.filter((item) => !item.pending)
|
||||
.slice(-6)
|
||||
.map(({ role, content }) => ({ role, content }))
|
||||
|
||||
messages.value.push(createMessage('user', question))
|
||||
prompt.value = ''
|
||||
|
||||
if (!aiReady.value) {
|
||||
messages.value.push(
|
||||
createMessage('assistant', '先去设置页填写 DeepSeek API Key,然后这里就可以直接对话。'),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const pendingMessage = createMessage('assistant', '正在整理本地账本并生成回答...', {
|
||||
pending: true,
|
||||
})
|
||||
messages.value.push(pendingMessage)
|
||||
sending.value = true
|
||||
|
||||
try {
|
||||
const result = await askFinanceAssistant({
|
||||
question,
|
||||
transactions: sortedTransactions.value,
|
||||
conversation,
|
||||
})
|
||||
replaceMessage(pendingMessage.id, createMessage('assistant', result.content))
|
||||
} catch (error) {
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage('assistant', error?.message || 'AI 请求失败,请稍后重试。'),
|
||||
)
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickPrompt = (value) => {
|
||||
void sendPrompt(value)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
void sendPrompt()
|
||||
}
|
||||
|
||||
const handleKeydown = (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void sendPrompt()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -12,72 +109,82 @@
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-extrabold text-stone-800">AI 财务顾问</h2>
|
||||
<p class="text-xs text-stone-400">即将接入对话分析与自动分类</p>
|
||||
<p class="text-xs text-stone-400">直接基于本地账本和 DeepSeek 对话分析。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto space-y-5 pr-1 pb-2">
|
||||
<div class="flex gap-3">
|
||||
<div v-if="!hasTransactions" class="mb-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
|
||||
当前还没有账本记录。聊天功能可以使用,但回答只会提示你先开始记账。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!aiReady"
|
||||
class="mb-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700"
|
||||
>
|
||||
先去设置页填写 DeepSeek API Key,这里才能真正发送提问。
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto space-y-4 pr-1 pb-2">
|
||||
<div
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="flex gap-3"
|
||||
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<template v-if="message.role === 'assistant'">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-stone-100 flex items-center justify-center text-stone-400 mt-1 shrink-0"
|
||||
>
|
||||
<i class="ph-fill ph-robot" />
|
||||
</div>
|
||||
<div
|
||||
class="bg-white p-4 rounded-2xl rounded-tl-none shadow-sm border border-stone-100 text-sm text-stone-600 leading-relaxed max-w-[85%]"
|
||||
>
|
||||
<p>我已经整理了你最近 30 天的消费记录。</p>
|
||||
<div class="mt-3 bg-stone-50 rounded-xl p-3 border border-stone-100">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="ph-fill ph-chart-line text-orange-400" />
|
||||
<span class="font-bold text-stone-700 text-xs">本月摘要</span>
|
||||
</div>
|
||||
<p class="text-xs">
|
||||
餐饮与通勤是主要支出来源,夜间消费频率明显高于白天,预算消耗速度偏快。
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-3">下一步会支持自动分类、标签建议、异常支出提醒和自然语言问答。</p>
|
||||
</div>
|
||||
<i :class="['ph-fill', message.pending ? 'ph-circle-notch animate-spin' : 'ph-robot']" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex gap-3 flex-row-reverse">
|
||||
<div
|
||||
class="bg-stone-700 text-white p-4 rounded-2xl rounded-tr-none shadow-lg text-sm max-w-[85%]"
|
||||
:class="[
|
||||
'max-w-[85%] rounded-2xl p-4 text-sm leading-relaxed shadow-sm',
|
||||
message.role === 'user'
|
||||
? 'bg-stone-800 text-white rounded-tr-none'
|
||||
: 'bg-white border border-stone-100 text-stone-700 rounded-tl-none',
|
||||
]"
|
||||
>
|
||||
帮我总结这周花钱最多的三类项目。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-stone-100 flex items-center justify-center text-stone-400 mt-1 shrink-0"
|
||||
>
|
||||
<i class="ph-fill ph-robot" />
|
||||
</div>
|
||||
<div
|
||||
class="bg-white p-4 rounded-2xl rounded-tl-none shadow-sm border border-stone-100 text-sm text-stone-600 leading-relaxed max-w-[85%]"
|
||||
>
|
||||
<p>示例输出会像这样:</p>
|
||||
<ul class="list-disc list-inside text-xs space-y-1 marker:text-orange-400 mt-2">
|
||||
<li>餐饮:¥428,集中在晚餐和外卖。</li>
|
||||
<li>通勤:¥156,工作日为主。</li>
|
||||
<li>一般支出:¥98,主要是零碎网购和订阅扣费。</li>
|
||||
</ul>
|
||||
<p class="whitespace-pre-wrap">{{ message.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 relative shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入想分析的问题..."
|
||||
class="w-full pl-5 pr-12 py-3.5 rounded-full bg-white border border-stone-100 focus:outline-none focus:border-orange-300 focus:ring-4 focus:ring-orange-50 transition text-sm shadow-sm text-stone-700 font-medium placeholder-stone-300"
|
||||
<div class="mt-4 shrink-0 space-y-3">
|
||||
<div class="flex gap-2 overflow-x-auto hide-scrollbar pb-1">
|
||||
<button
|
||||
v-for="item in quickPrompts"
|
||||
:key="item"
|
||||
class="shrink-0 rounded-full bg-white border border-stone-100 px-3 py-2 text-xs font-bold text-stone-600 shadow-sm"
|
||||
@click="handleQuickPrompt(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<textarea
|
||||
v-model="prompt"
|
||||
rows="2"
|
||||
placeholder="输入你的账本问题,例如:这周哪里花得最多?"
|
||||
class="w-full pl-5 pr-14 py-3.5 rounded-3xl bg-white border border-stone-100 focus:outline-none focus:border-orange-300 focus:ring-4 focus:ring-orange-50 transition text-sm shadow-sm text-stone-700 font-medium placeholder-stone-300 resize-none"
|
||||
@keydown="handleKeydown"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-2 top-2 w-9 h-9 bg-gradient-warm text-white rounded-full flex items-center justify-center hover:scale-105 transition shadow-md"
|
||||
class="absolute right-3 bottom-3 w-10 h-10 rounded-full flex items-center justify-center transition shadow-md"
|
||||
:class="
|
||||
canSend && aiReady
|
||||
? 'bg-gradient-warm text-white'
|
||||
: 'bg-stone-200 text-stone-400 cursor-not-allowed'
|
||||
"
|
||||
:disabled="!canSend || !aiReady"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<i class="ph-bold ph-arrow-up" />
|
||||
<i :class="['ph-bold', sending ? 'ph-circle-notch animate-spin' : 'ph-arrow-up']" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,6 +7,14 @@ import { useTransactionStore } from '../stores/transactions'
|
||||
import { useTransactionEntry } from '../composables/useTransactionEntry'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
import { getAiStatusMeta } from '../config/aiStatus.js'
|
||||
import {
|
||||
getEntryTypeLabel,
|
||||
getTransferSummary,
|
||||
isStoredValueAccountType,
|
||||
shouldCountAsExpense,
|
||||
shouldCountAsIncome,
|
||||
} from '../config/ledger.js'
|
||||
import { getCategoryLabel, getCategoryMeta } from '../config/transactionCategories.js'
|
||||
import { bootstrapApp } from '../services/appBootstrap.js'
|
||||
|
||||
@@ -14,8 +22,14 @@ const router = useRouter()
|
||||
const transactionStore = useTransactionStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const uiStore = useUiStore()
|
||||
const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions, sortedTransactions } =
|
||||
storeToRefs(transactionStore)
|
||||
const {
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
todaysIncome,
|
||||
todaysExpense,
|
||||
latestTransactions,
|
||||
sortedTransactions,
|
||||
} = storeToRefs(transactionStore)
|
||||
const { notifications, confirmNotification, dismissNotification, processingId, syncNotifications } =
|
||||
useTransactionEntry()
|
||||
|
||||
@@ -27,7 +41,6 @@ onMounted(async () => {
|
||||
await syncNotifications()
|
||||
|
||||
try {
|
||||
// App 回到前台时,刷新本地账本和待处理通知。
|
||||
appStateListener = await App.addListener('appStateChange', async ({ isActive }) => {
|
||||
if (isActive) {
|
||||
await transactionStore.hydrateTransactions()
|
||||
@@ -88,11 +101,33 @@ const periodExpense = computed(() => {
|
||||
if (cycle === 'none' || !start) return totalExpense.value || 0
|
||||
|
||||
return (sortedTransactions.value || []).reduce((sum, tx) => {
|
||||
if (tx.amount >= 0) return sum
|
||||
if (tx.amount >= 0 || tx.impactExpense === false) return sum
|
||||
return new Date(tx.date) >= start ? sum + tx.amount : sum
|
||||
}, 0)
|
||||
})
|
||||
|
||||
const currentMonthIncome = computed(() =>
|
||||
(sortedTransactions.value || []).reduce((sum, tx) => {
|
||||
const date = new Date(tx.date)
|
||||
const now = new Date()
|
||||
if (date.getFullYear() !== now.getFullYear() || date.getMonth() !== now.getMonth()) return sum
|
||||
return shouldCountAsIncome(tx) ? sum + tx.amount : sum
|
||||
}, 0),
|
||||
)
|
||||
|
||||
const currentMonthExpense = computed(() =>
|
||||
Math.abs(
|
||||
(sortedTransactions.value || []).reduce((sum, tx) => {
|
||||
const date = new Date(tx.date)
|
||||
const now = new Date()
|
||||
if (date.getFullYear() !== now.getFullYear() || date.getMonth() !== now.getMonth()) return sum
|
||||
return shouldCountAsExpense(tx) ? sum + tx.amount : sum
|
||||
}, 0),
|
||||
),
|
||||
)
|
||||
|
||||
const currentMonthNet = computed(() => currentMonthIncome.value - currentMonthExpense.value)
|
||||
|
||||
const budgetUsage = computed(() => {
|
||||
const expense = Math.abs(periodExpense.value || 0)
|
||||
const budget = monthlyBudget.value || 0
|
||||
@@ -100,7 +135,6 @@ const budgetUsage = computed(() => {
|
||||
return Math.min(expense / budget, 1)
|
||||
})
|
||||
|
||||
const balance = computed(() => (totalIncome.value || 0) + (totalExpense.value || 0))
|
||||
const remainingBudget = computed(() =>
|
||||
Math.max((monthlyBudget.value || 0) - Math.abs(periodExpense.value || 0), 0),
|
||||
)
|
||||
@@ -123,6 +157,55 @@ const formatTime = (value) =>
|
||||
new Date(value),
|
||||
)
|
||||
|
||||
const resolveAiBadge = (transaction) => {
|
||||
if (!transaction?.aiStatus || ['idle', 'failed'].includes(transaction.aiStatus)) {
|
||||
return null
|
||||
}
|
||||
return getAiStatusMeta(transaction.aiStatus)
|
||||
}
|
||||
|
||||
const resolveLedgerBadge = (transaction) => {
|
||||
if (transaction.entryType === 'transfer') {
|
||||
return {
|
||||
label: getEntryTypeLabel(transaction.entryType),
|
||||
icon: 'ph-arrows-left-right',
|
||||
className: 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
||||
}
|
||||
}
|
||||
if (isStoredValueAccountType(transaction.fundSourceType)) {
|
||||
return {
|
||||
label: '储值支付',
|
||||
icon: 'ph-wallet',
|
||||
className: 'bg-teal-50 text-teal-700 border-teal-200',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const formatLedgerHint = (transaction) => {
|
||||
if (transaction.entryType === 'transfer') {
|
||||
return getTransferSummary(transaction)
|
||||
}
|
||||
if (isStoredValueAccountType(transaction.fundSourceType)) {
|
||||
return `从${transaction.fundSourceName || '储值账户'}扣款`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatAiHint = (transaction) => {
|
||||
if (transaction.aiStatus === 'running') {
|
||||
return '正在补全分类与标签'
|
||||
}
|
||||
if (transaction.aiStatus === 'suggested' && transaction.aiCategory) {
|
||||
return `建议分类:${getCategoryLabel(transaction.aiCategory)}`
|
||||
}
|
||||
if (transaction.aiStatus === 'applied') {
|
||||
const tags = (transaction.aiTags || []).slice(0, 2).join(' · ')
|
||||
return tags ? `已补全:${tags}` : '已完成自动补全'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const goSettings = () => {
|
||||
router.push({ name: 'settings' })
|
||||
}
|
||||
@@ -131,10 +214,6 @@ const goList = () => {
|
||||
router.push({ name: 'list' })
|
||||
}
|
||||
|
||||
const goAnalysis = () => {
|
||||
router.push({ name: 'analysis' })
|
||||
}
|
||||
|
||||
const openTransactionDetail = (tx) => {
|
||||
if (!tx?.id) return
|
||||
uiStore.openAddEntry(tx.id)
|
||||
@@ -163,38 +242,37 @@ const openTransactionDetail = (tx) => {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-full h-48 rounded-3xl bg-gradient-warm text-white p-6 shadow-xl shadow-orange-200/50 relative overflow-hidden group transition-all hover:shadow-orange-300/50"
|
||||
class="w-full rounded-[32px] bg-gradient-warm text-white p-6 shadow-xl shadow-orange-200/50 relative overflow-hidden"
|
||||
>
|
||||
<div class="absolute -right-10 -top-10 w-40 h-40 bg-white opacity-10 rounded-full blur-2xl" />
|
||||
<div class="absolute left-0 bottom-0 w-full h-1/2 bg-gradient-to-t from-black/10 to-transparent" />
|
||||
|
||||
<div class="relative z-10 flex flex-col h-full justify-between">
|
||||
<div class="relative z-10 space-y-5">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-white/80 text-xs font-bold tracking-widest uppercase">当前结余</p>
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center backdrop-blur-md hover:bg-white/30 transition"
|
||||
>
|
||||
<i class="ph-bold ph-eye" />
|
||||
</button>
|
||||
<div>
|
||||
<p class="text-white/80 text-xs font-bold tracking-widest uppercase">本月净结余</p>
|
||||
<p class="text-[11px] text-white/70 mt-1">仅统计本月已入账的收入和支出</p>
|
||||
</div>
|
||||
<h2 class="text-4xl font-extrabold mt-2">
|
||||
{{ formatCurrency(balance) }}
|
||||
<span class="px-3 py-1 rounded-full bg-white/15 text-[11px] font-bold">本月口径</span>
|
||||
</div>
|
||||
<h2 class="text-4xl font-extrabold mt-3">
|
||||
{{ currentMonthNet >= 0 ? '' : '-' }}{{ formatCurrency(currentMonthNet) }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="rounded-2xl bg-white/10 px-4 py-3 backdrop-blur-sm">
|
||||
<p class="text-white/70 text-xs mb-0.5 flex items-center gap-1">
|
||||
<i class="ph-bold ph-arrow-down-left" /> 收入
|
||||
</p>
|
||||
<p class="font-bold text-lg">{{ formatCurrency(totalIncome) }}</p>
|
||||
<p class="font-bold text-lg">{{ formatCurrency(currentMonthIncome) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="rounded-2xl bg-white/10 px-4 py-3 backdrop-blur-sm">
|
||||
<p class="text-white/70 text-xs mb-0.5 flex items-center gap-1">
|
||||
<i class="ph-bold ph-arrow-up-right" /> 支出
|
||||
</p>
|
||||
<p class="font-bold text-lg">{{ formatCurrency(Math.abs(totalExpense)) }}</p>
|
||||
<p class="font-bold text-lg">{{ formatCurrency(currentMonthExpense) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,33 +335,65 @@ 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>
|
||||
<p class="font-bold text-stone-800 text-sm">{{ tx.merchant }}</p>
|
||||
<p class="text-[11px] text-stone-400">
|
||||
{{ formatTime(tx.date) }} 路 {{ getCategoryLabel(tx.category) }}
|
||||
<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"
|
||||
:class="resolveLedgerBadge(tx).className"
|
||||
>
|
||||
<i :class="['ph-bold text-[10px]', resolveLedgerBadge(tx).icon]" />
|
||||
{{ resolveLedgerBadge(tx).label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="resolveAiBadge(tx)"
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-bold"
|
||||
:class="resolveAiBadge(tx).className"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'ph-bold text-[10px]',
|
||||
resolveAiBadge(tx).icon,
|
||||
resolveAiBadge(tx).iconClassName,
|
||||
]"
|
||||
/>
|
||||
{{ resolveAiBadge(tx).label }}
|
||||
</span>
|
||||
</div>
|
||||
<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-[11px] leading-5 text-stone-400">
|
||||
{{ formatLedgerHint(tx) }}
|
||||
</p>
|
||||
<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',
|
||||
]"
|
||||
>
|
||||
@@ -317,7 +427,7 @@ const openTransactionDetail = (tx) => {
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold text-stone-800 text-sm">
|
||||
{{ notif.channel }} 路 {{ formatTime(notif.createdAt) }}
|
||||
{{ notif.channel }} · {{ formatTime(notif.createdAt) }}
|
||||
</h4>
|
||||
<p class="text-xs text-stone-400 leading-relaxed">
|
||||
{{ notif.text }}
|
||||
@@ -345,22 +455,5 @@ const openTransactionDetail = (tx) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white p-5 rounded-3xl shadow-sm border border-stone-100 flex flex-col justify-between h-32 relative overflow-hidden group"
|
||||
@click="goAnalysis"
|
||||
>
|
||||
<div
|
||||
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-purple-50 rounded-full blur-xl group-hover:bg-purple-100 transition"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center z-10"
|
||||
>
|
||||
<i class="ph-fill ph-robot text-xl" />
|
||||
</div>
|
||||
<div class="z-10">
|
||||
<h4 class="font-bold text-stone-700">AI 财务顾问</h4>
|
||||
<p class="text-xs text-stone-400 mt-1">查看消费总结、自动分类建议和可执行的预算提醒。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { getAiStatusMeta } from '../config/aiStatus.js'
|
||||
import {
|
||||
getEntryTypeLabel,
|
||||
getTransferSummary,
|
||||
isStoredValueAccountType,
|
||||
} from '../config/ledger.js'
|
||||
import { CATEGORY_CHIPS, getCategoryLabel } from '../config/transactionCategories.js'
|
||||
import { useTransactionStore } from '../stores/transactions'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
@@ -19,14 +25,62 @@ const toggleTodayOnly = () => {
|
||||
filters.value.showOnlyToday = !filters.value.showOnlyToday
|
||||
}
|
||||
|
||||
const formatAmount = (value) =>
|
||||
`${value >= 0 ? '+' : '-'} 楼${Math.abs(value || 0).toFixed(2)}`
|
||||
const formatAmount = (value) => `${value >= 0 ? '+' : '-'} ¥${Math.abs(value || 0).toFixed(2)}`
|
||||
|
||||
const formatTime = (value) =>
|
||||
new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(
|
||||
new Date(value),
|
||||
)
|
||||
|
||||
const resolveLedgerBadge = (transaction) => {
|
||||
if (transaction.entryType === 'transfer') {
|
||||
return {
|
||||
label: getEntryTypeLabel(transaction.entryType),
|
||||
icon: 'ph-arrows-left-right',
|
||||
className: 'bg-cyan-50 text-cyan-700 border-cyan-200',
|
||||
}
|
||||
}
|
||||
if (isStoredValueAccountType(transaction.fundSourceType)) {
|
||||
return {
|
||||
label: '储值支付',
|
||||
icon: 'ph-wallet',
|
||||
className: 'bg-teal-50 text-teal-700 border-teal-200',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const resolveAiBadge = (transaction) => {
|
||||
if (!transaction?.aiStatus || ['idle', 'failed'].includes(transaction.aiStatus)) {
|
||||
return null
|
||||
}
|
||||
return getAiStatusMeta(transaction.aiStatus)
|
||||
}
|
||||
|
||||
const formatLedgerHint = (transaction) => {
|
||||
if (transaction.entryType === 'transfer') {
|
||||
return getTransferSummary(transaction)
|
||||
}
|
||||
if (isStoredValueAccountType(transaction.fundSourceType)) {
|
||||
return `从${transaction.fundSourceName || '储值账户'}扣款`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatAiHint = (transaction) => {
|
||||
if (transaction.aiStatus === 'running') {
|
||||
return '正在补全分类与标签'
|
||||
}
|
||||
if (transaction.aiStatus === 'suggested' && transaction.aiCategory) {
|
||||
return `建议分类:${getCategoryLabel(transaction.aiCategory)}`
|
||||
}
|
||||
if (transaction.aiStatus === 'applied') {
|
||||
const tags = (transaction.aiTags || []).slice(0, 2).join(' · ')
|
||||
return tags ? `已补全:${tags}` : '已完成自动补全'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const handleEdit = (item) => {
|
||||
uiStore.openAddEntry(item.id)
|
||||
}
|
||||
@@ -48,38 +102,38 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
|
||||
|
||||
<template>
|
||||
<div class="space-y-5 pb-10 animate-fade-in">
|
||||
<div class="sticky top-0 z-20 bg-warmOffwhite/95 backdrop-blur-sm py-2">
|
||||
<div class="sticky top-0 z-20 bg-warmOffwhite/95 backdrop-blur-sm py-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-extrabold text-stone-800">账单明细</h2>
|
||||
<button
|
||||
class="flex items-center gap-2 bg-white border border-stone-100 px-3 py-1.5 rounded-full shadow-sm text-xs font-bold text-stone-500"
|
||||
class="flex items-center gap-2 bg-white border border-stone-100 px-3.5 py-2 rounded-full shadow-sm text-xs font-bold text-stone-500"
|
||||
@click="uiStore.openAddEntry()"
|
||||
>
|
||||
<i class="ph-bold ph-plus" /> 新增
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 hide-scrollbar">
|
||||
<div class="flex gap-3 overflow-x-auto pb-3 hide-scrollbar">
|
||||
<button
|
||||
v-for="chip in categoryChips"
|
||||
:key="chip.value"
|
||||
class="px-3 py-1.5 rounded-full text-xs font-bold flex items-center gap-1 border transition"
|
||||
class="shrink-0 min-h-[44px] px-4 py-2.5 rounded-[22px] text-sm font-bold inline-flex items-center justify-center gap-2 border transition whitespace-nowrap leading-none"
|
||||
:class="
|
||||
filters.category === chip.value
|
||||
? 'bg-gradient-warm text-white border-transparent shadow-sm'
|
||||
? 'bg-gradient-warm text-white border-transparent shadow-sm shadow-orange-200/40'
|
||||
: 'bg-white text-stone-500 border-stone-100'
|
||||
"
|
||||
@click="setCategory(chip.value)"
|
||||
>
|
||||
<i :class="['text-sm', chip.icon]" />
|
||||
{{ chip.label }}
|
||||
<i :class="['text-base leading-none', chip.icon]" />
|
||||
<span class="inline-flex items-center leading-none">{{ chip.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-[11px] text-stone-400 font-bold px-1">
|
||||
<span>长按一条记录可快速编辑</span>
|
||||
<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"
|
||||
>
|
||||
@@ -103,22 +157,22 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-stone-500">{{ group.label }}</h4>
|
||||
<p class="text-[10px] text-stone-400">
|
||||
支出 楼{{ group.totalExpense.toFixed(2) }}
|
||||
支出 ¥{{ group.totalExpense.toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
<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="[
|
||||
@@ -127,32 +181,62 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<p class="text-xs text-stone-400 mt-0.5">
|
||||
<span
|
||||
v-if="resolveLedgerBadge(item)"
|
||||
class="inline-flex items-center justify-center gap-1 px-2 py-1 rounded-full border text-[10px] font-bold leading-none"
|
||||
:class="resolveLedgerBadge(item).className"
|
||||
>
|
||||
<i :class="['ph-bold text-[10px] leading-none', resolveLedgerBadge(item).icon]" />
|
||||
<span class="inline-flex items-center leading-none">{{ resolveLedgerBadge(item).label }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="resolveAiBadge(item)"
|
||||
class="inline-flex items-center justify-center gap-1 px-2 py-1 rounded-full border text-[10px] font-bold leading-none"
|
||||
:class="resolveAiBadge(item).className"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'ph-bold text-[10px] leading-none',
|
||||
resolveAiBadge(item).icon,
|
||||
resolveAiBadge(item).iconClassName,
|
||||
]"
|
||||
/>
|
||||
<span class="inline-flex items-center leading-none">{{ resolveAiBadge(item).label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs leading-5 text-stone-400">
|
||||
{{ formatTime(item.date) }}
|
||||
<span v-if="item.category" class="text-stone-300">
|
||||
路 {{ getCategoryLabel(item.category) }}
|
||||
· {{ getCategoryLabel(item.category) }}
|
||||
</span>
|
||||
<span v-if="item.note" class="text-stone-300">
|
||||
路 {{ item.note }}
|
||||
<span v-if="item.note" class="text-stone-300 break-words">
|
||||
· {{ item.note }}
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="formatLedgerHint(item)" class="text-[11px] leading-5 text-stone-400">
|
||||
{{ formatLedgerHint(item) }}
|
||||
</p>
|
||||
<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)"
|
||||
>
|
||||
|
||||
@@ -2,16 +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,
|
||||
@@ -23,6 +34,24 @@ const aiAutoCategoryEnabled = computed({
|
||||
set: (value) => settingsStore.setAiAutoCategoryEnabled(value),
|
||||
})
|
||||
|
||||
const aiApiKey = computed({
|
||||
get: () => settingsStore.aiApiKey,
|
||||
set: (value) => settingsStore.setAiApiKey(value),
|
||||
})
|
||||
|
||||
const aiAutoApplyThreshold = computed({
|
||||
get: () => String(settingsStore.aiAutoApplyThreshold ?? 0.9),
|
||||
set: (value) => settingsStore.setAiAutoApplyThreshold(value),
|
||||
})
|
||||
|
||||
const currentAiProviderLabel = computed(
|
||||
() => AI_PROVIDER_OPTIONS.find((item) => item.value === settingsStore.aiProvider)?.label || 'DeepSeek',
|
||||
)
|
||||
|
||||
const currentAiModelLabel = computed(
|
||||
() => AI_MODEL_OPTIONS.find((item) => item.value === settingsStore.aiModel)?.label || 'deepseek-chat',
|
||||
)
|
||||
|
||||
const nativeBridgeReady = isNativeNotificationBridgeAvailable()
|
||||
const notificationPermissionGranted = ref(true)
|
||||
const checkingPermission = ref(false)
|
||||
@@ -101,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
|
||||
@@ -117,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 = () => {
|
||||
@@ -167,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">
|
||||
@@ -373,7 +459,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 自动分类开关(预留) -->
|
||||
<!-- AI 自动分类与标签 -->
|
||||
<div class="flex flex-col gap-1 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -383,9 +469,9 @@ onMounted(() => {
|
||||
<i class="ph-fill ph-magic-wand" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-stone-700 text-sm">AI 自动分类与标签(预留)</p>
|
||||
<p class="font-bold text-stone-700 text-sm">AI 自动分类与标签</p>
|
||||
<p class="text-[11px] text-stone-400 mt-0.5">
|
||||
开启后,未来会自动补全分类、标签,并生成可复用的商户画像。
|
||||
使用本地 DeepSeek API Key 为新交易补全分类、标签,并记录商户画像。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,11 +483,44 @@ onMounted(() => {
|
||||
<div class="w-4 h-4 bg-white rounded-full shadow-sm" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="aiAutoCategoryEnabled" class="px-4 pb-1 text-[11px] text-purple-600">
|
||||
当前版本仅记录偏好,后续接入 AI 能力后会自动生效。
|
||||
|
||||
<div v-if="aiAutoCategoryEnabled" class="px-4 pt-3 pb-1 space-y-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="px-3 py-1 rounded-full bg-stone-100 text-[11px] font-bold text-stone-600">
|
||||
Provider · {{ currentAiProviderLabel }}
|
||||
</span>
|
||||
<span class="px-3 py-1 rounded-full bg-stone-100 text-[11px] font-bold text-stone-600">
|
||||
Model · {{ currentAiModelLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<EchoInput
|
||||
v-model="aiApiKey"
|
||||
label="DeepSeek API Key"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
hint="仅保存在当前设备,本阶段用于本地直连 DeepSeek。"
|
||||
/>
|
||||
|
||||
<EchoInput
|
||||
v-model="aiAutoApplyThreshold"
|
||||
label="自动应用阈值(0-1)"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
step="0.05"
|
||||
placeholder="例如 0.9"
|
||||
hint="高于该阈值的 AI 分类会自动写入交易分类,低于阈值仅作为建议保留。"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!settingsStore.aiApiKey"
|
||||
class="text-[11px] text-amber-600 bg-amber-50 border border-amber-200 rounded-2xl px-3 py-2"
|
||||
>
|
||||
当前已开启 AI,但还没有填写 DeepSeek API Key,保存交易时不会发起 AI 分类请求。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 通用设置 -->
|
||||
@@ -411,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
|
||||
@@ -421,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"
|
||||
|
||||
Reference in New Issue
Block a user