feat:新增ai分析,新增储值账户,优化通知规则
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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,})"),
|
||||||
|
|||||||
@@ -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*(?:已支付|已收款|到账|入账)"""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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, '杭州通互联互通卡')
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
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',
|
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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
: []
|
: []
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
11
src/types/transaction.d.ts
vendored
11
src/types/transaction.d.ts
vendored
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user