feat:新增ai分析,新增储值账户,优化通知规则

This commit is contained in:
2026-03-12 14:03:01 +08:00
parent 6a00875246
commit 6ca962a187
22 changed files with 1294 additions and 237 deletions

View File

@@ -1,24 +1,22 @@
package com.echo.app.notification package com.echo.app.notification
import android.content.Context import android.content.Context
import android.database.Cursor
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import android.util.Log import android.util.Log
import java.util.UUID import java.util.UUID
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock import kotlin.concurrent.withLock
import kotlin.math.abs
// 原生 SQLite 访问封装:与 @capacitor-community/sqlite 共用同一个 echo_local 数据库 // Native SQLite access shared with @capacitor-community/sqlite.
object NotificationDb { object NotificationDb {
private const val TAG = "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_FILE_NAME = "echo_localSQLite.db"
private const val DB_VERSION = 1 private const val DB_VERSION = 1
// transactions 表结构需与 src/lib/sqlite.js 保持完全一致
private const val SQL_CREATE_TRANSACTIONS = """ private const val SQL_CREATE_TRANSACTIONS = """
CREATE TABLE IF NOT EXISTS transactions ( CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY NOT NULL, id TEXT PRIMARY KEY NOT NULL,
@@ -27,11 +25,24 @@ object NotificationDb {
category TEXT DEFAULT 'Uncategorized', category TEXT DEFAULT 'Uncategorized',
date TEXT NOT NULL, date TEXT NOT NULL,
note TEXT, 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 = """ private const val SQL_CREATE_NOTIFICATIONS_RAW = """
CREATE TABLE IF NOT EXISTS notifications_raw ( CREATE TABLE IF NOT EXISTS notifications_raw (
id TEXT PRIMARY KEY NOT NULL, 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) : private class EchoDbHelper(context: Context) :
SQLiteOpenHelper(context, DB_FILE_NAME, null, DB_VERSION) { SQLiteOpenHelper(context, DB_FILE_NAME, null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) { override fun onCreate(db: SQLiteDatabase) {
// 正常情况下,数据表由前端首次启动时创建;此处作为兜底
db.execSQL(SQL_CREATE_TRANSACTIONS) db.execSQL(SQL_CREATE_TRANSACTIONS)
db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW) db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW)
} }
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { 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 var schemaInitialized = false
private fun getHelper(context: Context): EchoDbHelper { private fun getHelper(context: Context): EchoDbHelper {
// 使用 applicationContext 避免泄漏 Activity
val appContext = context.applicationContext val appContext = context.applicationContext
return helper ?: synchronized(this) { return helper ?: synchronized(this) {
helper ?: EchoDbHelper(appContext).also { helper = it } 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) { private fun ensureSchema(db: SQLiteDatabase) {
if (schemaInitialized) return if (schemaInitialized) return
// 多线程下可能执行多次,但都是幂等的 IF NOT EXISTS性能成本极低
db.execSQL(SQL_CREATE_TRANSACTIONS) db.execSQL(SQL_CREATE_TRANSACTIONS)
db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW) db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW)
ensureTableColumns(db, "transactions", TRANSACTION_REQUIRED_COLUMNS)
schemaInitialized = true schemaInitialized = true
} }
// 用于规则引擎输入的原始通知模型
data class RawNotification( data class RawNotification(
val sourceId: String, val sourceId: String,
val packageName: String, val packageName: String,
@@ -94,30 +135,19 @@ object NotificationDb {
val postedAt: String 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) { fun processAndStoreNotification(context: Context, raw: RawNotification) {
lock.withLock { lock.withLock {
val db = getHelper(context).writableDatabase val db = getHelper(context).writableDatabase
ensureSchema(db) ensureSchema(db)
val rawId = UUID.randomUUID().toString() val rawId = UUID.randomUUID().toString()
var status = "unmatched" var status = "unmatched"
var ruleId: String? = null var ruleId: String? = null
var transactionId: String? = null var transactionId: String? = null
var errorMessage: String? = null var errorMessage: String? = null
db.beginTransaction() db.beginTransaction()
try { try {
// 写入 notifications_raw初始 unmatched
db.compileStatement( db.compileStatement(
""" """
INSERT INTO notifications_raw ( INSERT INTO notifications_raw (
@@ -127,16 +157,8 @@ object NotificationDb {
).apply { ).apply {
bindString(1, rawId) bindString(1, rawId)
bindString(2, raw.sourceId) bindString(2, raw.sourceId)
if (raw.channel != null) { if (raw.channel != null) bindString(3, raw.channel) else bindNull(3)
bindString(3, raw.channel) if (raw.title != null) bindString(4, raw.title) else bindNull(4)
} else {
bindNull(3)
}
if (raw.title != null) {
bindString(4, raw.title)
} else {
bindNull(4)
}
bindString(5, raw.text) bindString(5, raw.text)
bindString(6, raw.postedAt) bindString(6, raw.postedAt)
bindString(7, status) bindString(7, status)
@@ -144,34 +166,37 @@ object NotificationDb {
close() close()
} }
// 调用规则引擎
val parsed = NotificationRuleEngine.parse(raw) val parsed = NotificationRuleEngine.parse(raw)
if (parsed != null) { if (parsed != null) {
ruleId = parsed.ruleId ruleId = parsed.ruleId
// 与 JS 侧逻辑保持一致:
// requiresConfirmation = !(autoCapture && amount!=0 && merchant!='Unknown')
val hasAmount = parsed.amount != 0.0 val hasAmount = parsed.amount != 0.0
val merchantKnown = val merchantKnown = parsed.merchant.isNotBlank() && parsed.merchant != "Unknown"
parsed.merchant.isNotBlank() && parsed.merchant != "Unknown"
val requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown) val requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown)
if (!requiresConfirmation) { if (!requiresConfirmation) {
// 统一按照设计文档:支出为负数,收入为正数 val signedAmount = if (parsed.isExpense) -abs(parsed.amount) else abs(parsed.amount)
val signedAmount = if (parsed.isExpense) {
-kotlin.math.abs(parsed.amount)
} else {
kotlin.math.abs(parsed.amount)
}
transactionId = UUID.randomUUID().toString() transactionId = UUID.randomUUID().toString()
status = "matched" status = "matched"
db.compileStatement( db.compileStatement(
""" """
INSERT INTO transactions ( INSERT INTO transactions (
id, amount, merchant, category, date, note, sync_status id,
) VALUES (?, ?, ?, ?, ?, ?, ?) 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() """.trimIndent()
).apply { ).apply {
bindString(1, transactionId) bindString(1, transactionId)
@@ -179,23 +204,21 @@ object NotificationDb {
bindString(3, parsed.merchant) bindString(3, parsed.merchant)
bindString(4, parsed.category) bindString(4, parsed.category)
bindString(5, raw.postedAt) bindString(5, raw.postedAt)
if (parsed.note != null) { if (parsed.note != null) bindString(6, parsed.note) else bindNull(6)
bindString(6, parsed.note)
} else {
bindNull(6)
}
// sync_status: 0 = pending本地生成尚未同步
bindLong(7, 0L) 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() executeInsert()
close() close()
} }
} else {
// 保持 status = 'unmatched',但记录命中的 rule_id方便前端调试视图展示“有推荐规则”
status = "unmatched"
} }
} }
// 根据结果更新 notifications_raw 状态
db.compileStatement( db.compileStatement(
""" """
UPDATE notifications_raw UPDATE notifications_raw
@@ -204,21 +227,9 @@ object NotificationDb {
""".trimIndent() """.trimIndent()
).apply { ).apply {
bindString(1, status) bindString(1, status)
if (ruleId != null) { if (ruleId != null) bindString(2, ruleId) else bindNull(2)
bindString(2, ruleId) if (transactionId != null) bindString(3, transactionId) else bindNull(3)
} else { if (errorMessage != null) bindString(4, errorMessage) else bindNull(4)
bindNull(2)
}
if (transactionId != null) {
bindString(3, transactionId)
} else {
bindNull(3)
}
if (errorMessage != null) {
bindString(4, errorMessage)
} else {
bindNull(4)
}
bindString(5, rawId) bindString(5, rawId)
executeUpdateDelete() executeUpdateDelete()
close() close()
@@ -226,8 +237,7 @@ object NotificationDb {
db.setTransactionSuccessful() db.setTransactionSuccessful()
} catch (e: Exception) { } catch (e: Exception) {
// 若解析阶段抛异常,尽量在同一事务中回写 error 状态 Log.e(TAG, "processAndStoreNotification failed", e)
Log.e(TAG, "处理通知并写入 SQLite 失败", e)
status = "error" status = "error"
errorMessage = e.message ?: e.toString() errorMessage = e.message ?: e.toString()
try { try {
@@ -239,13 +249,13 @@ object NotificationDb {
""".trimIndent() """.trimIndent()
).apply { ).apply {
bindString(1, status) bindString(1, status)
bindString(2, errorMessage!!) bindString(2, errorMessage)
bindString(3, rawId) bindString(3, rawId)
executeUpdateDelete() executeUpdateDelete()
close() close()
} }
} catch (inner: Exception) { } catch (inner: Exception) {
Log.e(TAG, "更新 notifications_raw 错误状态失败", inner) Log.e(TAG, "failed to update notifications_raw error state", inner)
} }
} finally { } finally {
db.endTransaction() db.endTransaction()

View File

@@ -10,6 +10,15 @@ internal object NotificationRuleData {
keywords = listOf("支付", "扣款", "支出", "消费", "成功支付"), keywords = listOf("支付", "扣款", "支出", "消费", "成功支付"),
requiredTextPatterns = emptyList(), requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.EXPENSE, direction = NotificationRuleDirection.EXPENSE,
entryType = "expense",
fundSourceType = "cash",
fundSourceName = "支付宝余额",
fundSourceNameFromChannel = false,
fundTargetType = "merchant",
fundTargetName = null,
fundTargetNameFromChannel = false,
impactExpense = true,
impactIncome = false,
defaultCategory = "Food", defaultCategory = "Food",
autoCapture = false, autoCapture = false,
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)), merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
@@ -22,6 +31,15 @@ internal object NotificationRuleData {
keywords = listOf("到账", "收入", "收款", "入账"), keywords = listOf("到账", "收入", "收款", "入账"),
requiredTextPatterns = emptyList(), requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.INCOME, direction = NotificationRuleDirection.INCOME,
entryType = "income",
fundSourceType = "external",
fundSourceName = "外部转入",
fundSourceNameFromChannel = false,
fundTargetType = "cash",
fundTargetName = "支付宝余额",
fundTargetNameFromChannel = false,
impactExpense = false,
impactIncome = true,
defaultCategory = "Income", defaultCategory = "Income",
autoCapture = true, autoCapture = true,
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)), merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
@@ -34,6 +52,15 @@ internal object NotificationRuleData {
keywords = listOf("支付", "支出", "扣款", "消费成功"), keywords = listOf("支付", "支出", "扣款", "消费成功"),
requiredTextPatterns = emptyList(), requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.EXPENSE, direction = NotificationRuleDirection.EXPENSE,
entryType = "expense",
fundSourceType = "cash",
fundSourceName = "微信余额",
fundSourceNameFromChannel = false,
fundTargetType = "merchant",
fundTargetName = null,
fundTargetNameFromChannel = false,
impactExpense = true,
impactIncome = false,
defaultCategory = "Groceries", defaultCategory = "Groceries",
autoCapture = false, autoCapture = false,
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"), merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
@@ -46,6 +73,15 @@ internal object NotificationRuleData {
keywords = listOf("收款", "到账", "入账", "转入"), keywords = listOf("收款", "到账", "入账", "转入"),
requiredTextPatterns = emptyList(), requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.INCOME, direction = NotificationRuleDirection.INCOME,
entryType = "income",
fundSourceType = "external",
fundSourceName = "外部转入",
fundSourceNameFromChannel = false,
fundTargetType = "cash",
fundTargetName = "微信余额",
fundTargetNameFromChannel = false,
impactExpense = false,
impactIncome = true,
defaultCategory = "Income", defaultCategory = "Income",
autoCapture = true, autoCapture = true,
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"), merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
@@ -58,6 +94,15 @@ internal object NotificationRuleData {
keywords = listOf("工资", "薪资", "代发", "到账", "入账", "收入"), keywords = listOf("工资", "薪资", "代发", "到账", "入账", "收入"),
requiredTextPatterns = emptyList(), requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.INCOME, direction = NotificationRuleDirection.INCOME,
entryType = "income",
fundSourceType = "external",
fundSourceName = "外部转入",
fundSourceNameFromChannel = false,
fundTargetType = "bank",
fundTargetName = "银行卡",
fundTargetNameFromChannel = false,
impactExpense = false,
impactIncome = true,
defaultCategory = "Income", defaultCategory = "Income",
autoCapture = true, autoCapture = true,
merchantPattern = Regex("(?:来自|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"), merchantPattern = Regex("(?:来自|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
@@ -70,11 +115,62 @@ internal object NotificationRuleData {
keywords = listOf("支出", "消费", "扣款", "刷卡消费", "银联消费"), keywords = listOf("支出", "消费", "扣款", "刷卡消费", "银联消费"),
requiredTextPatterns = emptyList(), requiredTextPatterns = emptyList(),
direction = NotificationRuleDirection.EXPENSE, direction = NotificationRuleDirection.EXPENSE,
entryType = "expense",
fundSourceType = "bank",
fundSourceName = "银行卡",
fundSourceNameFromChannel = false,
fundTargetType = "merchant",
fundTargetName = null,
fundTargetNameFromChannel = false,
impactExpense = true,
impactIncome = false,
defaultCategory = "Expense", defaultCategory = "Expense",
autoCapture = false, autoCapture = false,
merchantPattern = Regex("(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"), merchantPattern = Regex("(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
defaultMerchant = null, 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( NotificationRuleDefinition(
id = "transit-card-expense", id = "transit-card-expense",
label = "公交出行", label = "公交出行",
@@ -82,6 +178,15 @@ internal object NotificationRuleData {
keywords = emptyList(), keywords = emptyList(),
requiredTextPatterns = listOf("扣费", "扣款", "支付", "乘车码", "车费", "票价", "本次乘车", "实付", "优惠后"), requiredTextPatterns = listOf("扣费", "扣款", "支付", "乘车码", "车费", "票价", "本次乘车", "实付", "优惠后"),
direction = NotificationRuleDirection.EXPENSE, direction = NotificationRuleDirection.EXPENSE,
entryType = "expense",
fundSourceType = "stored_value",
fundSourceName = null,
fundSourceNameFromChannel = true,
fundTargetType = "merchant",
fundTargetName = null,
fundTargetNameFromChannel = false,
impactExpense = true,
impactIncome = false,
defaultCategory = "Transport", defaultCategory = "Transport",
autoCapture = true, autoCapture = true,
merchantPattern = Regex("([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})"), merchantPattern = Regex("([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})"),

View File

@@ -14,6 +14,15 @@ internal data class NotificationRuleDefinition(
val keywords: List<String> = emptyList(), val keywords: List<String> = emptyList(),
val requiredTextPatterns: List<String> = emptyList(), val requiredTextPatterns: List<String> = emptyList(),
val direction: NotificationRuleDirection, 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 defaultCategory: String,
val autoCapture: Boolean, val autoCapture: Boolean,
val merchantPattern: Regex? = null, val merchantPattern: Regex? = null,
@@ -30,6 +39,13 @@ object NotificationRuleEngine {
val category: String, val category: String,
val amount: Double, val amount: Double,
val isExpense: Boolean, 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 note: String?,
val autoCapture: Boolean, val autoCapture: Boolean,
) )
@@ -60,11 +76,24 @@ object NotificationRuleEngine {
category = rule.defaultCategory.ifBlank { "Uncategorized" }, category = rule.defaultCategory.ifBlank { "Uncategorized" },
amount = amount, amount = amount,
isExpense = rule.direction == NotificationRuleDirection.EXPENSE, 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), note = content.take(200),
autoCapture = rule.autoCapture, 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( private fun matchRule(
raw: NotificationDb.RawNotification, raw: NotificationDb.RawNotification,
text: String, text: String,
@@ -80,17 +109,25 @@ object NotificationRuleEngine {
val keywordHit = val keywordHit =
rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) } rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) }
val requiredTextHit = 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 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 { private fun extractAmount(text: String): Double {
val normalized = text.replace(",", "").trim() val normalized = text.replace(",", "").trim()
val patterns = listOf( val patterns = listOf(
Regex("""(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)""", RegexOption.IGNORE_CASE), Regex("""(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)""", RegexOption.IGNORE_CASE),
Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)"""), 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*(?:已支付|已收款|到账|入账)"""), Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)"""),
) )

View File

@@ -25,6 +25,56 @@ const cases = [
verify(result) { verify(result) {
assert.equal(result.rule?.id, 'transit-card-expense') assert.equal(result.rule?.id, 'transit-card-expense')
assert.equal(result.transaction.amount, -2) 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, '杭州通互联互通卡')
}, },
}, },
{ {

View File

@@ -1,4 +1,4 @@
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import notificationRules from '../src/config/notificationRules.js' import notificationRules from '../src/config/notificationRules.js'
@@ -28,6 +28,8 @@ const toKotlinRegex = (spec) => {
return `Regex(${toKotlinString(spec.pattern)}${flags})` return `Regex(${toKotlinString(spec.pattern)}${flags})`
} }
const toOptionalString = (value) => (value ? toKotlinString(value) : 'null')
const toRuleBlock = (rule) => ` NotificationRuleDefinition( const toRuleBlock = (rule) => ` NotificationRuleDefinition(
id = ${toKotlinString(rule.id)}, id = ${toKotlinString(rule.id)},
label = ${toKotlinString(rule.label)}, label = ${toKotlinString(rule.label)},
@@ -35,12 +37,19 @@ const toRuleBlock = (rule) => ` NotificationRuleDefinition(
keywords = ${toKotlinStringList(rule.keywords)}, keywords = ${toKotlinStringList(rule.keywords)},
requiredTextPatterns = ${toKotlinStringList(rule.requiredTextPatterns)}, requiredTextPatterns = ${toKotlinStringList(rule.requiredTextPatterns)},
direction = NotificationRuleDirection.${rule.direction === 'income' ? 'INCOME' : 'EXPENSE'}, 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)}, defaultCategory = ${toKotlinString(rule.defaultCategory)},
autoCapture = ${rule.autoCapture ? 'true' : 'false'}, autoCapture = ${rule.autoCapture ? 'true' : 'false'},
merchantPattern = ${toKotlinRegex(rule.merchantPattern)}, merchantPattern = ${toKotlinRegex(rule.merchantPattern)},
defaultMerchant = ${ defaultMerchant = ${toOptionalString(rule.defaultMerchant)},
rule.defaultMerchant ? toKotlinString(rule.defaultMerchant) : 'null'
},
)` )`
const content = `package com.echo.app.notification const content = `package com.echo.app.notification

28
src/config/aiStatus.js Normal file
View 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
View 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}`
}

View File

@@ -1,4 +1,22 @@
const notificationRules = [ const transitStoredValueChannels = [
'杭州通互联互通卡',
'杭州通',
'北京一卡通',
'上海公共交通卡',
'深圳通',
'苏州公交卡',
'交通联合',
'乘车码',
'地铁',
'公交',
'Mi Pay',
'Huawei Pay',
'华为钱包',
'小米钱包',
'OPPO 钱包',
]
const notificationRules = [
{ {
id: 'alipay-expense', id: 'alipay-expense',
label: '支付宝消费', label: '支付宝消费',
@@ -6,6 +24,13 @@
keywords: ['支付', '扣款', '支出', '消费', '成功支付'], keywords: ['支付', '扣款', '支出', '消费', '成功支付'],
requiredTextPatterns: [], requiredTextPatterns: [],
direction: 'expense', direction: 'expense',
entryType: 'expense',
fundSourceType: 'cash',
fundSourceName: '支付宝余额',
fundTargetType: 'merchant',
fundTargetName: '',
impactExpense: true,
impactIncome: false,
defaultCategory: 'Food', defaultCategory: 'Food',
autoCapture: false, autoCapture: false,
merchantPattern: { merchantPattern: {
@@ -21,6 +46,13 @@
keywords: ['到账', '收入', '收款', '入账'], keywords: ['到账', '收入', '收款', '入账'],
requiredTextPatterns: [], requiredTextPatterns: [],
direction: 'income', direction: 'income',
entryType: 'income',
fundSourceType: 'external',
fundSourceName: '外部转入',
fundTargetType: 'cash',
fundTargetName: '支付宝余额',
impactExpense: false,
impactIncome: true,
defaultCategory: 'Income', defaultCategory: 'Income',
autoCapture: true, autoCapture: true,
merchantPattern: { merchantPattern: {
@@ -36,6 +68,13 @@
keywords: ['支付', '支出', '扣款', '消费成功'], keywords: ['支付', '支出', '扣款', '消费成功'],
requiredTextPatterns: [], requiredTextPatterns: [],
direction: 'expense', direction: 'expense',
entryType: 'expense',
fundSourceType: 'cash',
fundSourceName: '微信余额',
fundTargetType: 'merchant',
fundTargetName: '',
impactExpense: true,
impactIncome: false,
defaultCategory: 'Groceries', defaultCategory: 'Groceries',
autoCapture: false, autoCapture: false,
merchantPattern: { merchantPattern: {
@@ -51,6 +90,13 @@
keywords: ['收款', '到账', '入账', '转入'], keywords: ['收款', '到账', '入账', '转入'],
requiredTextPatterns: [], requiredTextPatterns: [],
direction: 'income', direction: 'income',
entryType: 'income',
fundSourceType: 'external',
fundSourceName: '外部转入',
fundTargetType: 'cash',
fundTargetName: '微信余额',
impactExpense: false,
impactIncome: true,
defaultCategory: 'Income', defaultCategory: 'Income',
autoCapture: true, autoCapture: true,
merchantPattern: { merchantPattern: {
@@ -79,6 +125,13 @@
keywords: ['工资', '薪资', '代发', '到账', '入账', '收入'], keywords: ['工资', '薪资', '代发', '到账', '入账', '收入'],
requiredTextPatterns: [], requiredTextPatterns: [],
direction: 'income', direction: 'income',
entryType: 'income',
fundSourceType: 'external',
fundSourceName: '外部转入',
fundTargetType: 'bank',
fundTargetName: '银行卡',
impactExpense: false,
impactIncome: true,
defaultCategory: 'Income', defaultCategory: 'Income',
autoCapture: true, autoCapture: true,
merchantPattern: { merchantPattern: {
@@ -109,6 +162,13 @@
keywords: ['支出', '消费', '扣款', '刷卡消费', '银联消费'], keywords: ['支出', '消费', '扣款', '刷卡消费', '银联消费'],
requiredTextPatterns: [], requiredTextPatterns: [],
direction: 'expense', direction: 'expense',
entryType: 'expense',
fundSourceType: 'bank',
fundSourceName: '银行卡',
fundTargetType: 'merchant',
fundTargetName: '',
impactExpense: true,
impactIncome: false,
defaultCategory: 'Expense', defaultCategory: 'Expense',
autoCapture: false, autoCapture: false,
merchantPattern: { merchantPattern: {
@@ -117,29 +177,61 @@
}, },
defaultMerchant: '', 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', id: 'transit-card-expense',
label: '公交出行', label: '公交出行',
channels: [ channels: transitStoredValueChannels,
'杭州通互联互通卡',
'杭州通',
'北京一卡通',
'上海公共交通卡',
'深圳通',
'苏州公交卡',
'交通联合',
'乘车码',
'地铁',
'公交',
'Mi Pay',
'Huawei Pay',
'华为钱包',
'小米钱包',
'OPPO 钱包',
],
keywords: [], keywords: [],
requiredTextPatterns: ['扣费', '扣款', '支付', '乘车码', '车费', '票价', '本次乘车', '实付', '优惠后'], requiredTextPatterns: ['扣费', '扣款', '支付', '乘车码', '车费', '票价', '本次乘车', '实付', '优惠后'],
direction: 'expense', direction: 'expense',
entryType: 'expense',
fundSourceType: 'stored_value',
fundSourceName: '',
fundSourceNameFromChannel: true,
fundTargetType: 'merchant',
fundTargetName: '',
impactExpense: true,
impactIncome: false,
defaultCategory: 'Transport', defaultCategory: 'Transport',
autoCapture: true, autoCapture: true,
merchantPattern: { merchantPattern: {

View File

@@ -47,6 +47,14 @@
color: 'text-stone-600', color: 'text-stone-600',
budgetable: true, budgetable: true,
}, },
{
value: 'Transfer',
label: '转账',
icon: 'ph-arrows-left-right',
bg: 'bg-cyan-100',
color: 'text-cyan-700',
budgetable: false,
},
{ {
value: 'Income', value: 'Income',
label: '收入', label: '收入',
@@ -84,7 +92,6 @@ export const DEFAULT_CATEGORY_BUDGETS = BUDGET_CATEGORY_OPTIONS.reduce((acc, cat
return acc return acc
}, {}) }, {})
export const getCategoryMeta = (category) => export const getCategoryMeta = (category) => CATEGORY_META[category] || CATEGORY_META.Uncategorized
CATEGORY_META[category] || CATEGORY_META.Uncategorized
export const getCategoryLabel = (category) => getCategoryMeta(category).label export const getCategoryLabel = (category) => getCategoryMeta(category).label

View File

@@ -1,8 +1,92 @@
import { TRANSACTION_CATEGORIES } from '../config/transactionCategories.js' import {
getCategoryLabel,
TRANSACTION_CATEGORIES,
} from '../config/transactionCategories.js'
import {
getTransferSummary,
shouldCountAsExpense,
shouldCountAsIncome,
} from '../config/ledger.js'
import { AI_TRANSACTION_TAGS } from '../config/transactionTags.js' import { AI_TRANSACTION_TAGS } from '../config/transactionTags.js'
const categoryValues = TRANSACTION_CATEGORIES.map((item) => item.value) 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 = [] }) => { export const buildTransactionEnrichmentMessages = ({ transaction, similarTransactions = [] }) => {
const payload = { const payload = {
transaction: { transaction: {
@@ -14,7 +98,7 @@ export const buildTransactionEnrichmentMessages = ({ transaction, similarTransac
currentCategory: transaction.category || 'Uncategorized', currentCategory: transaction.category || 'Uncategorized',
date: transaction.date, date: transaction.date,
}, },
categories: categoryValues, categories: categoryValues.filter((value) => value !== 'Transfer'),
tags: AI_TRANSACTION_TAGS, tags: AI_TRANSACTION_TAGS,
similarTransactions: similarTransactions.slice(0, 6).map((item) => ({ similarTransactions: similarTransactions.slice(0, 6).map((item) => ({
merchant: item.aiNormalizedMerchant || item.merchant, merchant: item.aiNormalizedMerchant || item.merchant,
@@ -37,9 +121,39 @@ export const buildTransactionEnrichmentMessages = ({ transaction, similarTransac
] ]
} }
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) => { export const normalizeEnrichmentResult = (rawResult) => {
const result = rawResult && typeof rawResult === 'object' ? rawResult : {} const result = rawResult && typeof rawResult === 'object' ? rawResult : {}
const category = categoryValues.includes(result.category) ? result.category : 'Uncategorized' const category = categoryValues.includes(result.category) && result.category !== 'Transfer'
? result.category
: 'Uncategorized'
const tags = Array.isArray(result.tags) const tags = Array.isArray(result.tags)
? result.tags.filter((tag) => AI_TRANSACTION_TAGS.includes(tag)).slice(0, 3) ? result.tags.filter((tag) => AI_TRANSACTION_TAGS.includes(tag)).slice(0, 3)
: [] : []

View File

@@ -13,6 +13,13 @@ const TRANSACTION_TABLE_SQL = `
date TEXT NOT NULL, date TEXT NOT NULL,
note TEXT, 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_category TEXT,
ai_tags TEXT, ai_tags TEXT,
ai_confidence REAL, ai_confidence REAL,
@@ -33,6 +40,13 @@ const MERCHANT_PROFILE_TABLE_SQL = `
); );
` `
const TRANSACTION_REQUIRED_COLUMNS = { 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_category: 'TEXT',
ai_tags: 'TEXT', ai_tags: 'TEXT',
ai_confidence: 'REAL', ai_confidence: 'REAL',

View File

@@ -1,9 +1,13 @@
import { buildTransactionEnrichmentMessages } from '../../lib/aiPrompt.js' import {
buildFinanceChatMessages,
buildTransactionEnrichmentMessages,
} from '../../lib/aiPrompt.js'
import { useSettingsStore } from '../../stores/settings.js' import { useSettingsStore } from '../../stores/settings.js'
import { import {
applyAiEnrichment, applyAiEnrichment,
fetchRecentTransactionsForAi, fetchRecentTransactionsForAi,
markTransactionAiFailed, markTransactionAiFailed,
markTransactionAiRunning,
} from '../transactionService.js' } from '../transactionService.js'
import { DeepSeekProvider } from './deepseekProvider.js' import { DeepSeekProvider } from './deepseekProvider.js'
@@ -24,11 +28,13 @@ const createProvider = (settingsStore) => {
}) })
} }
export const isAiReady = (settingsStore = useSettingsStore()) => export const hasAiAccess = (settingsStore = useSettingsStore()) => !!settingsStore.aiApiKey
!!settingsStore.aiAutoCategoryEnabled && !!settingsStore.aiApiKey
export const maybeEnrichTransactionWithAi = async (transaction) => { export const isAiReady = (settingsStore = useSettingsStore()) =>
if (!transaction?.id) return transaction !!settingsStore.aiAutoCategoryEnabled && hasAiAccess(settingsStore)
export const maybeEnrichTransactionWithAi = async (transaction, options = {}) => {
if (!transaction?.id || transaction.entryType === 'transfer') return transaction
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
if (!isAiReady(settingsStore)) { if (!isAiReady(settingsStore)) {
@@ -36,11 +42,16 @@ export const maybeEnrichTransactionWithAi = async (transaction) => {
} }
try { try {
const running = await markTransactionAiRunning(transaction.id, settingsStore.aiModel)
if (running) {
options.onProgress?.(running)
}
const provider = createProvider(settingsStore) const provider = createProvider(settingsStore)
const similarTransactions = await fetchRecentTransactionsForAi(6, transaction.id) const similarTransactions = await fetchRecentTransactionsForAi(6, transaction.id)
const messages = buildTransactionEnrichmentMessages({ const messages = buildTransactionEnrichmentMessages({
transaction, transaction,
similarTransactions, similarTransactions: similarTransactions.filter((item) => item.entryType !== 'transfer'),
}) })
const result = await provider.enrichTransaction({ messages }) const result = await provider.enrichTransaction({ messages })
const threshold = clampThreshold(settingsStore.aiAutoApplyThreshold) const threshold = clampThreshold(settingsStore.aiAutoApplyThreshold)
@@ -61,3 +72,19 @@ export const maybeEnrichTransactionWithAi = async (transaction) => {
return failed || transaction 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 })
}

View File

@@ -1,6 +1,17 @@
import { normalizeEnrichmentResult } from '../../lib/aiPrompt.js' import { normalizeEnrichmentResult } from '../../lib/aiPrompt.js'
import { AiProvider } from './aiProvider.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 { export class DeepSeekProvider extends AiProvider {
constructor({ apiKey, baseUrl = 'https://api.deepseek.com', model = 'deepseek-chat' }) { constructor({ apiKey, baseUrl = 'https://api.deepseek.com', model = 'deepseek-chat' }) {
super() super()
@@ -9,7 +20,7 @@ export class DeepSeekProvider extends AiProvider {
this.model = model this.model = model
} }
async enrichTransaction({ messages }) { async createCompletion(payload) {
if (!this.apiKey) { if (!this.apiKey) {
throw new Error('DeepSeek API key is required') throw new Error('DeepSeek API key is required')
} }
@@ -22,9 +33,7 @@ export class DeepSeekProvider extends AiProvider {
}, },
body: JSON.stringify({ body: JSON.stringify({
model: this.model, model: this.model,
temperature: 0.2, ...payload,
response_format: { type: 'json_object' },
messages,
}), }),
}) })
@@ -33,8 +42,17 @@ export class DeepSeekProvider extends AiProvider {
throw new Error(`DeepSeek request failed: ${response.status} ${detail}`.trim()) throw new Error(`DeepSeek request failed: ${response.status} ${detail}`.trim())
} }
const data = await response.json() return response.json()
const content = data?.choices?.[0]?.message?.content }
async enrichTransaction({ messages }) {
const data = await this.createCompletion({
temperature: 0.2,
response_format: { type: 'json_object' },
messages,
})
const content = extractMessageContent(data)
if (!content) { if (!content) {
throw new Error('DeepSeek returned an empty response') throw new Error('DeepSeek returned an empty response')
} }
@@ -52,4 +70,22 @@ export class DeepSeekProvider extends AiProvider {
model: data?.model || this.model, 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,
}
}
} }

View File

@@ -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) => ({ const fallbackRules = notificationRulesSource.map((rule) => ({
...rule, ...rule,
@@ -10,13 +11,36 @@ const fallbackRules = notificationRulesSource.map((rule) => ({
const amountPatterns = [ const amountPatterns = [
/(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)/i, /(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)/i,
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)/, /(-?\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*(?:已支付|已收款|到账|入账)/, /(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)/,
] ]
let cachedRules = [...fallbackRules] let cachedRules = [...fallbackRules]
let remoteRuleFetcher = async () => [] 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 = {}) => ({ const buildEmptyTransaction = (notification, options = {}) => ({
id: options.id, id: options.id,
merchant: options.merchant || 'Unknown', merchant: options.merchant || 'Unknown',
@@ -25,6 +49,7 @@ const buildEmptyTransaction = (notification, options = {}) => ({
date: new Date(options.date || notification?.createdAt || new Date().toISOString()).toISOString(), date: new Date(options.date || notification?.createdAt || new Date().toISOString()).toISOString(),
note: notification?.text || '', note: notification?.text || '',
syncStatus: 'pending', syncStatus: 'pending',
...DEFAULT_LEDGER_BY_ENTRY_TYPE.expense,
}) })
export const setRemoteRuleFetcher = (fetcher) => { export const setRemoteRuleFetcher = (fetcher) => {
@@ -78,6 +103,13 @@ const looksLikeAmount = (raw = '') => {
return /^[-\d.,]+\s*(?:元|块|块钱|¥|¥|人民币)?$/i.test(value) 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 extractMerchant = (text, rule) => {
const content = text || '' const content = text || ''
@@ -134,7 +166,8 @@ const matchRule = (notification, rule) => {
const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word)) const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word))
const requiredTextHit = const requiredTextHit =
!rule.requiredTextPatterns?.length || !rule.requiredTextPatterns?.length ||
rule.requiredTextPatterns.some((word) => text.includes(word)) rule.requiredTextPatterns.some((word) => text.includes(word)) ||
hasTransitRouteAmountFallback(rule, text)
return channelHit && keywordHit && requiredTextHit return channelHit && keywordHit && requiredTextHit
} }
@@ -161,8 +194,7 @@ export const transformNotificationToTransaction = (notification, options = {}) =
} }
const direction = options.direction || rule.direction || 'expense' const direction = options.direction || rule.direction || 'expense'
const normalizedAmount = const normalizedAmount = direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText)
direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText)
let merchant = options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown' let merchant = options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown'
if (merchant === 'Unknown' && rule.defaultMerchant) { if (merchant === 'Unknown' && rule.defaultMerchant) {
@@ -170,6 +202,7 @@ export const transformNotificationToTransaction = (notification, options = {}) =
} }
const date = options.date || notification?.createdAt || new Date().toISOString() const date = options.date || notification?.createdAt || new Date().toISOString()
const ledger = buildLedgerPayload(rule, notification, merchant)
const transaction = { const transaction = {
id: options.id, id: options.id,
@@ -179,6 +212,7 @@ export const transformNotificationToTransaction = (notification, options = {}) =
date: new Date(date).toISOString(), date: new Date(date).toISOString(),
note: notification?.text || '', note: notification?.text || '',
syncStatus: 'pending', syncStatus: 'pending',
...ledger,
} }
const requiresConfirmation = const requiresConfirmation =

View File

@@ -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' import { getDb, saveDbToStore } from '../lib/sqlite'
const statusMap = { const statusMap = {
@@ -15,6 +19,13 @@ const TRANSACTION_SELECT_FIELDS = `
date, date,
note, note,
sync_status, sync_status,
entry_type,
fund_source_type,
fund_source_name,
fund_target_type,
fund_target_name,
impact_expense,
impact_income,
ai_category, ai_category,
ai_tags, ai_tags,
ai_confidence, ai_confidence,
@@ -31,7 +42,7 @@ const normalizeStatus = (value) => {
} }
const normalizeAiStatus = (value) => { const normalizeAiStatus = (value) => {
if (['applied', 'suggested', 'failed'].includes(value)) { if (['running', 'applied', 'suggested', 'failed'].includes(value)) {
return value return value
} }
return 'idle' return 'idle'
@@ -47,22 +58,68 @@ const parseAiTags = (value) => {
} }
} }
const mapRow = (row) => ({ const normalizeFlag = (value, fallback = false) => {
id: row.id, if (value === 1 || value === true || value === '1') return true
amount: Number(row.amount), if (value === 0 || value === false || value === '0') return false
merchant: row.merchant, return fallback
category: row.category || 'Uncategorized', }
date: row.date,
note: row.note || '', const inferEntryTypeFromAmount = (amount) => (Number(amount) >= 0 ? 'income' : 'expense')
syncStatus: normalizeStatus(row.sync_status),
aiCategory: row.ai_category || '', const buildLedgerDefaults = (payload = {}, existing = null) => {
aiTags: parseAiTags(row.ai_tags), const entryType = payload.entryType || existing?.entryType || inferEntryTypeFromAmount(payload.amount ?? existing?.amount ?? 0)
aiConfidence: typeof row.ai_confidence === 'number' ? row.ai_confidence : Number(row.ai_confidence || 0), const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense
aiReason: row.ai_reason || '',
aiStatus: normalizeAiStatus(row.ai_status), return {
aiModel: row.ai_model || '', entryType,
aiNormalizedMerchant: row.ai_normalized_merchant || '', 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,
category: row.category || 'Uncategorized',
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 || '',
}
}
const getMerchantKey = (merchant) => const getMerchantKey = (merchant) =>
String(merchant || '') String(merchant || '')
@@ -190,19 +247,37 @@ export const fetchRecentTransactionsForAi = async (limit = 6, excludeId = '') =>
export const insertTransaction = async (payload) => { export const insertTransaction = async (payload) => {
const db = await getDb() const db = await getDb()
const id = payload.id || uuidv4() const id = payload.id || uuidv4()
const amount = Number(payload.amount)
const ledger = buildLedgerDefaults(payload)
const sanitized = { const sanitized = {
merchant: payload.merchant?.trim() || 'Unknown', merchant: payload.merchant?.trim() || 'Unknown',
category: payload.category?.trim() || 'Uncategorized', category: payload.category?.trim() || 'Uncategorized',
note: payload.note?.trim() || '', note: payload.note?.trim() || '',
date: payload.date, date: payload.date,
amount: Number(payload.amount), amount,
syncStatus: statusMap[payload.syncStatus] ?? statusMap.pending, syncStatus: statusMap[payload.syncStatus] ?? statusMap.pending,
...ledger,
} }
await db.run( await db.run(
` `
INSERT INTO transactions (id, amount, merchant, category, date, note, sync_status) INSERT INTO transactions (
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
[ [
id, id,
@@ -212,6 +287,13 @@ export const insertTransaction = async (payload) => {
sanitized.date, sanitized.date,
sanitized.note || null, sanitized.note || null,
sanitized.syncStatus, sanitized.syncStatus,
sanitized.entryType,
sanitized.fundSourceType,
sanitized.fundSourceName || null,
sanitized.fundTargetType,
sanitized.fundTargetName || null,
sanitized.impactExpense ? 1 : 0,
sanitized.impactIncome ? 1 : 0,
], ],
) )
@@ -232,20 +314,31 @@ export const insertTransaction = async (payload) => {
} }
export const updateTransaction = async (payload) => { export const updateTransaction = async (payload) => {
const existing = await getTransactionById(payload.id)
if (!existing) return null
const db = await getDb() const db = await getDb()
const ledger = buildLedgerDefaults(payload, existing)
await db.run( await db.run(
` `
UPDATE transactions 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 = ?
WHERE id = ? WHERE id = ?
`, `,
[ [
Number(payload.amount), Number(payload.amount),
payload.merchant?.trim() || 'Unknown', payload.merchant?.trim() || existing.merchant || 'Unknown',
payload.category?.trim() || 'Uncategorized', payload.category?.trim() || existing.category || 'Uncategorized',
payload.date, payload.date,
payload.note?.trim() || null, payload.note?.trim() || null,
statusMap[payload.syncStatus] ?? statusMap.pending, 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,
payload.id, payload.id,
], ],
) )
@@ -254,6 +347,21 @@ export const updateTransaction = async (payload) => {
return getTransactionById(payload.id) return getTransactionById(payload.id)
} }
export const markTransactionAiRunning = async (transactionId, model = '') => {
const db = await getDb()
await db.run(
`
UPDATE transactions
SET ai_status = ?, ai_model = ?, ai_reason = ?
WHERE id = ?
`,
['running', model || null, null, transactionId],
)
await saveDbToStore()
return getTransactionById(transactionId)
}
export const applyAiEnrichment = async (transactionId, enrichment, options = {}) => { export const applyAiEnrichment = async (transactionId, enrichment, options = {}) => {
const existing = await getTransactionById(transactionId) const existing = await getTransactionById(transactionId)
if (!existing) return null if (!existing) return null
@@ -282,7 +390,10 @@ export const applyAiEnrichment = async (transactionId, enrichment, options = {})
], ],
) )
if (!options.skipMerchantProfile && (enrichment.normalizedMerchant || enrichment.category || enrichment.tags?.length)) { if (
!options.skipMerchantProfile &&
(enrichment.normalizedMerchant || enrichment.category || enrichment.tags?.length)
) {
await upsertMerchantProfile({ await upsertMerchantProfile({
merchant: existing.merchant, merchant: existing.merchant,
normalizedMerchant: enrichment.normalizedMerchant, normalizedMerchant: enrichment.normalizedMerchant,

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { shouldCountAsExpense, shouldCountAsIncome } from '../config/ledger.js'
import { maybeEnrichTransactionWithAi } from '../services/ai/aiService.js' import { maybeEnrichTransactionWithAi } from '../services/ai/aiService.js'
import { import {
deleteTransaction, deleteTransaction,
@@ -52,19 +53,19 @@ export const useTransactionStore = defineStore(
}) })
const totalExpense = computed(() => 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(() => 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(() => 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(() => 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)) const latestTransactions = computed(() => sortedTransactions.value.slice(0, 5))
@@ -90,7 +91,7 @@ export const useTransactionStore = defineStore(
dayKey, dayKey,
label: formatDayLabel(dayKey), label: formatDayLabel(dayKey),
totalExpense: records totalExpense: records
.filter((tx) => tx.amount < 0) .filter((tx) => shouldCountAsExpense(tx))
.reduce((sum, tx) => sum + Math.abs(tx.amount), 0), .reduce((sum, tx) => sum + Math.abs(tx.amount), 0),
items: records, items: records,
})) }))
@@ -117,10 +118,21 @@ export const useTransactionStore = defineStore(
} }
} }
const upsertTransaction = (nextTransaction) => {
transactions.value = replaceTransaction(transactions.value, nextTransaction)
}
const enrichTransactionInBackground = async (transaction) => { const enrichTransactionInBackground = async (transaction) => {
const enriched = await maybeEnrichTransactionWithAi(transaction) const enriched = await maybeEnrichTransactionWithAi(transaction, {
onProgress: (nextTransaction) => {
if (nextTransaction?.id === transaction.id) {
upsertTransaction(nextTransaction)
}
},
})
if (!enriched || enriched.id !== transaction.id) return if (!enriched || enriched.id !== transaction.id) return
transactions.value = replaceTransaction(transactions.value, enriched) upsertTransaction(enriched)
} }
const addTransaction = async (payload) => { const addTransaction = async (payload) => {
@@ -141,7 +153,7 @@ export const useTransactionStore = defineStore(
} }
const updated = await updateTransaction(normalized) const updated = await updateTransaction(normalized)
if (updated) { if (updated) {
transactions.value = replaceTransaction(transactions.value, updated) upsertTransaction(updated)
void enrichTransactionInBackground(updated) void enrichTransactionInBackground(updated)
} }
return updated return updated

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
// 负责全局 UI 状态,例如新增记录的底部弹窗 // 管理全局 UI 状态,例如记一笔弹层的开关和当前编辑项。
export const useUiStore = defineStore('ui', () => { export const useUiStore = defineStore('ui', () => {
const addEntryVisible = ref(false) const addEntryVisible = ref(false)
const editingTransactionId = ref('') const editingTransactionId = ref('')
@@ -23,4 +23,3 @@ export const useUiStore = defineStore('ui', () => {
closeAddEntry, closeAddEntry,
} }
}) })

View File

@@ -1,4 +1,4 @@
export interface Transaction { export interface Transaction {
id: string; id: string;
amount: number; amount: number;
merchant: string; merchant: string;
@@ -6,11 +6,18 @@ export interface Transaction {
date: string; date: string;
note?: string; note?: string;
syncStatus: 'pending' | 'synced' | 'error'; 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; aiCategory?: string;
aiTags?: string[]; aiTags?: string[];
aiConfidence?: number; aiConfidence?: number;
aiReason?: string; aiReason?: string;
aiStatus?: 'idle' | 'applied' | 'suggested' | 'failed'; aiStatus?: 'idle' | 'running' | 'applied' | 'suggested' | 'failed';
aiModel?: string; aiModel?: string;
aiNormalizedMerchant?: string; aiNormalizedMerchant?: string;
} }

View File

@@ -217,7 +217,7 @@ const sheetStyle = computed(() => {
input-class="text-2xl font-bold" input-class="text-2xl font-bold"
> >
<template #prefix> <template #prefix>
<span class="text-stone-400 text-sm"></span> <span class="text-stone-400 text-sm">¥</span>
</template> </template>
</EchoInput> </EchoInput>

View File

@@ -1,5 +1,102 @@
<script setup> <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> </script>
<template> <template>
@@ -12,71 +109,89 @@
</div> </div>
<div> <div>
<h2 class="text-xl font-extrabold text-stone-800">AI 财务顾问</h2> <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> </div>
<div class="flex-1 overflow-y-auto space-y-5 pr-1 pb-2"> <div class="grid grid-cols-2 gap-3 mb-4">
<div class="flex gap-3"> <div class="bg-white rounded-2xl border border-stone-100 p-4 shadow-sm">
<div <p class="text-[11px] font-bold text-stone-400">账本样本</p>
class="w-8 h-8 rounded-full bg-stone-100 flex items-center justify-center text-stone-400 mt-1 shrink-0" <p class="text-lg font-extrabold text-stone-800 mt-1">{{ sortedTransactions.length }}</p>
> <p class="text-[11px] text-stone-400 mt-1">聊天时会带上最近 80 条记录摘要</p>
<i class="ph-fill ph-robot" /> </div>
</div> <div class="bg-white rounded-2xl border border-stone-100 p-4 shadow-sm">
<div <p class="text-[11px] font-bold text-stone-400">AI 状态</p>
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 class="text-lg font-extrabold mt-1" :class="aiReady ? 'text-emerald-600' : 'text-amber-600'">
> {{ aiReady ? '已连接' : '待配置' }}
<p>我已经整理了你最近 30 天的消费记录</p> </p>
<div class="mt-3 bg-stone-50 rounded-xl p-3 border border-stone-100"> <p class="text-[11px] text-stone-400 mt-1">
<div class="flex items-center gap-2 mb-2"> {{ aiReady ? '使用设置页里的 DeepSeek Key 直接调用。' : '先去设置页填写 DeepSeek API Key。' }}
<i class="ph-fill ph-chart-line text-orange-400" /> </p>
<span class="font-bold text-stone-700 text-xs">本月摘要</span> </div>
</div> </div>
<p class="text-xs">
餐饮与通勤是主要支出来源夜间消费频率明显高于白天预算消耗速度偏快 <div class="flex gap-2 flex-wrap mb-4">
</p> <button
v-for="item in quickPrompts"
:key="item"
class="px-3 py-2 rounded-full bg-white border border-stone-100 text-xs font-bold text-stone-600 shadow-sm"
@click="handleQuickPrompt(item)"
>
{{ item }}
</button>
</div>
<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 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', message.pending ? 'ph-circle-notch animate-spin' : 'ph-robot']" />
</div> </div>
<p class="mt-3">下一步会支持自动分类标签建议异常支出提醒和自然语言问答</p> </template>
</div>
</div>
<div class="flex gap-3 flex-row-reverse">
<div <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',
]"
> >
帮我总结这周花钱最多的三类项目 <p class="whitespace-pre-wrap">{{ message.content }}</p>
</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>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-2 relative shrink-0"> <div class="mt-3 relative shrink-0">
<input <textarea
type="text" v-model="prompt"
placeholder="输入想分析的问题..." rows="2"
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" 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 <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> </button>
</div> </div>
</div> </div>

View File

@@ -7,6 +7,12 @@ import { useTransactionStore } from '../stores/transactions'
import { useTransactionEntry } from '../composables/useTransactionEntry' import { useTransactionEntry } from '../composables/useTransactionEntry'
import { useSettingsStore } from '../stores/settings' import { useSettingsStore } from '../stores/settings'
import { useUiStore } from '../stores/ui' import { useUiStore } from '../stores/ui'
import { getAiStatusMeta } from '../config/aiStatus.js'
import {
getEntryTypeLabel,
getTransferSummary,
isStoredValueAccountType,
} from '../config/ledger.js'
import { getCategoryLabel, getCategoryMeta } from '../config/transactionCategories.js' import { getCategoryLabel, getCategoryMeta } from '../config/transactionCategories.js'
import { bootstrapApp } from '../services/appBootstrap.js' import { bootstrapApp } from '../services/appBootstrap.js'
@@ -14,8 +20,14 @@ const router = useRouter()
const transactionStore = useTransactionStore() const transactionStore = useTransactionStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const uiStore = useUiStore() const uiStore = useUiStore()
const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions, sortedTransactions } = const {
storeToRefs(transactionStore) totalIncome,
totalExpense,
todaysIncome,
todaysExpense,
latestTransactions,
sortedTransactions,
} = storeToRefs(transactionStore)
const { notifications, confirmNotification, dismissNotification, processingId, syncNotifications } = const { notifications, confirmNotification, dismissNotification, processingId, syncNotifications } =
useTransactionEntry() useTransactionEntry()
@@ -27,7 +39,6 @@ onMounted(async () => {
await syncNotifications() await syncNotifications()
try { try {
// App 回到前台时,刷新本地账本和待处理通知。
appStateListener = await App.addListener('appStateChange', async ({ isActive }) => { appStateListener = await App.addListener('appStateChange', async ({ isActive }) => {
if (isActive) { if (isActive) {
await transactionStore.hydrateTransactions() await transactionStore.hydrateTransactions()
@@ -88,7 +99,7 @@ const periodExpense = computed(() => {
if (cycle === 'none' || !start) return totalExpense.value || 0 if (cycle === 'none' || !start) return totalExpense.value || 0
return (sortedTransactions.value || []).reduce((sum, tx) => { 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 return new Date(tx.date) >= start ? sum + tx.amount : sum
}, 0) }, 0)
}) })
@@ -123,6 +134,55 @@ const formatTime = (value) =>
new Date(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 = () => { const goSettings = () => {
router.push({ name: 'settings' }) router.push({ name: 'settings' })
} }
@@ -275,9 +335,39 @@ const openTransactionDetail = (tx) => {
<i :class="['ph-fill text-lg', getCategoryMeta(tx.category).icon]" /> <i :class="['ph-fill text-lg', getCategoryMeta(tx.category).icon]" />
</div> </div>
<div> <div>
<p class="font-bold text-stone-800 text-sm">{{ tx.merchant }}</p> <div class="flex items-center gap-2 flex-wrap">
<p class="font-bold text-stone-800 text-sm">{{ 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] text-stone-400"> <p class="text-[11px] text-stone-400">
{{ formatTime(tx.date) }} {{ getCategoryLabel(tx.category) }} {{ formatTime(tx.date) }} · {{ getCategoryLabel(tx.category) }}
</p>
<p v-if="formatLedgerHint(tx)" class="text-[10px] text-stone-400 mt-1">
{{ formatLedgerHint(tx) }}
</p>
<p v-if="formatAiHint(tx)" class="text-[10px] text-stone-400 mt-1">
{{ formatAiHint(tx) }}
</p> </p>
</div> </div>
</div> </div>
@@ -317,7 +407,7 @@ const openTransactionDetail = (tx) => {
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h4 class="font-bold text-stone-800 text-sm"> <h4 class="font-bold text-stone-800 text-sm">
{{ notif.channel }} {{ formatTime(notif.createdAt) }} {{ notif.channel }} · {{ formatTime(notif.createdAt) }}
</h4> </h4>
<p class="text-xs text-stone-400 leading-relaxed"> <p class="text-xs text-stone-400 leading-relaxed">
{{ notif.text }} {{ notif.text }}
@@ -359,7 +449,7 @@ const openTransactionDetail = (tx) => {
</div> </div>
<div class="z-10"> <div class="z-10">
<h4 class="font-bold text-stone-700">AI 财务顾问</h4> <h4 class="font-bold text-stone-700">AI 财务顾问</h4>
<p class="text-xs text-stone-400 mt-1">查看消费总结自动分类建议和可执行的预算提醒</p> <p class="text-xs text-stone-400 mt-1">现在可直接对话分析账本并查看自动分类建议和预算提醒</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,12 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia' 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 { CATEGORY_CHIPS, getCategoryLabel } from '../config/transactionCategories.js'
import { useTransactionStore } from '../stores/transactions' import { useTransactionStore } from '../stores/transactions'
import { useUiStore } from '../stores/ui' import { useUiStore } from '../stores/ui'
@@ -19,14 +25,62 @@ const toggleTodayOnly = () => {
filters.value.showOnlyToday = !filters.value.showOnlyToday filters.value.showOnlyToday = !filters.value.showOnlyToday
} }
const formatAmount = (value) => const formatAmount = (value) => `${value >= 0 ? '+' : '-'} ¥${Math.abs(value || 0).toFixed(2)}`
`${value >= 0 ? '+' : '-'}${Math.abs(value || 0).toFixed(2)}`
const formatTime = (value) => const formatTime = (value) =>
new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format( new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(
new Date(value), 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) => { const handleEdit = (item) => {
uiStore.openAddEntry(item.id) uiStore.openAddEntry(item.id)
} }
@@ -77,7 +131,7 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
</div> </div>
<div class="flex items-center justify-between text-[11px] text-stone-400 font-bold px-1"> <div class="flex items-center justify-between text-[11px] text-stone-400 font-bold px-1">
<span>按一条记录可快速编辑</span> <span>按一条记录可快速编辑</span>
<button <button
class="flex items-center gap-1 px-2 py-1 rounded-full border border-stone-200" class="flex items-center gap-1 px-2 py-1 rounded-full border border-stone-200"
:class="filters.showOnlyToday ? 'bg-gradient-warm text-white border-transparent' : 'bg-white'" :class="filters.showOnlyToday ? 'bg-gradient-warm text-white border-transparent' : 'bg-white'"
@@ -103,7 +157,7 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
<div> <div>
<h4 class="text-sm font-bold text-stone-500">{{ group.label }}</h4> <h4 class="text-sm font-bold text-stone-500">{{ group.label }}</h4>
<p class="text-[10px] text-stone-400"> <p class="text-[10px] text-stone-400">
支出 {{ group.totalExpense.toFixed(2) }} 支出 ¥{{ group.totalExpense.toFixed(2) }}
</p> </p>
</div> </div>
<span class="text-xs text-stone-300 font-bold">{{ group.dayKey }}</span> <span class="text-xs text-stone-300 font-bold">{{ group.dayKey }}</span>
@@ -128,18 +182,48 @@ const hasData = computed(() => groupedTransactions.value.length > 0)
/> />
</div> </div>
<div> <div>
<p class="font-bold text-stone-800 text-sm"> <div class="flex items-center gap-2 flex-wrap">
{{ item.merchant }} <p class="font-bold text-stone-800 text-sm">
</p> {{ item.merchant }}
</p>
<span
v-if="resolveLedgerBadge(item)"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-bold"
:class="resolveLedgerBadge(item).className"
>
<i :class="['ph-bold text-[10px]', resolveLedgerBadge(item).icon]" />
{{ resolveLedgerBadge(item).label }}
</span>
<span
v-if="resolveAiBadge(item)"
class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border text-[10px] font-bold"
:class="resolveAiBadge(item).className"
>
<i
:class="[
'ph-bold text-[10px]',
resolveAiBadge(item).icon,
resolveAiBadge(item).iconClassName,
]"
/>
{{ resolveAiBadge(item).label }}
</span>
</div>
<p class="text-xs text-stone-400 mt-0.5"> <p class="text-xs text-stone-400 mt-0.5">
{{ formatTime(item.date) }} {{ formatTime(item.date) }}
<span v-if="item.category" class="text-stone-300"> <span v-if="item.category" class="text-stone-300">
{{ getCategoryLabel(item.category) }} · {{ getCategoryLabel(item.category) }}
</span> </span>
<span v-if="item.note" class="text-stone-300"> <span v-if="item.note" class="text-stone-300">
{{ item.note }} · {{ item.note }}
</span> </span>
</p> </p>
<p v-if="formatLedgerHint(item)" class="text-[10px] text-stone-400 mt-1">
{{ formatLedgerHint(item) }}
</p>
<p v-if="formatAiHint(item)" class="text-[10px] text-stone-400 mt-1">
{{ formatAiHint(item) }}
</p>
</div> </div>
</div> </div>
<div class="text-right"> <div class="text-right">