Compare commits
2 Commits
d0f68e07b7
...
8cbe01dd9c
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cbe01dd9c | |||
| bf15918136 |
25
README.md
25
README.md
@@ -1,5 +1,24 @@
|
||||
# Vue 3 + Vite
|
||||
# Echo
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
Local-first personal finance app built with Vue 3, Vite, Capacitor, and SQLite.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run rules:sync
|
||||
npm run dev
|
||||
npm run build
|
||||
npm run rule:smoke
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/`: web app, stores, services, views, and SQLite bridge
|
||||
- `android/`: Capacitor Android shell and notification listener implementation
|
||||
- `scripts/`: release and local verification scripts
|
||||
- `src/config/notificationRules.js`: single source of truth for notification rules
|
||||
|
||||
## Notes
|
||||
|
||||
- Transaction data is stored locally.
|
||||
- After editing notification rules, run `npm run rules:sync` to regenerate the Android rule file.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.echo.app.notification
|
||||
|
||||
// Generated from src/config/notificationRules.js by scripts/sync-notification-rules.mjs.
|
||||
internal object NotificationRuleData {
|
||||
val fallbackRules: List<NotificationRuleDefinition> = listOf(
|
||||
NotificationRuleDefinition(
|
||||
id = "alipay-expense",
|
||||
label = "支付宝消费",
|
||||
channels = listOf("支付宝", "Alipay"),
|
||||
keywords = listOf("支付", "扣款", "支出", "消费", "成功支付"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
defaultCategory = "Food",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "alipay-income",
|
||||
label = "支付宝收款",
|
||||
channels = listOf("支付宝", "Alipay"),
|
||||
keywords = listOf("到账", "收入", "收款", "入账"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "wechat-expense",
|
||||
label = "微信消费",
|
||||
channels = listOf("微信支付", "微信", "WeChat"),
|
||||
keywords = listOf("支付", "支出", "扣款", "消费成功"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
defaultCategory = "Groceries",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "wechat-income",
|
||||
label = "微信收款",
|
||||
channels = listOf("微信支付", "微信", "WeChat"),
|
||||
keywords = listOf("收款", "到账", "入账", "转入"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "bank-income",
|
||||
label = "银行入账",
|
||||
channels = listOf("招商银行", "中国银行", "建设银行", "工商银行", "农业银行", "交通银行", "浦发银行", "兴业银行", "光大银行", "中信银行", "广发银行", "平安银行"),
|
||||
keywords = listOf("工资", "薪资", "代发", "到账", "入账", "收入"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "bank-expense",
|
||||
label = "银行卡支出",
|
||||
channels = listOf("招商银行", "中国银行", "建设银行", "工商银行", "农业银行", "交通银行", "浦发银行", "兴业银行", "光大银行", "中信银行", "广发银行", "平安银行", "借记卡", "信用卡"),
|
||||
keywords = listOf("支出", "消费", "扣款", "刷卡消费", "银联消费"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
defaultCategory = "Expense",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "transit-card-expense",
|
||||
label = "公交出行",
|
||||
channels = listOf("杭州通互联互通卡", "杭州通", "北京一卡通", "上海公共交通卡", "深圳通", "苏州公交卡", "交通联合", "乘车码", "地铁", "公交", "Mi Pay", "Huawei Pay", "华为钱包", "小米钱包", "OPPO 钱包"),
|
||||
keywords = emptyList(),
|
||||
requiredTextPatterns = listOf("扣费", "扣款", "支付", "乘车码", "车费", "票价", "本次乘车", "实付", "优惠后"),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
defaultCategory = "Transport",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})"),
|
||||
defaultMerchant = "公交/地铁出行",
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,150 +1,29 @@
|
||||
package com.echo.app.notification
|
||||
package com.echo.app.notification
|
||||
|
||||
import kotlin.math.abs
|
||||
|
||||
// 原生通知解析引擎:将 JS 侧的 fallbackRules & 提取逻辑完整搬到 Kotlin,
|
||||
// 在后台直接把通知转成交易草稿。
|
||||
internal enum class NotificationRuleDirection {
|
||||
INCOME,
|
||||
EXPENSE,
|
||||
}
|
||||
|
||||
internal data class NotificationRuleDefinition(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val channels: List<String> = emptyList(),
|
||||
val keywords: List<String> = emptyList(),
|
||||
val requiredTextPatterns: List<String> = emptyList(),
|
||||
val direction: NotificationRuleDirection,
|
||||
val defaultCategory: String,
|
||||
val autoCapture: Boolean,
|
||||
val merchantPattern: Regex? = null,
|
||||
val defaultMerchant: String? = null,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
object NotificationRuleEngine {
|
||||
private val fallbackRules: List<NotificationRuleDefinition> = NotificationRuleData.fallbackRules
|
||||
|
||||
// JS 侧规则模型的 Kotlin 版本
|
||||
private data class Rule(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val channels: List<String> = emptyList(),
|
||||
val keywords: List<String> = emptyList(),
|
||||
val direction: Direction,
|
||||
val defaultCategory: String,
|
||||
val autoCapture: Boolean,
|
||||
val merchantPattern: Regex? = null,
|
||||
val defaultMerchant: String? = null,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
private enum class Direction {
|
||||
INCOME,
|
||||
EXPENSE,
|
||||
}
|
||||
|
||||
// 与 src/services/notificationRuleService.js 中的 fallbackRules 一一对应
|
||||
private val fallbackRules: List<Rule> = listOf(
|
||||
Rule(
|
||||
id = "alipay-expense",
|
||||
label = "支付宝消费",
|
||||
channels = listOf("支付宝", "Alipay"),
|
||||
keywords = listOf("支付", "扣款", "支出", "消费", "成功支付"),
|
||||
direction = Direction.EXPENSE,
|
||||
defaultCategory = "Food",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("""向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)""", RegexOption.IGNORE_CASE),
|
||||
),
|
||||
Rule(
|
||||
id = "alipay-income",
|
||||
label = "支付宝收款",
|
||||
channels = listOf("支付宝", "Alipay"),
|
||||
keywords = listOf("到账", "收入", "收款", "入账"),
|
||||
direction = Direction.INCOME,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("""(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)""", RegexOption.IGNORE_CASE),
|
||||
),
|
||||
Rule(
|
||||
id = "wechat-expense",
|
||||
label = "微信消费",
|
||||
channels = listOf("微信支付", "微信", "WeChat"),
|
||||
keywords = listOf("支付", "支出", "扣款", "消费成功"),
|
||||
direction = Direction.EXPENSE,
|
||||
defaultCategory = "Groceries",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("""向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""),
|
||||
),
|
||||
Rule(
|
||||
id = "wechat-income",
|
||||
label = "微信收款",
|
||||
channels = listOf("微信支付", "微信", "WeChat"),
|
||||
keywords = listOf("收款", "到账", "入账", "转入"),
|
||||
direction = Direction.INCOME,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("""(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""),
|
||||
),
|
||||
Rule(
|
||||
id = "bank-income",
|
||||
label = "工资到账",
|
||||
channels = listOf(
|
||||
"招商银行",
|
||||
"中国银行",
|
||||
"建设银行",
|
||||
"工商银行",
|
||||
"农业银行",
|
||||
"交通银行",
|
||||
"浦发银行",
|
||||
"兴业银行",
|
||||
"光大银行",
|
||||
"中信银行",
|
||||
"广发银行",
|
||||
"平安银行",
|
||||
),
|
||||
keywords = listOf("工资", "薪资", "代发", "到账", "入账", "收入"),
|
||||
direction = Direction.INCOME,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("""(?:来自|付款方|来源)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""),
|
||||
),
|
||||
Rule(
|
||||
id = "bank-expense",
|
||||
label = "银行卡支出",
|
||||
channels = listOf(
|
||||
"招商银行",
|
||||
"中国银行",
|
||||
"建设银行",
|
||||
"工商银行",
|
||||
"农业银行",
|
||||
"交通银行",
|
||||
"浦发银行",
|
||||
"兴业银行",
|
||||
"光大银行",
|
||||
"中信银行",
|
||||
"广发银行",
|
||||
"平安银行",
|
||||
"借记卡",
|
||||
"信用卡",
|
||||
),
|
||||
keywords = listOf("支出", "消费", "扣款", "刷卡消费", "银联消费"),
|
||||
direction = Direction.EXPENSE,
|
||||
defaultCategory = "Expense",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("""(?:商户|商家|消费商户)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""),
|
||||
),
|
||||
Rule(
|
||||
id = "transit-card-expense",
|
||||
label = "公交出行",
|
||||
channels = listOf(
|
||||
"杭州通互联互通卡",
|
||||
"杭州通",
|
||||
"北京一卡通",
|
||||
"上海公共交通卡",
|
||||
"深圳通",
|
||||
"苏州公交卡",
|
||||
"交通联合",
|
||||
"乘车码",
|
||||
"地铁",
|
||||
"公交",
|
||||
"Mi Pay",
|
||||
"Huawei Pay",
|
||||
"华为钱包",
|
||||
"小米钱包",
|
||||
"OPPO 钱包",
|
||||
),
|
||||
keywords = emptyList(), // 只看来源,不强制正文关键字
|
||||
direction = Direction.EXPENSE,
|
||||
defaultCategory = "Transport",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("""([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到)\s*[\u4e00-\u9fa5]{2,})"""),
|
||||
defaultMerchant = "公交/地铁出行",
|
||||
),
|
||||
)
|
||||
|
||||
// 解析后的交易结果,与 JS transformNotificationToTransaction 对齐
|
||||
data class ParsedTransaction(
|
||||
val ruleId: String,
|
||||
val merchant: String,
|
||||
@@ -155,12 +34,6 @@ object NotificationRuleEngine {
|
||||
val autoCapture: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* 尝试从原始通知中解析出一条交易记录。
|
||||
*
|
||||
* 返回 null 表示未命中任何规则或无法解析金额,
|
||||
* 此时应在前端视图中展示为 unmatched,供用户手动处理。
|
||||
*/
|
||||
fun parse(raw: NotificationDb.RawNotification): ParsedTransaction? {
|
||||
val content = buildString {
|
||||
if (!raw.title.isNullOrBlank()) {
|
||||
@@ -181,72 +54,77 @@ object NotificationRuleEngine {
|
||||
merchant = rule.defaultMerchant
|
||||
}
|
||||
|
||||
val isExpense = rule.direction == Direction.EXPENSE
|
||||
val category = rule.defaultCategory.ifBlank { "Uncategorized" }
|
||||
|
||||
return ParsedTransaction(
|
||||
ruleId = rule.id,
|
||||
merchant = merchant.ifBlank { "Unknown" },
|
||||
category = category,
|
||||
category = rule.defaultCategory.ifBlank { "Uncategorized" },
|
||||
amount = amount,
|
||||
isExpense = isExpense,
|
||||
isExpense = rule.direction == NotificationRuleDirection.EXPENSE,
|
||||
note = content.take(200),
|
||||
autoCapture = rule.autoCapture,
|
||||
)
|
||||
}
|
||||
|
||||
// ===== 内部辅助函数:匹配规则 & 提取金额 / 商户 =====
|
||||
|
||||
private fun matchRule(raw: NotificationDb.RawNotification, text: String, rule: Rule): Boolean {
|
||||
private fun matchRule(
|
||||
raw: NotificationDb.RawNotification,
|
||||
text: String,
|
||||
rule: NotificationRuleDefinition
|
||||
): Boolean {
|
||||
if (!rule.enabled) return false
|
||||
|
||||
val channel = raw.channel ?: ""
|
||||
// 频道命中:优先用通知来源/标题匹配,必要时回退到正文匹配(兼容 adb shell / 部分厂商改名)
|
||||
val channelHit =
|
||||
rule.channels.isEmpty() || rule.channels.any { channel.contains(it) || text.contains(it) }
|
||||
val keywordHit = rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) }
|
||||
return channelHit && keywordHit
|
||||
rule.channels.isEmpty() ||
|
||||
rule.channels.any { channel.contains(it) } ||
|
||||
(channel.isBlank() && rule.channels.any { text.contains(it) })
|
||||
val keywordHit =
|
||||
rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) }
|
||||
val requiredTextHit =
|
||||
rule.requiredTextPatterns.isEmpty() || rule.requiredTextPatterns.any { text.contains(it) }
|
||||
|
||||
return channelHit && keywordHit && requiredTextHit
|
||||
}
|
||||
|
||||
// 从通知文本中提取交易金额,兼容「¥」「¥」「元」「人民币」等常见形式
|
||||
private fun extractAmount(text: String): Double {
|
||||
val primaryRegex =
|
||||
Regex("""(?:[¥¥]|人民币)\s*(-?\d+(?:\.\d{1,2})?)|(-?\d+(?:\.\d{1,2})?)\s*元""")
|
||||
val primary = primaryRegex.find(text)
|
||||
val v1 = primary?.groups?.get(1)?.value
|
||||
val v2 = primary?.groups?.get(2)?.value
|
||||
val value = v1 ?: v2
|
||||
if (!value.isNullOrBlank()) {
|
||||
return abs(value.toDoubleOrNull() ?: 0.0)
|
||||
val normalized = text.replace(",", "").trim()
|
||||
val patterns = listOf(
|
||||
Regex("""(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)""", RegexOption.IGNORE_CASE),
|
||||
Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)"""),
|
||||
Regex("""(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车)\D{0,8}?(-?\d+(?:\.\d{1,2})?)"""),
|
||||
Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)"""),
|
||||
)
|
||||
|
||||
for (pattern in patterns) {
|
||||
val match = pattern.find(normalized)
|
||||
val value = match?.groups?.get(1)?.value ?: continue
|
||||
val parsed = abs(value.toDoubleOrNull() ?: 0.0)
|
||||
if (parsed > 0.0) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
val looseRegex = Regex("""(-?\d+(?:\.\d{1,2})?)""")
|
||||
val loose = looseRegex.find(text)
|
||||
val looseVal = loose?.groups?.get(1)?.value
|
||||
return if (!looseVal.isNullOrBlank()) abs(looseVal.toDoubleOrNull() ?: 0.0) else 0.0
|
||||
return 0.0
|
||||
}
|
||||
|
||||
// 判断字符串是否更像是「金额」而不是商户名
|
||||
private fun looksLikeAmount(raw: String?): Boolean {
|
||||
val value = raw?.trim().orEmpty()
|
||||
if (value.isEmpty()) return false
|
||||
val pattern = Regex("""^[-\d.,]+\s*(?:元|块钱|¥|¥|人民币)?$""")
|
||||
return pattern.matches(value)
|
||||
return Regex("""^[-\d.,]+\s*(?:元|块|块钱|¥|¥|人民币)?$""", RegexOption.IGNORE_CASE)
|
||||
.matches(value)
|
||||
}
|
||||
|
||||
private fun extractMerchant(text: String, rule: Rule): String {
|
||||
private fun extractMerchant(text: String, rule: NotificationRuleDefinition): String {
|
||||
val content = text.ifBlank { "" }
|
||||
|
||||
rule.merchantPattern?.let { regex ->
|
||||
val m = regex.find(content)
|
||||
if (m != null && m.groupValues.size > 1) {
|
||||
val candidate = m.groupValues[1].trim()
|
||||
val match = regex.find(content)
|
||||
if (match != null && match.groupValues.size > 1) {
|
||||
val candidate = match.groupValues[1].trim()
|
||||
if (candidate.isNotEmpty()) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// 通用的「站点 A -> 站点 B」线路模式(公交/地铁通知)
|
||||
val routeRegex =
|
||||
Regex("""([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到|->)\s*[\u4e00-\u9fa5]{2,})""")
|
||||
val routeRegex = Regex("""([\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,})""")
|
||||
val routeMatch = routeRegex.find(content)
|
||||
if (routeMatch != null && routeMatch.groupValues.size > 1) {
|
||||
return routeMatch.groupValues[1].trim()
|
||||
@@ -258,18 +136,17 @@ object NotificationRuleEngine {
|
||||
)
|
||||
|
||||
for (pattern in genericPatterns) {
|
||||
val m = pattern.find(content)
|
||||
if (m != null && m.groupValues.size > 1) {
|
||||
val candidate = m.groupValues[1].trim()
|
||||
val match = pattern.find(content)
|
||||
if (match != null && match.groupValues.size > 1) {
|
||||
val candidate = match.groupValues[1].trim()
|
||||
if (candidate.isNotEmpty()) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// 处理「……支出(消费支付宝-上海拉扎斯信息科技有限公司)3.23元」这类银行通知
|
||||
val parenMatch = Regex("""\(([^)]+)\)""").find(content)
|
||||
if (parenMatch != null && parenMatch.groupValues.size > 1) {
|
||||
val inner = parenMatch.groupValues[1]
|
||||
val payMatch = Regex("""(?:支付宝|微信支付|微信)[-—\s]*([^\d元¥¥]+)$""").find(inner)
|
||||
val payMatch = Regex("""(?:支付宝|微信支付|微信)[-\s]*([^\d元¥¥]+)$""").find(inner)
|
||||
if (payMatch != null && payMatch.groupValues.size > 1) {
|
||||
val candidate = payMatch.groupValues[1].trim()
|
||||
if (candidate.isNotEmpty() && !looksLikeAmount(candidate)) {
|
||||
|
||||
272
package-lock.json
generated
272
package-lock.json
generated
@@ -22,7 +22,7 @@
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^7.4.4",
|
||||
"@capacitor/cli": "^5.7.8",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.35",
|
||||
@@ -124,46 +124,46 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/cli": {
|
||||
"version": "7.4.4",
|
||||
"resolved": "https://registry.npmmirror.com/@capacitor/cli/-/cli-7.4.4.tgz",
|
||||
"integrity": "sha512-J7ciBE7GlJ70sr2s8oz1+H4ZdNk4MGG41fsakUlDHWva5UWgFIZYMiEdDvGbYazAYTaxN3lVZpH9zil9FfZj+Q==",
|
||||
"version": "5.7.8",
|
||||
"resolved": "https://registry.npmmirror.com/@capacitor/cli/-/cli-5.7.8.tgz",
|
||||
"integrity": "sha512-qN8LDlREMhrYhOvVXahoJVNkP8LP55/YPRJrzTAFrMqlNJC18L3CzgWYIblFPnuwfbH/RxbfoZT/ydkwgVpMrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ionic/cli-framework-output": "^2.2.8",
|
||||
"@ionic/utils-subprocess": "^3.0.1",
|
||||
"@ionic/utils-terminal": "^2.3.5",
|
||||
"commander": "^12.1.0",
|
||||
"debug": "^4.4.0",
|
||||
"@ionic/cli-framework-output": "^2.2.5",
|
||||
"@ionic/utils-fs": "^3.1.6",
|
||||
"@ionic/utils-subprocess": "^2.1.11",
|
||||
"@ionic/utils-terminal": "^2.3.3",
|
||||
"commander": "^9.3.0",
|
||||
"debug": "^4.3.4",
|
||||
"env-paths": "^2.2.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"kleur": "^4.1.5",
|
||||
"native-run": "^2.0.1",
|
||||
"kleur": "^4.1.4",
|
||||
"native-run": "^2.0.0",
|
||||
"open": "^8.4.0",
|
||||
"plist": "^3.1.0",
|
||||
"plist": "^3.0.5",
|
||||
"prompts": "^2.4.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"semver": "^7.6.3",
|
||||
"rimraf": "^4.4.1",
|
||||
"semver": "^7.3.7",
|
||||
"tar": "^6.1.11",
|
||||
"tslib": "^2.8.1",
|
||||
"xml2js": "^0.6.2"
|
||||
"tslib": "^2.4.0",
|
||||
"xml2js": "^0.5.0"
|
||||
},
|
||||
"bin": {
|
||||
"cap": "bin/capacitor",
|
||||
"capacitor": "bin/capacitor"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/cli/node_modules/commander": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/commander/-/commander-12.1.0.tgz",
|
||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz",
|
||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/core": {
|
||||
@@ -693,14 +693,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ionic/utils-process": {
|
||||
"version": "2.1.12",
|
||||
"resolved": "https://registry.npmmirror.com/@ionic/utils-process/-/utils-process-2.1.12.tgz",
|
||||
"integrity": "sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==",
|
||||
"version": "2.1.11",
|
||||
"resolved": "https://registry.npmmirror.com/@ionic/utils-process/-/utils-process-2.1.11.tgz",
|
||||
"integrity": "sha512-Uavxn+x8j3rDlZEk1X7YnaN6wCgbCwYQOeIjv/m94i1dzslqWhqIHEqxEyeE8HsT5Negboagg7GtQiABy+BLbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ionic/utils-object": "2.1.6",
|
||||
"@ionic/utils-terminal": "2.3.5",
|
||||
"@ionic/utils-terminal": "2.3.4",
|
||||
"debug": "^4.0.0",
|
||||
"signal-exit": "^3.0.3",
|
||||
"tree-kill": "^1.2.2",
|
||||
@@ -710,10 +710,31 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ionic/utils-process/node_modules/@ionic/utils-terminal": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/@ionic/utils-terminal/-/utils-terminal-2.3.4.tgz",
|
||||
"integrity": "sha512-cEiMFl3jklE0sW60r8JHH3ijFTwh/jkdEKWbylSyExQwZ8pPuwoXz7gpkWoJRLuoRHHSvg+wzNYyPJazIHfoJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/slice-ansi": "^4.0.0",
|
||||
"debug": "^4.0.0",
|
||||
"signal-exit": "^3.0.3",
|
||||
"slice-ansi": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"tslib": "^2.0.1",
|
||||
"untildify": "^4.0.0",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ionic/utils-stream": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmmirror.com/@ionic/utils-stream/-/utils-stream-3.1.7.tgz",
|
||||
"integrity": "sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==",
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmmirror.com/@ionic/utils-stream/-/utils-stream-3.1.6.tgz",
|
||||
"integrity": "sha512-4+Kitey1lTA1yGtnigeYNhV/0tggI3lWBMjC7tBs1K9GXa/q7q4CtOISppdh8QgtOhrhAXS2Igp8rbko/Cj+lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -725,17 +746,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ionic/utils-subprocess": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/@ionic/utils-subprocess/-/utils-subprocess-3.0.1.tgz",
|
||||
"integrity": "sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==",
|
||||
"version": "2.1.14",
|
||||
"resolved": "https://registry.npmmirror.com/@ionic/utils-subprocess/-/utils-subprocess-2.1.14.tgz",
|
||||
"integrity": "sha512-nGYvyGVjU0kjPUcSRFr4ROTraT3w/7r502f5QJEsMRKTqa4eEzCshtwRk+/mpASm0kgBN5rrjYA5A/OZg8ahqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ionic/utils-array": "2.1.6",
|
||||
"@ionic/utils-fs": "3.1.7",
|
||||
"@ionic/utils-process": "2.1.12",
|
||||
"@ionic/utils-stream": "3.1.7",
|
||||
"@ionic/utils-terminal": "2.3.5",
|
||||
"@ionic/utils-process": "2.1.11",
|
||||
"@ionic/utils-stream": "3.1.6",
|
||||
"@ionic/utils-terminal": "2.3.4",
|
||||
"cross-spawn": "^7.0.3",
|
||||
"debug": "^4.0.0",
|
||||
"tslib": "^2.0.1"
|
||||
@@ -744,6 +765,27 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ionic/utils-subprocess/node_modules/@ionic/utils-terminal": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/@ionic/utils-terminal/-/utils-terminal-2.3.4.tgz",
|
||||
"integrity": "sha512-cEiMFl3jklE0sW60r8JHH3ijFTwh/jkdEKWbylSyExQwZ8pPuwoXz7gpkWoJRLuoRHHSvg+wzNYyPJazIHfoJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/slice-ansi": "^4.0.0",
|
||||
"debug": "^4.0.0",
|
||||
"signal-exit": "^3.0.3",
|
||||
"slice-ansi": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"tslib": "^2.0.1",
|
||||
"untildify": "^4.0.0",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@ionic/utils-terminal": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/@ionic/utils-terminal/-/utils-terminal-2.3.5.tgz",
|
||||
@@ -765,29 +807,6 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1479,6 +1498,13 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -1546,6 +1572,16 @@
|
||||
"node": ">= 5.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
|
||||
@@ -1984,21 +2020,6 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.2.tgz",
|
||||
"integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-minipass": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||
@@ -2025,6 +2046,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2051,18 +2079,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/glob/-/glob-13.0.0.tgz",
|
||||
"integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==",
|
||||
"version": "9.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/glob/-/glob-9.3.4.tgz",
|
||||
"integrity": "sha512-qaSc49hojMOv1EPM4EuyITjDSgSKI0rthoHnvE81tcOi1SCVndHko7auqxdQ14eiQG2NDBJBE86+2xIrbIvrbA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minimatch": "^10.1.1",
|
||||
"minipass": "^7.1.2",
|
||||
"path-scurry": "^2.0.0"
|
||||
"fs.realpath": "^1.0.0",
|
||||
"minimatch": "^8.0.2",
|
||||
"minipass": "^4.2.4",
|
||||
"path-scurry": "^1.6.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -2343,14 +2372,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "11.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-11.2.2.tgz",
|
||||
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
@@ -2386,29 +2412,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.1.1.tgz",
|
||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||
"version": "8.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-8.0.7.tgz",
|
||||
"integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz",
|
||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
||||
"version": "4.2.8",
|
||||
"resolved": "https://registry.npmmirror.com/minipass/-/minipass-4.2.8.tgz",
|
||||
"integrity": "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
@@ -2579,13 +2605,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
|
||||
@@ -2610,22 +2629,32 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz",
|
||||
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"lru-cache": "^11.0.0",
|
||||
"minipass": "^7.1.2"
|
||||
"lru-cache": "^10.2.0",
|
||||
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": ">=16 || 14 >=14.18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-scurry/node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz",
|
||||
@@ -3002,20 +3031,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rimraf": {
|
||||
"version": "6.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-6.1.2.tgz",
|
||||
"integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==",
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-4.4.1.tgz",
|
||||
"integrity": "sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"glob": "^13.0.0",
|
||||
"package-json-from-dist": "^1.0.1"
|
||||
"glob": "^9.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"rimraf": "dist/esm/bin.mjs"
|
||||
"rimraf": "dist/cjs/src/bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -3876,9 +3904,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/xml2js": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz",
|
||||
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.5.0.tgz",
|
||||
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"rules:sync": "node scripts/sync-notification-rules.mjs",
|
||||
"rule:smoke": "node scripts/rule-smoke.mjs",
|
||||
"version:sync": "node scripts/sync-version.cjs",
|
||||
"release": "node scripts/release.cjs",
|
||||
"release:patch": "node scripts/release.cjs patch",
|
||||
@@ -14,9 +16,9 @@
|
||||
"release:major": "node scripts/release.cjs major"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor-community/sqlite": "^5.7.2",
|
||||
"@capacitor-community/sqlite": "^5.7.4",
|
||||
"@capacitor/android": "^5.7.8",
|
||||
"@capacitor/app": "^5.0.0",
|
||||
"@capacitor/app": "^5.0.8",
|
||||
"@capacitor/core": "^5.7.8",
|
||||
"@phosphor-icons/web": "^2.1.2",
|
||||
"jeep-sqlite": "^2.7.1",
|
||||
@@ -28,7 +30,7 @@
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^7.4.4",
|
||||
"@capacitor/cli": "^5.7.8",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.35",
|
||||
|
||||
16
public/default-avatar.svg
Normal file
16
public/default-avatar.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="24" y1="20" x2="136" y2="140" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFB36B"/>
|
||||
<stop offset="1" stop-color="#F06449"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ring" x1="38" y1="36" x2="120" y2="124" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFF7ED" stop-opacity="0.95"/>
|
||||
<stop offset="1" stop-color="#FFE7D4" stop-opacity="0.72"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="144" height="144" rx="48" fill="url(#bg)"/>
|
||||
<circle cx="80" cy="80" r="48" fill="url(#ring)"/>
|
||||
<path d="M61 52H101V64H75V75H97V87H75V108H61V52Z" fill="#8A3412"/>
|
||||
<path d="M103 52H117V108H103V52Z" fill="#8A3412" fill-opacity="0.25"/>
|
||||
</svg>
|
||||
63
scripts/rule-smoke.mjs
Normal file
63
scripts/rule-smoke.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { transformNotificationToTransaction } from '../src/services/notificationRuleService.js'
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: 'ignore transit operation notice',
|
||||
notification: {
|
||||
id: 'n1',
|
||||
channel: '绍兴公交',
|
||||
text: '黄酒小镇专线绍兴北站出站,该线路已于2025年10月17日起暂停运营',
|
||||
createdAt: '2026-03-11T00:00:00.000Z',
|
||||
},
|
||||
verify(result) {
|
||||
assert.equal(result.rule, undefined)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'capture transit expense with payment context',
|
||||
notification: {
|
||||
id: 'n2',
|
||||
channel: '绍兴公交',
|
||||
text: '乘车码扣费2元,绍兴北站->黄酒小镇',
|
||||
createdAt: '2026-03-11T00:00:00.000Z',
|
||||
},
|
||||
verify(result) {
|
||||
assert.equal(result.rule?.id, 'transit-card-expense')
|
||||
assert.equal(result.transaction.amount, -2)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'capture bank expense',
|
||||
notification: {
|
||||
id: 'n3',
|
||||
channel: '招商银行',
|
||||
text: '您尾号1234信用卡消费支出3.23元,商户支付宝-上海拉扎斯信息科技有限公司',
|
||||
createdAt: '2026-03-11T00:00:00.000Z',
|
||||
},
|
||||
verify(result) {
|
||||
assert.equal(result.rule?.id, 'bank-expense')
|
||||
assert.equal(result.transaction.amount, -3.23)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'capture wechat income',
|
||||
notification: {
|
||||
id: 'n4',
|
||||
channel: '微信支付',
|
||||
text: '收款到账12.50元,来自张三',
|
||||
createdAt: '2026-03-11T00:00:00.000Z',
|
||||
},
|
||||
verify(result) {
|
||||
assert.equal(result.rule?.id, 'wechat-income')
|
||||
assert.equal(result.transaction.amount, 12.5)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = transformNotificationToTransaction(testCase.notification)
|
||||
testCase.verify(result)
|
||||
}
|
||||
|
||||
console.log(`rule smoke passed: ${cases.length} cases`)
|
||||
57
scripts/sync-notification-rules.mjs
Normal file
57
scripts/sync-notification-rules.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import notificationRules from '../src/config/notificationRules.js'
|
||||
|
||||
const rootDir = path.resolve(import.meta.dirname, '..')
|
||||
const targetFile = path.join(
|
||||
rootDir,
|
||||
'android',
|
||||
'app',
|
||||
'src',
|
||||
'main',
|
||||
'java',
|
||||
'com',
|
||||
'echo',
|
||||
'app',
|
||||
'notification',
|
||||
'NotificationRuleData.kt',
|
||||
)
|
||||
|
||||
const toKotlinString = (value) => JSON.stringify(value).replace(/\$/g, '\\$')
|
||||
|
||||
const toKotlinStringList = (values = []) =>
|
||||
values.length ? `listOf(${values.map((value) => toKotlinString(value)).join(', ')})` : 'emptyList()'
|
||||
|
||||
const toKotlinRegex = (spec) => {
|
||||
if (!spec?.pattern) return 'null'
|
||||
const flags = spec.flags?.includes('i') ? ', setOf(RegexOption.IGNORE_CASE)' : ''
|
||||
return `Regex(${toKotlinString(spec.pattern)}${flags})`
|
||||
}
|
||||
|
||||
const toRuleBlock = (rule) => ` NotificationRuleDefinition(
|
||||
id = ${toKotlinString(rule.id)},
|
||||
label = ${toKotlinString(rule.label)},
|
||||
channels = ${toKotlinStringList(rule.channels)},
|
||||
keywords = ${toKotlinStringList(rule.keywords)},
|
||||
requiredTextPatterns = ${toKotlinStringList(rule.requiredTextPatterns)},
|
||||
direction = NotificationRuleDirection.${rule.direction === 'income' ? 'INCOME' : 'EXPENSE'},
|
||||
defaultCategory = ${toKotlinString(rule.defaultCategory)},
|
||||
autoCapture = ${rule.autoCapture ? 'true' : 'false'},
|
||||
merchantPattern = ${toKotlinRegex(rule.merchantPattern)},
|
||||
defaultMerchant = ${
|
||||
rule.defaultMerchant ? toKotlinString(rule.defaultMerchant) : 'null'
|
||||
},
|
||||
)`
|
||||
|
||||
const content = `package com.echo.app.notification
|
||||
|
||||
// Generated from src/config/notificationRules.js by scripts/sync-notification-rules.mjs.
|
||||
internal object NotificationRuleData {
|
||||
val fallbackRules: List<NotificationRuleDefinition> = listOf(
|
||||
${notificationRules.map(toRuleBlock).join(',\n')}
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
fs.writeFileSync(targetFile, content)
|
||||
console.log(`[rules:sync] wrote ${path.relative(rootDir, targetFile)}`)
|
||||
14
src/App.vue
14
src/App.vue
@@ -4,24 +4,22 @@ import { RouterView } from 'vue-router'
|
||||
import { App } from '@capacitor/app'
|
||||
import BottomDock from './components/BottomDock.vue'
|
||||
import AddEntryView from './views/AddEntryView.vue'
|
||||
import { useTransactionStore } from './stores/transactions'
|
||||
import { bootstrapApp } from './services/appBootstrap.js'
|
||||
import { useUiStore } from './stores/ui'
|
||||
|
||||
const transactionStore = useTransactionStore()
|
||||
const uiStore = useUiStore()
|
||||
|
||||
let backButtonListener = null
|
||||
|
||||
onMounted(() => {
|
||||
transactionStore.ensureInitialized()
|
||||
void bootstrapApp()
|
||||
|
||||
// Android 物理返回键:如果新增记录面板打开,则优先关闭面板而不是直接退出应用
|
||||
onMounted(() => {
|
||||
if (typeof App?.addListener === 'function') {
|
||||
App.addListener('backButton', ({ canGoBack }) => {
|
||||
if (uiStore.addEntryVisible) {
|
||||
uiStore.closeAddEntry()
|
||||
} else if (!canGoBack) {
|
||||
// 这里保留默认行为(由 Capacitor 处理),不强制 exitApp
|
||||
// Keep Capacitor default back behavior.
|
||||
}
|
||||
})
|
||||
.then((handle) => {
|
||||
@@ -42,21 +40,17 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 整体应用容器:居中展示移动端画布,配合沉浸式状态栏使用安全区域内边距 -->
|
||||
<div class="min-h-screen bg-warmOffwhite text-stone-800 flex items-center justify-center">
|
||||
<div
|
||||
class="h-screen max-h-[844px] max-w-md w-full mx-auto bg-warmOffwhite shadow-2xl relative overflow-hidden flex flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom);"
|
||||
>
|
||||
<!-- 主内容区域:由 vue-router 控制具体页面 -->
|
||||
<main class="flex-1 overflow-y-auto hide-scrollbar px-5 pt-2 pb-28 relative">
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
<!-- 底部 Dock 导航 -->
|
||||
<BottomDock />
|
||||
|
||||
<!-- 新增记录底部弹窗:在任意 Tab 上浮层显示 -->
|
||||
<transition name="slide-up">
|
||||
<AddEntryView v-if="uiStore.addEntryVisible" />
|
||||
</transition>
|
||||
|
||||
153
src/config/notificationRules.js
Normal file
153
src/config/notificationRules.js
Normal file
@@ -0,0 +1,153 @@
|
||||
const notificationRules = [
|
||||
{
|
||||
id: 'alipay-expense',
|
||||
label: '支付宝消费',
|
||||
channels: ['支付宝', 'Alipay'],
|
||||
keywords: ['支付', '扣款', '支出', '消费', '成功支付'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Food',
|
||||
autoCapture: false,
|
||||
merchantPattern: {
|
||||
pattern: '(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
|
||||
flags: 'i',
|
||||
},
|
||||
defaultMerchant: '',
|
||||
},
|
||||
{
|
||||
id: 'alipay-income',
|
||||
label: '支付宝收款',
|
||||
channels: ['支付宝', 'Alipay'],
|
||||
keywords: ['到账', '收入', '收款', '入账'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'income',
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: {
|
||||
pattern: '(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
|
||||
flags: 'i',
|
||||
},
|
||||
defaultMerchant: '',
|
||||
},
|
||||
{
|
||||
id: 'wechat-expense',
|
||||
label: '微信消费',
|
||||
channels: ['微信支付', '微信', 'WeChat'],
|
||||
keywords: ['支付', '支出', '扣款', '消费成功'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Groceries',
|
||||
autoCapture: false,
|
||||
merchantPattern: {
|
||||
pattern: '(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
|
||||
flags: '',
|
||||
},
|
||||
defaultMerchant: '',
|
||||
},
|
||||
{
|
||||
id: 'wechat-income',
|
||||
label: '微信收款',
|
||||
channels: ['微信支付', '微信', 'WeChat'],
|
||||
keywords: ['收款', '到账', '入账', '转入'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'income',
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: {
|
||||
pattern: '(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
|
||||
flags: '',
|
||||
},
|
||||
defaultMerchant: '',
|
||||
},
|
||||
{
|
||||
id: 'bank-income',
|
||||
label: '银行入账',
|
||||
channels: [
|
||||
'招商银行',
|
||||
'中国银行',
|
||||
'建设银行',
|
||||
'工商银行',
|
||||
'农业银行',
|
||||
'交通银行',
|
||||
'浦发银行',
|
||||
'兴业银行',
|
||||
'光大银行',
|
||||
'中信银行',
|
||||
'广发银行',
|
||||
'平安银行',
|
||||
],
|
||||
keywords: ['工资', '薪资', '代发', '到账', '入账', '收入'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'income',
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: {
|
||||
pattern: '(?:来自|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
|
||||
flags: '',
|
||||
},
|
||||
defaultMerchant: '',
|
||||
},
|
||||
{
|
||||
id: 'bank-expense',
|
||||
label: '银行卡支出',
|
||||
channels: [
|
||||
'招商银行',
|
||||
'中国银行',
|
||||
'建设银行',
|
||||
'工商银行',
|
||||
'农业银行',
|
||||
'交通银行',
|
||||
'浦发银行',
|
||||
'兴业银行',
|
||||
'光大银行',
|
||||
'中信银行',
|
||||
'广发银行',
|
||||
'平安银行',
|
||||
'借记卡',
|
||||
'信用卡',
|
||||
],
|
||||
keywords: ['支出', '消费', '扣款', '刷卡消费', '银联消费'],
|
||||
requiredTextPatterns: [],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Expense',
|
||||
autoCapture: false,
|
||||
merchantPattern: {
|
||||
pattern: '(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)',
|
||||
flags: '',
|
||||
},
|
||||
defaultMerchant: '',
|
||||
},
|
||||
{
|
||||
id: 'transit-card-expense',
|
||||
label: '公交出行',
|
||||
channels: [
|
||||
'杭州通互联互通卡',
|
||||
'杭州通',
|
||||
'北京一卡通',
|
||||
'上海公共交通卡',
|
||||
'深圳通',
|
||||
'苏州公交卡',
|
||||
'交通联合',
|
||||
'乘车码',
|
||||
'地铁',
|
||||
'公交',
|
||||
'Mi Pay',
|
||||
'Huawei Pay',
|
||||
'华为钱包',
|
||||
'小米钱包',
|
||||
'OPPO 钱包',
|
||||
],
|
||||
keywords: [],
|
||||
requiredTextPatterns: ['扣费', '扣款', '支付', '乘车码', '车费', '票价', '本次乘车', '实付', '优惠后'],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Transport',
|
||||
autoCapture: true,
|
||||
merchantPattern: {
|
||||
pattern: '([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})',
|
||||
flags: '',
|
||||
},
|
||||
defaultMerchant: '公交/地铁出行',
|
||||
},
|
||||
]
|
||||
|
||||
export default notificationRules
|
||||
90
src/config/transactionCategories.js
Normal file
90
src/config/transactionCategories.js
Normal file
@@ -0,0 +1,90 @@
|
||||
export const TRANSACTION_CATEGORIES = [
|
||||
{
|
||||
value: 'Food',
|
||||
label: '餐饮',
|
||||
icon: 'ph-bowl-food',
|
||||
bg: 'bg-orange-100',
|
||||
color: 'text-orange-600',
|
||||
budgetable: true,
|
||||
},
|
||||
{
|
||||
value: 'Transport',
|
||||
label: '通勤',
|
||||
icon: 'ph-taxi',
|
||||
bg: 'bg-blue-100',
|
||||
color: 'text-blue-600',
|
||||
budgetable: true,
|
||||
},
|
||||
{
|
||||
value: 'Health',
|
||||
label: '健康',
|
||||
icon: 'ph-heartbeat',
|
||||
bg: 'bg-rose-100',
|
||||
color: 'text-rose-600',
|
||||
budgetable: true,
|
||||
},
|
||||
{
|
||||
value: 'Groceries',
|
||||
label: '买菜',
|
||||
icon: 'ph-basket',
|
||||
bg: 'bg-amber-100',
|
||||
color: 'text-amber-600',
|
||||
budgetable: true,
|
||||
},
|
||||
{
|
||||
value: 'Entertainment',
|
||||
label: '娱乐',
|
||||
icon: 'ph-game-controller',
|
||||
bg: 'bg-fuchsia-100',
|
||||
color: 'text-fuchsia-600',
|
||||
budgetable: true,
|
||||
},
|
||||
{
|
||||
value: 'Expense',
|
||||
label: '一般支出',
|
||||
icon: 'ph-receipt',
|
||||
bg: 'bg-stone-100',
|
||||
color: 'text-stone-600',
|
||||
budgetable: true,
|
||||
},
|
||||
{
|
||||
value: 'Income',
|
||||
label: '收入',
|
||||
icon: 'ph-wallet',
|
||||
bg: 'bg-emerald-100',
|
||||
color: 'text-emerald-600',
|
||||
budgetable: false,
|
||||
},
|
||||
{
|
||||
value: 'Uncategorized',
|
||||
label: '其他',
|
||||
icon: 'ph-note-pencil',
|
||||
bg: 'bg-stone-100',
|
||||
color: 'text-stone-500',
|
||||
budgetable: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_TRANSACTION_CATEGORY = 'Food'
|
||||
|
||||
export const CATEGORY_CHIPS = [
|
||||
{ label: '全部', value: 'all', icon: 'ph-asterisk' },
|
||||
...TRANSACTION_CATEGORIES.map(({ label, value, icon }) => ({ label, value, icon })),
|
||||
]
|
||||
|
||||
export const CATEGORY_META = TRANSACTION_CATEGORIES.reduce((acc, category) => {
|
||||
acc[category.value] = category
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
export const BUDGET_CATEGORY_OPTIONS = TRANSACTION_CATEGORIES.filter((category) => category.budgetable)
|
||||
|
||||
export const DEFAULT_CATEGORY_BUDGETS = BUDGET_CATEGORY_OPTIONS.reduce((acc, category) => {
|
||||
acc[category.value] = 0
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
export const getCategoryMeta = (category) =>
|
||||
CATEGORY_META[category] || CATEGORY_META.Uncategorized
|
||||
|
||||
export const getCategoryLabel = (category) => getCategoryMeta(category).label
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import '@phosphor-icons/web/regular'
|
||||
import '@phosphor-icons/web/bold'
|
||||
@@ -10,7 +10,7 @@ import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import { defineCustomElements as jeepSqliteCE } from 'jeep-sqlite/loader'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
// 注册 jeep-sqlite 自定义元素并保证 Web 端具备 Capacitor 环境
|
||||
// 注册 jeep-sqlite 自定义元素,并为 Web 环境挂载 Capacitor 对象。
|
||||
window.Capacitor = Capacitor
|
||||
jeepSqliteCE(window)
|
||||
|
||||
|
||||
15
src/services/appBootstrap.js
Normal file
15
src/services/appBootstrap.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useTransactionStore } from '../stores/transactions'
|
||||
|
||||
let bootstrapPromise = null
|
||||
|
||||
export const bootstrapApp = async () => {
|
||||
if (!bootstrapPromise) {
|
||||
const transactionStore = useTransactionStore()
|
||||
bootstrapPromise = transactionStore.ensureInitialized().catch((error) => {
|
||||
bootstrapPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return bootstrapPromise
|
||||
}
|
||||
@@ -1,150 +1,32 @@
|
||||
const fallbackRules = [
|
||||
// 支付宝消费:日常吃饭、网购等
|
||||
{
|
||||
id: 'alipay-expense',
|
||||
label: '支付宝消费',
|
||||
channels: ['支付宝', 'Alipay'],
|
||||
keywords: ['支付', '扣款', '支出', '消费', '成功支付'],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Food',
|
||||
autoCapture: false,
|
||||
merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i,
|
||||
},
|
||||
// 支付宝收款 / 到账
|
||||
{
|
||||
id: 'alipay-income',
|
||||
label: '支付宝收款',
|
||||
channels: ['支付宝', 'Alipay'],
|
||||
keywords: ['到账', '收入', '收款', '入账'],
|
||||
direction: 'income',
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: /(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i,
|
||||
},
|
||||
// 微信消费
|
||||
{
|
||||
id: 'wechat-expense',
|
||||
label: '微信消费',
|
||||
channels: ['微信支付', '微信', 'WeChat'],
|
||||
keywords: ['支付', '支出', '扣款', '消费成功'],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Groceries',
|
||||
autoCapture: false,
|
||||
merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||||
},
|
||||
// 微信收款 / 转账入账
|
||||
{
|
||||
id: 'wechat-income',
|
||||
label: '微信收款',
|
||||
channels: ['微信支付', '微信', 'WeChat'],
|
||||
keywords: ['收款', '到账', '入账', '转入'],
|
||||
direction: 'income',
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: /(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||||
},
|
||||
// 银行工资 / 常规入账
|
||||
{
|
||||
id: 'bank-income',
|
||||
label: '工资到账',
|
||||
channels: [
|
||||
'招商银行',
|
||||
'中国银行',
|
||||
'建设银行',
|
||||
'工商银行',
|
||||
'农业银行',
|
||||
'交通银行',
|
||||
'浦发银行',
|
||||
'兴业银行',
|
||||
'光大银行',
|
||||
'中信银行',
|
||||
'广发银行',
|
||||
'平安银行',
|
||||
],
|
||||
keywords: ['工资', '薪资', '代发', '到账', '入账', '收入'],
|
||||
direction: 'income',
|
||||
defaultCategory: 'Income',
|
||||
autoCapture: true,
|
||||
merchantPattern: /(?:来自|付款方|来源)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||||
},
|
||||
// 银行消费 / 借记卡扣款
|
||||
{
|
||||
id: 'bank-expense',
|
||||
label: '银行卡支出',
|
||||
channels: [
|
||||
'招商银行',
|
||||
'中国银行',
|
||||
'建设银行',
|
||||
'工商银行',
|
||||
'农业银行',
|
||||
'交通银行',
|
||||
'浦发银行',
|
||||
'兴业银行',
|
||||
'光大银行',
|
||||
'中信银行',
|
||||
'广发银行',
|
||||
'平安银行',
|
||||
'借记卡',
|
||||
'信用卡',
|
||||
],
|
||||
keywords: ['支出', '消费', '扣款', '刷卡消费', '银联消费'],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Expense',
|
||||
autoCapture: false,
|
||||
merchantPattern: /(?:商户|商家|消费商户)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||||
},
|
||||
// 手机公交卡 / 乘车码扣款(地铁、公交等),按交通分类
|
||||
{
|
||||
id: 'transit-card-expense',
|
||||
label: '公交出行',
|
||||
channels: [
|
||||
'杭州通互联互通卡',
|
||||
'杭州通',
|
||||
'北京一卡通',
|
||||
'上海公共交通卡',
|
||||
'深圳通',
|
||||
'苏州公交卡',
|
||||
'交通联合',
|
||||
'乘车码',
|
||||
'地铁',
|
||||
'公交',
|
||||
'Mi Pay',
|
||||
'Huawei Pay',
|
||||
'华为钱包',
|
||||
'小米钱包',
|
||||
'OPPO 钱包',
|
||||
],
|
||||
// 对公交卡类通知,只要来源匹配即可视为出行支出,不强制正文关键字
|
||||
keywords: [],
|
||||
direction: 'expense',
|
||||
defaultCategory: 'Transport',
|
||||
autoCapture: true,
|
||||
// 优先从正文中提取「文三路->祥园路」之类的起点-终点信息作为“商户”
|
||||
merchantPattern: /([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到)\s*[\u4e00-\u9fa5]{2,})/,
|
||||
// 若无法提取线路,则使用统一名称
|
||||
defaultMerchant: '公交/地铁出行',
|
||||
},
|
||||
import notificationRulesSource from '../config/notificationRules.js'
|
||||
|
||||
const fallbackRules = notificationRulesSource.map((rule) => ({
|
||||
...rule,
|
||||
merchantPattern: rule.merchantPattern?.pattern
|
||||
? new RegExp(rule.merchantPattern.pattern, rule.merchantPattern.flags || '')
|
||||
: null,
|
||||
}))
|
||||
|
||||
const amountPatterns = [
|
||||
/(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)/i,
|
||||
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)/,
|
||||
/(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车)\D{0,8}?(-?\d+(?:\.\d{1,2})?)/,
|
||||
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)/,
|
||||
]
|
||||
|
||||
let cachedRules = [...fallbackRules]
|
||||
let remoteRuleFetcher = async () => []
|
||||
|
||||
/**
|
||||
* 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际的 fetch 函数。
|
||||
* 服务端可以返回一组规则对象,形如:
|
||||
* {
|
||||
* id: string;
|
||||
* label?: string;
|
||||
* enabled?: boolean; // false 时在本地完全忽略此规则
|
||||
* priority?: number; // 预留优先级字段(当前仅在服务端使用)
|
||||
* channels?: string[]; // 匹配通知来源(标题 / 包名)
|
||||
* keywords?: string[]; // 匹配通知正文的关键字
|
||||
* direction?: 'income' | 'expense';
|
||||
* defaultCategory?: string;
|
||||
* autoCapture?: boolean;
|
||||
* }
|
||||
* @param {(currentRules: Array) => Promise<Array>} fetcher
|
||||
*/
|
||||
const buildEmptyTransaction = (notification, options = {}) => ({
|
||||
id: options.id,
|
||||
merchant: options.merchant || 'Unknown',
|
||||
category: options.category || 'Uncategorized',
|
||||
amount: 0,
|
||||
date: new Date(options.date || notification?.createdAt || new Date().toISOString()).toISOString(),
|
||||
note: notification?.text || '',
|
||||
syncStatus: 'pending',
|
||||
})
|
||||
|
||||
export const setRemoteRuleFetcher = (fetcher) => {
|
||||
remoteRuleFetcher = typeof fetcher === 'function' ? fetcher : remoteRuleFetcher
|
||||
}
|
||||
@@ -167,47 +49,44 @@ export const loadNotificationRules = async () => {
|
||||
}
|
||||
} catch (err) {
|
||||
cachedRules = [...cachedRules]
|
||||
console.warn('[rules] 使用本地规则,远程规则获取失败:', err)
|
||||
console.warn('[rules] using cached fallback rules because remote sync failed:', err)
|
||||
}
|
||||
return cachedRules
|
||||
}
|
||||
|
||||
export const getCachedNotificationRules = () => cachedRules
|
||||
|
||||
// 从通知文本中提取交易金额,兼容「¥」「¥」「元」「人民币」等常见形式
|
||||
const extractAmount = (text = '') => {
|
||||
const primary = text.match(
|
||||
/(?:[¥¥]|人民币)\s*(-?\d+(?:\.\d{1,2})?)|(-?\d+(?:\.\d{1,2})?)\s*元/,
|
||||
)
|
||||
const value = primary?.[1] || primary?.[2]
|
||||
if (value) {
|
||||
return Math.abs(parseFloat(value))
|
||||
const normalized = String(text).replace(/,/g, '').trim()
|
||||
|
||||
for (const pattern of amountPatterns) {
|
||||
const match = normalized.match(pattern)
|
||||
const value = match?.[1]
|
||||
if (!value) continue
|
||||
const parsed = Math.abs(Number.parseFloat(value))
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
const loose = text.match(/(-?\d+(?:\.\d{1,2})?)/)
|
||||
return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// 判断字符串是否更像是「金额」而不是商户名,用于避免把「1.00元」当成商户
|
||||
const looksLikeAmount = (raw = '') => {
|
||||
const value = String(raw).trim()
|
||||
if (!value) return false
|
||||
const amountPattern = /^[-\d.,]+\s*(?:元|块钱|¥|¥|人民币)?$/
|
||||
return amountPattern.test(value)
|
||||
return /^[-\d.,]+\s*(?:元|块|块钱|¥|¥|人民币)?$/i.test(value)
|
||||
}
|
||||
|
||||
const extractMerchant = (text, rule) => {
|
||||
const content = text || ''
|
||||
|
||||
if (rule?.merchantPattern instanceof RegExp) {
|
||||
const m = content.match(rule.merchantPattern)
|
||||
if (m?.[1]) return m[1].trim()
|
||||
const matched = content.match(rule.merchantPattern)
|
||||
if (matched?.[1]) return matched[1].trim()
|
||||
}
|
||||
|
||||
// 通用的「站点 A -> 站点 B」线路模式(公交/地铁通知),
|
||||
// 即使未命中专用规则也尝试提取,避免退化为金额或「Unknown」
|
||||
const routeMatch = content.match(
|
||||
/([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到|->)\s*[\u4e00-\u9fa5]{2,})/,
|
||||
)
|
||||
const routeMatch = content.match(/([\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,})/)
|
||||
if (routeMatch?.[1]) {
|
||||
return routeMatch[1].trim()
|
||||
}
|
||||
@@ -218,16 +97,14 @@ const extractMerchant = (text, rule) => {
|
||||
]
|
||||
|
||||
for (const pattern of genericPatterns) {
|
||||
const m = content.match(pattern)
|
||||
if (m?.[1]) return m[1].trim()
|
||||
const matched = content.match(pattern)
|
||||
if (matched?.[1]) return matched[1].trim()
|
||||
}
|
||||
|
||||
// 针对「……支出(消费支付宝-上海拉扎斯信息科技有限公司)3.23元」这类银行通知,
|
||||
// 优先尝试从括号中的「支付宝-商户名」结构中提取真正的商户名
|
||||
const parenMatch = content.match(/\(([^)]+)\)/)
|
||||
if (parenMatch?.[1]) {
|
||||
const inner = parenMatch[1]
|
||||
const payMatch = inner.match(/(?:支付宝|微信支付|微信)[-—\s]*([^\d元¥¥]+)$/)
|
||||
const payMatch = inner.match(/(?:支付宝|微信支付|微信)[-\s]*([^\d元¥¥]+)$/)
|
||||
if (payMatch?.[1]) {
|
||||
const candidate = payMatch[1].trim()
|
||||
if (candidate && !looksLikeAmount(candidate)) {
|
||||
@@ -247,32 +124,48 @@ const extractMerchant = (text, rule) => {
|
||||
|
||||
const matchRule = (notification, rule) => {
|
||||
if (!rule || rule.enabled === false) return false
|
||||
|
||||
const channel = notification?.channel || ''
|
||||
const text = notification?.text || ''
|
||||
// 频道命中:优先用通知标题/来源匹配,退而求其次用正文包含匹配(兼容 adb shell / 部分 ROM)
|
||||
const channelHit =
|
||||
!rule.channels?.length ||
|
||||
rule.channels.some((item) => channel.includes(item) || text.includes(item))
|
||||
rule.channels.some((item) => channel.includes(item)) ||
|
||||
(!channel && rule.channels.some((item) => text.includes(item)))
|
||||
const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word))
|
||||
return channelHit && keywordHit
|
||||
const requiredTextHit =
|
||||
!rule.requiredTextPatterns?.length ||
|
||||
rule.requiredTextPatterns.some((word) => text.includes(word))
|
||||
|
||||
return channelHit && keywordHit && requiredTextHit
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据当前规则集生成交易草稿,并返回命中的规则信息
|
||||
* @param {{ id: string, channel: string, text: string, createdAt: string }} notification
|
||||
* @param {{ ruleSet?: Array, overrides?: object }} options
|
||||
* @returns {{ transaction: import('../types/transaction').Transaction, rule?: object, requiresConfirmation: boolean }}
|
||||
*/
|
||||
export const transformNotificationToTransaction = (notification, options = {}) => {
|
||||
const ruleSet = options.ruleSet?.length ? options.ruleSet : cachedRules
|
||||
const rule = ruleSet.find((item) => matchRule(notification, item))
|
||||
const amountFromText = extractAmount(notification?.text)
|
||||
const direction = options.direction || rule?.direction || 'expense'
|
||||
|
||||
if (!rule) {
|
||||
return {
|
||||
transaction: buildEmptyTransaction(notification, options),
|
||||
rule: undefined,
|
||||
requiresConfirmation: false,
|
||||
}
|
||||
}
|
||||
|
||||
const amountFromText = extractAmount(notification?.text || '')
|
||||
if (amountFromText <= 0) {
|
||||
return {
|
||||
transaction: buildEmptyTransaction(notification, options),
|
||||
rule: undefined,
|
||||
requiresConfirmation: false,
|
||||
}
|
||||
}
|
||||
|
||||
const direction = options.direction || rule.direction || 'expense'
|
||||
const normalizedAmount =
|
||||
direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText)
|
||||
|
||||
let merchant = options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown'
|
||||
if (merchant === 'Unknown' && rule?.defaultMerchant) {
|
||||
if (merchant === 'Unknown' && rule.defaultMerchant) {
|
||||
merchant = rule.defaultMerchant
|
||||
}
|
||||
|
||||
@@ -281,8 +174,8 @@ export const transformNotificationToTransaction = (notification, options = {}) =
|
||||
const transaction = {
|
||||
id: options.id,
|
||||
merchant,
|
||||
category: options.category || rule?.defaultCategory || 'Uncategorized',
|
||||
amount: Number.isFinite(normalizedAmount) ? normalizedAmount : 0,
|
||||
category: options.category || rule.defaultCategory || 'Uncategorized',
|
||||
amount: normalizedAmount,
|
||||
date: new Date(date).toISOString(),
|
||||
note: notification?.text || '',
|
||||
syncStatus: 'pending',
|
||||
@@ -290,7 +183,7 @@ export const transformNotificationToTransaction = (notification, options = {}) =
|
||||
|
||||
const requiresConfirmation =
|
||||
options.requiresConfirmation ??
|
||||
!(rule?.autoCapture && transaction.amount !== 0 && merchant !== 'Unknown')
|
||||
!(rule.autoCapture && transaction.amount !== 0 && merchant !== 'Unknown')
|
||||
|
||||
return { transaction, rule, requiresConfirmation }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { DEFAULT_CATEGORY_BUDGETS } from '../config/transactionCategories.js'
|
||||
|
||||
export const useSettingsStore = defineStore(
|
||||
'settings',
|
||||
@@ -7,17 +8,10 @@ export const useSettingsStore = defineStore(
|
||||
const notificationCaptureEnabled = ref(true)
|
||||
const aiAutoCategoryEnabled = ref(false)
|
||||
const monthlyBudget = ref(12000)
|
||||
const budgetResetCycle = ref('monthly') // monthly | weekly | none | custom
|
||||
const budgetMonthlyResetDay = ref(1) // 每月第几天重置,1-28
|
||||
const budgetCustomStartDate = ref('') // 自定义起始日(ISO 日期字符串)
|
||||
const categoryBudgets = ref({
|
||||
Food: 0,
|
||||
Transport: 0,
|
||||
Health: 0,
|
||||
Groceries: 0,
|
||||
Entertainment: 0,
|
||||
Uncategorized: 0,
|
||||
})
|
||||
const budgetResetCycle = ref('monthly')
|
||||
const budgetMonthlyResetDay = ref(1)
|
||||
const budgetCustomStartDate = ref('')
|
||||
const categoryBudgets = ref({ ...DEFAULT_CATEGORY_BUDGETS })
|
||||
const categoryBudgetEnabled = ref(false)
|
||||
const profileName = ref('Echo 用户')
|
||||
const profileAvatar = ref('')
|
||||
@@ -115,4 +109,3 @@ export const useSettingsStore = defineStore(
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
DEFAULT_TRANSACTION_CATEGORY,
|
||||
TRANSACTION_CATEGORIES,
|
||||
getCategoryLabel,
|
||||
} from '../config/transactionCategories.js'
|
||||
import { useTransactionStore } from '../stores/transactions'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
import EchoInput from '../components/EchoInput.vue'
|
||||
@@ -14,7 +19,7 @@ const dragStartY = ref(0)
|
||||
const dragging = ref(false)
|
||||
const dragOffset = ref(0)
|
||||
|
||||
const categories = ['Food', 'Transport', 'Health', 'Groceries', 'Entertainment', 'Income', 'Uncategorized']
|
||||
const categories = TRANSACTION_CATEGORIES
|
||||
|
||||
const toDatetimeLocal = (value) => {
|
||||
const date = value ? new Date(value) : new Date()
|
||||
@@ -26,7 +31,7 @@ const form = reactive({
|
||||
type: 'expense',
|
||||
amount: '',
|
||||
merchant: '',
|
||||
category: 'Food',
|
||||
category: DEFAULT_TRANSACTION_CATEGORY,
|
||||
note: '',
|
||||
date: toDatetimeLocal(),
|
||||
})
|
||||
@@ -35,7 +40,7 @@ const resetForm = () => {
|
||||
form.type = 'expense'
|
||||
form.amount = ''
|
||||
form.merchant = ''
|
||||
form.category = 'Food'
|
||||
form.category = DEFAULT_TRANSACTION_CATEGORY
|
||||
form.note = ''
|
||||
form.date = toDatetimeLocal()
|
||||
}
|
||||
@@ -146,8 +151,6 @@ const sheetStyle = computed(() => {
|
||||
transition: dragging.value ? 'none' : 'transform 0.2s ease-out',
|
||||
}
|
||||
})
|
||||
|
||||
transactionStore.ensureInitialized()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -169,10 +172,10 @@ transactionStore.ensureInitialized()
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<p class="text-xs text-stone-400 font-bold uppercase tracking-widest">
|
||||
{{ editingId ? '编辑记录' : '记录新消费' }}
|
||||
{{ editingId ? '编辑记录' : '记录新流水' }}
|
||||
</p>
|
||||
<h3 class="text-xl font-extrabold text-stone-800">
|
||||
{{ editingId ? '更新本地账本' : '快速入账' }}
|
||||
{{ editingId ? '更新本地账本' : '快速记一笔' }}
|
||||
</h3>
|
||||
</div>
|
||||
<button class="text-stone-400 text-sm font-bold" @click="closePanel">关闭</button>
|
||||
@@ -214,7 +217,7 @@ transactionStore.ensureInitialized()
|
||||
input-class="text-2xl font-bold"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="text-stone-400 text-sm">¥</span>
|
||||
<span class="text-stone-400 text-sm">楼</span>
|
||||
</template>
|
||||
</EchoInput>
|
||||
|
||||
@@ -229,16 +232,16 @@ transactionStore.ensureInitialized()
|
||||
<div class="mt-2 flex gap-2 overflow-x-auto hide-scrollbar pb-1">
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
:key="category.value"
|
||||
class="px-4 py-2 rounded-2xl text-xs font-bold border transition"
|
||||
:class="
|
||||
form.category === category
|
||||
form.category === category.value
|
||||
? 'bg-gradient-warm text-white border-transparent'
|
||||
: 'bg-stone-50 text-stone-500 border-stone-100'
|
||||
"
|
||||
@click="form.category = category"
|
||||
@click="form.category = category.value"
|
||||
>
|
||||
{{ category }}
|
||||
{{ getCategoryLabel(category.value) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,7 +261,7 @@ transactionStore.ensureInitialized()
|
||||
v-model="form.note"
|
||||
rows="2"
|
||||
class="mt-2 w-full rounded-2xl border border-stone-200 px-4 py-3 text-sm focus:outline-none focus:border-stone-400 resize-none"
|
||||
placeholder="可记录口味、心情或健康状态"
|
||||
placeholder="可记录口味、心情或其他背景信息"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -272,7 +275,7 @@ transactionStore.ensureInitialized()
|
||||
:disabled="saving"
|
||||
@click="submitForm"
|
||||
>
|
||||
{{ saving ? '保存中...' : editingId ? '保存修改' : '立即入账' }}
|
||||
{{ saving ? '保存中...' : editingId ? '保存修改' : '立即记账' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
// 分析页目前为静态占位,实现基础对话 UI,后续接入真实 AI 逻辑
|
||||
<script setup>
|
||||
// 分析页当前仍是占位页,先展示未来 AI 财务顾问的交互方向。
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -12,13 +12,11 @@
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-extrabold text-stone-800">AI 财务顾问</h2>
|
||||
<p class="text-xs text-stone-400">Powered by Gemini</p>
|
||||
<p class="text-xs text-stone-400">即将接入对话分析与自动分类</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div class="flex-1 overflow-y-auto space-y-5 pr-1 pb-2">
|
||||
<!-- Bot Message -->
|
||||
<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"
|
||||
@@ -28,30 +26,28 @@
|
||||
<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>你好 Alex!👋 我分析了你本周的饮食记录。</p>
|
||||
<p>我已经整理了你最近 30 天的消费记录。</p>
|
||||
<div class="mt-3 bg-stone-50 rounded-xl p-3 border border-stone-100">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="ph-fill ph-warning text-orange-400" />
|
||||
<span class="font-bold text-stone-700 text-xs">高热量警告</span>
|
||||
<i class="ph-fill ph-chart-line text-orange-400" />
|
||||
<span class="font-bold text-stone-700 text-xs">本月摘要</span>
|
||||
</div>
|
||||
<p class="text-xs">
|
||||
周二和周四的晚餐摄入热量超标 30%,主要是因为“炸鸡”和“奶茶”。
|
||||
餐饮与通勤是主要支出来源,夜间消费频率明显高于白天,预算消耗速度偏快。
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-3">建议今晚尝试清淡饮食,比如蔬菜沙拉或三文鱼。</p>
|
||||
<p class="mt-3">下一步会支持自动分类、标签建议、异常支出提醒和自然语言问答。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Message -->
|
||||
<div class="flex gap-3 flex-row-reverse">
|
||||
<div
|
||||
class="bg-stone-700 text-white p-4 rounded-2xl rounded-tr-none shadow-lg text-sm max-w-[85%]"
|
||||
>
|
||||
如果是自己做饭,有什么推荐的食谱吗?要简单的。
|
||||
帮我总结这周花钱最多的三类项目。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bot Message 2 -->
|
||||
<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"
|
||||
@@ -61,22 +57,20 @@
|
||||
<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>
|
||||
<p class="font-bold text-stone-800 mt-2 mb-1">🥑 牛油果虾仁拌饭</p>
|
||||
<ul class="list-disc list-inside text-xs space-y-1 marker:text-orange-400">
|
||||
<li>热量:约 450 kcal</li>
|
||||
<li>耗时:10 分钟</li>
|
||||
<li>成本:约 ¥18</li>
|
||||
<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>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="mt-2 relative shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="输入消息..."
|
||||
placeholder="输入想分析的问题..."
|
||||
class="w-full pl-5 pr-12 py-3.5 rounded-full bg-white border border-stone-100 focus:outline-none focus:border-orange-300 focus:ring-4 focus:ring-orange-50 transition text-sm shadow-sm text-stone-700 font-medium placeholder-stone-300"
|
||||
/>
|
||||
<button
|
||||
@@ -87,4 +81,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { App } from '@capacitor/app'
|
||||
@@ -7,6 +7,8 @@ import { useTransactionStore } from '../stores/transactions'
|
||||
import { useTransactionEntry } from '../composables/useTransactionEntry'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
import { getCategoryLabel, getCategoryMeta } from '../config/transactionCategories.js'
|
||||
import { bootstrapApp } from '../services/appBootstrap.js'
|
||||
|
||||
const router = useRouter()
|
||||
const transactionStore = useTransactionStore()
|
||||
@@ -17,13 +19,15 @@ const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactio
|
||||
const { notifications, confirmNotification, dismissNotification, processingId, syncNotifications } =
|
||||
useTransactionEntry()
|
||||
|
||||
const defaultAvatar = '/default-avatar.svg'
|
||||
let appStateListener = null
|
||||
|
||||
onMounted(async () => {
|
||||
await transactionStore.ensureInitialized()
|
||||
await bootstrapApp()
|
||||
await syncNotifications()
|
||||
|
||||
try {
|
||||
// App 从后台回到前台时,自动刷新本地账本和通知队列
|
||||
// App 回到前台时,刷新本地账本和待处理通知。
|
||||
appStateListener = await App.addListener('appStateChange', async ({ isActive }) => {
|
||||
if (isActive) {
|
||||
await transactionStore.hydrateTransactions()
|
||||
@@ -31,7 +35,6 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Web 环境或插件不可用时忽略
|
||||
console.warn('[notifications] appStateChange listener failed', error)
|
||||
}
|
||||
})
|
||||
@@ -46,50 +49,47 @@ onBeforeUnmount(() => {
|
||||
const monthlyBudget = computed(() => settingsStore.monthlyBudget || 0)
|
||||
const budgetResetCycle = computed(() => settingsStore.budgetResetCycle || 'monthly')
|
||||
|
||||
const currentDayLabel = computed(
|
||||
() =>
|
||||
new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
}).format(new Date()),
|
||||
const currentDayLabel = computed(() =>
|
||||
new Intl.DateTimeFormat('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
}).format(new Date()),
|
||||
)
|
||||
|
||||
const periodStart = computed(() => {
|
||||
const cycle = budgetResetCycle.value
|
||||
const now = new Date()
|
||||
|
||||
if (cycle === 'weekly') {
|
||||
const day = now.getDay() || 7 // 周一=1,周日=7
|
||||
const diff = day - 1
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - diff)
|
||||
const day = now.getDay() || 7
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - (day - 1))
|
||||
}
|
||||
|
||||
if (cycle === 'monthly') {
|
||||
const day = settingsStore.budgetMonthlyResetDay || 1
|
||||
const safeDay = Math.min(Math.max(Number(day) || 1, 1), 28)
|
||||
const candidate = new Date(now.getFullYear(), now.getMonth(), safeDay)
|
||||
if (now >= candidate) {
|
||||
return candidate
|
||||
}
|
||||
if (now >= candidate) return candidate
|
||||
return new Date(now.getFullYear(), now.getMonth() - 1, safeDay)
|
||||
}
|
||||
|
||||
if (cycle === 'custom') {
|
||||
if (!settingsStore.budgetCustomStartDate) return null
|
||||
return new Date(settingsStore.budgetCustomStartDate)
|
||||
}
|
||||
// none:不限制周期
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const periodExpense = computed(() => {
|
||||
const cycle = budgetResetCycle.value
|
||||
const start = periodStart.value
|
||||
if (cycle === 'none' || !start) {
|
||||
return totalExpense.value || 0
|
||||
}
|
||||
if (cycle === 'none' || !start) return totalExpense.value || 0
|
||||
|
||||
return (sortedTransactions.value || []).reduce((sum, tx) => {
|
||||
if (tx.amount >= 0) return sum
|
||||
const date = new Date(tx.date)
|
||||
return date >= start ? sum + tx.amount : sum
|
||||
return new Date(tx.date) >= start ? sum + tx.amount : sum
|
||||
}, 0)
|
||||
})
|
||||
|
||||
@@ -107,29 +107,22 @@ const remainingBudget = computed(() =>
|
||||
|
||||
const todayIncomeValue = computed(() => todaysIncome.value || 0)
|
||||
const todayExpenseValue = computed(() => Math.abs(todaysExpense.value || 0))
|
||||
const recentTransactions = computed(() => latestTransactions.value.slice(0, 4))
|
||||
|
||||
const formatCurrency = (value) => `¥ ${Math.abs(value || 0).toFixed(2)}`
|
||||
const formatAmount = (value) =>
|
||||
`${value >= 0 ? '+' : '-'} ¥${Math.abs(value || 0).toFixed(2)}`
|
||||
const cycleLabel = computed(() => {
|
||||
if (budgetResetCycle.value === 'weekly') return '每周重置'
|
||||
if (budgetResetCycle.value === 'none') return '不重置'
|
||||
if (budgetResetCycle.value === 'custom') return '自定义'
|
||||
return '每月重置'
|
||||
})
|
||||
|
||||
const formatCurrency = (value) => `¥${Math.abs(value || 0).toFixed(2)}`
|
||||
const formatAmount = (value) => `${value >= 0 ? '+' : '-'} ¥${Math.abs(value || 0).toFixed(2)}`
|
||||
const formatTime = (value) =>
|
||||
new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(
|
||||
new Date(value),
|
||||
)
|
||||
|
||||
const recentTransactions = computed(() => latestTransactions.value.slice(0, 4))
|
||||
|
||||
const categoryMeta = {
|
||||
Food: { icon: 'ph-bowl-food', bg: 'bg-orange-100', color: 'text-orange-600' },
|
||||
Transport: { icon: 'ph-taxi', bg: 'bg-blue-100', color: 'text-blue-600' },
|
||||
Health: { icon: 'ph-heartbeat', bg: 'bg-rose-100', color: 'text-rose-600' },
|
||||
Groceries: { icon: 'ph-basket', bg: 'bg-amber-100', color: 'text-amber-600' },
|
||||
Income: { icon: 'ph-wallet', bg: 'bg-emerald-100', color: 'text-emerald-600' },
|
||||
Uncategorized: { icon: 'ph-note-pencil', bg: 'bg-stone-100', color: 'text-stone-500' },
|
||||
default: { icon: 'ph-note-pencil', bg: 'bg-stone-100', color: 'text-stone-500' },
|
||||
}
|
||||
|
||||
const getCategoryMeta = (category) => categoryMeta[category] || categoryMeta.default
|
||||
|
||||
const goSettings = () => {
|
||||
router.push({ name: 'settings' })
|
||||
}
|
||||
@@ -162,11 +155,8 @@ const openTransactionDetail = (tx) => {
|
||||
@click="goSettings"
|
||||
>
|
||||
<img
|
||||
:src="
|
||||
settingsStore.profileAvatar ||
|
||||
'https://api.dicebear.com/7.x/avataaars/svg?seed=Echo'
|
||||
"
|
||||
class="rounded-full"
|
||||
:src="settingsStore.profileAvatar || defaultAvatar"
|
||||
class="rounded-full w-full h-full object-cover"
|
||||
alt="User"
|
||||
/>
|
||||
</div>
|
||||
@@ -181,7 +171,7 @@ const openTransactionDetail = (tx) => {
|
||||
<div class="relative z-10 flex flex-col h-full justify-between">
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-white/80 text-xs font-bold tracking-widest uppercase">本月结余</p>
|
||||
<p class="text-white/80 text-xs font-bold tracking-widest uppercase">当前结余</p>
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-white/20 flex items-center justify-center backdrop-blur-md hover:bg-white/30 transition"
|
||||
>
|
||||
@@ -236,18 +226,7 @@ const openTransactionDetail = (tx) => {
|
||||
<span>剩余 {{ formatCurrency(remainingBudget) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between mt-1 text-[10px] text-stone-400">
|
||||
<span>
|
||||
周期:
|
||||
{{
|
||||
budgetResetCycle === 'weekly'
|
||||
? '每周重置'
|
||||
: budgetResetCycle === 'none'
|
||||
? '不重置'
|
||||
: budgetResetCycle === 'custom'
|
||||
? '自定义'
|
||||
: '每月重置'
|
||||
}}
|
||||
</span>
|
||||
<span>周期:{{ cycleLabel }}</span>
|
||||
<button
|
||||
class="text-[10px] text-stone-400 underline-offset-2 hover:text-stone-600"
|
||||
@click="goSettings"
|
||||
@@ -298,7 +277,7 @@ const openTransactionDetail = (tx) => {
|
||||
<div>
|
||||
<p class="font-bold text-stone-800 text-sm">{{ tx.merchant }}</p>
|
||||
<p class="text-[11px] text-stone-400">
|
||||
{{ formatTime(tx.date) }} · {{ tx.category }}
|
||||
{{ formatTime(tx.date) }} 路 {{ getCategoryLabel(tx.category) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -313,7 +292,7 @@ const openTransactionDetail = (tx) => {
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-stone-400 text-center py-4">
|
||||
还没有记录,快去添加第一笔消费吧~
|
||||
还没有记录,去添加第一笔流水吧。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -325,10 +304,7 @@ const openTransactionDetail = (tx) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notifications.length"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div v-if="notifications.length" class="space-y-3">
|
||||
<div
|
||||
v-for="notif in notifications"
|
||||
:key="notif.id"
|
||||
@@ -341,7 +317,7 @@ const openTransactionDetail = (tx) => {
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="font-bold text-stone-800 text-sm">
|
||||
{{ notif.channel }} · {{ formatTime(notif.createdAt) }}
|
||||
{{ notif.channel }} 路 {{ formatTime(notif.createdAt) }}
|
||||
</h4>
|
||||
<p class="text-xs text-stone-400 leading-relaxed">
|
||||
{{ notif.text }}
|
||||
@@ -365,44 +341,25 @@ const openTransactionDetail = (tx) => {
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-stone-400 text-center py-4 border border-dashed border-stone-100 rounded-2xl">
|
||||
暂无新通知,享受慢生活 ☕️
|
||||
暂无新通知,先安心生活。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
class="bg-white p-5 rounded-3xl shadow-sm border border-stone-100 flex flex-col justify-between h-32 relative overflow-hidden group"
|
||||
@click="goAnalysis"
|
||||
>
|
||||
<div
|
||||
class="bg-white p-5 rounded-3xl shadow-sm border border-stone-100 flex flex-col justify-between h-32 relative overflow-hidden group"
|
||||
@click="goAnalysis"
|
||||
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-purple-50 rounded-full blur-xl group-hover:bg-purple-100 transition"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center z-10"
|
||||
>
|
||||
<div
|
||||
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-purple-50 rounded-full blur-xl group-hover:bg-purple-100 transition"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-purple-100 text-purple-600 flex items-center justify-center z-10"
|
||||
>
|
||||
<i class="ph-fill ph-robot text-xl" />
|
||||
</div>
|
||||
<div class="z-10">
|
||||
<h4 class="font-bold text-stone-700">AI 顾问</h4>
|
||||
<p class="text-xs text-stone-400 mt-1">分析我的消费习惯</p>
|
||||
</div>
|
||||
<i class="ph-fill ph-robot text-xl" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white p-5 rounded-3xl shadow-sm border border-stone-100 flex flex-col justify-between h-32 relative overflow-hidden group"
|
||||
>
|
||||
<div
|
||||
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-emerald-50 rounded-full blur-xl group-hover:bg-emerald-100 transition"
|
||||
/>
|
||||
<div
|
||||
class="w-10 h-10 rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center z-10"
|
||||
>
|
||||
<i class="ph-fill ph-carrot text-xl" />
|
||||
</div>
|
||||
<div class="z-10">
|
||||
<h4 class="font-bold text-stone-700">热量管理</h4>
|
||||
<p class="text-xs text-stone-400 mt-1">今日剩余 750 kcal</p>
|
||||
</div>
|
||||
<div class="z-10">
|
||||
<h4 class="font-bold text-stone-700">AI 财务顾问</h4>
|
||||
<p class="text-xs text-stone-400 mt-1">查看消费总结、自动分类建议和可执行的预算提醒。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { CATEGORY_CHIPS, getCategoryLabel } from '../config/transactionCategories.js'
|
||||
import { useTransactionStore } from '../stores/transactions'
|
||||
import { useUiStore } from '../stores/ui'
|
||||
|
||||
@@ -8,17 +9,7 @@ const transactionStore = useTransactionStore()
|
||||
const uiStore = useUiStore()
|
||||
const { groupedTransactions, filters, loading } = storeToRefs(transactionStore)
|
||||
const deletingId = ref('')
|
||||
|
||||
const categoryChips = [
|
||||
{ label: '全部', value: 'all', icon: 'ph-asterisk' },
|
||||
{ label: '餐饮', value: 'Food', icon: 'ph-bowl-food' },
|
||||
{ label: '通勤', value: 'Transport', icon: 'ph-taxi' },
|
||||
{ label: '健康', value: 'Health', icon: 'ph-heartbeat' },
|
||||
{ label: '买菜', value: 'Groceries', icon: 'ph-basket' },
|
||||
{ label: '娱乐', value: 'Entertainment', icon: 'ph-game-controller' },
|
||||
{ label: '收入', value: 'Income', icon: 'ph-wallet' },
|
||||
{ label: '其他', value: 'Uncategorized', icon: 'ph-dots-three-outline' },
|
||||
]
|
||||
const categoryChips = CATEGORY_CHIPS
|
||||
|
||||
const setCategory = (value) => {
|
||||
filters.value.category = value
|
||||
@@ -29,7 +20,7 @@ const toggleTodayOnly = () => {
|
||||
}
|
||||
|
||||
const formatAmount = (value) =>
|
||||
`${value >= 0 ? '+' : '-'} ¥${Math.abs(value || 0).toFixed(2)}`
|
||||
`${value >= 0 ? '+' : '-'} 楼${Math.abs(value || 0).toFixed(2)}`
|
||||
|
||||
const formatTime = (value) =>
|
||||
new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit' }).format(
|
||||
@@ -53,8 +44,6 @@ const handleDelete = async (item) => {
|
||||
}
|
||||
|
||||
const hasData = computed(() => groupedTransactions.value.length > 0)
|
||||
|
||||
transactionStore.ensureInitialized()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -114,7 +103,7 @@ transactionStore.ensureInitialized()
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-stone-500">{{ group.label }}</h4>
|
||||
<p class="text-[10px] text-stone-400">
|
||||
支出 ¥{{ group.totalExpense.toFixed(2) }}
|
||||
支出 楼{{ group.totalExpense.toFixed(2) }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="text-xs text-stone-300 font-bold">{{ group.dayKey }}</span>
|
||||
@@ -145,10 +134,10 @@ transactionStore.ensureInitialized()
|
||||
<p class="text-xs text-stone-400 mt-0.5">
|
||||
{{ formatTime(item.date) }}
|
||||
<span v-if="item.category" class="text-stone-300">
|
||||
· {{ item.category }}
|
||||
路 {{ getCategoryLabel(item.category) }}
|
||||
</span>
|
||||
<span v-if="item.note" class="text-stone-300">
|
||||
· {{ item.note }}
|
||||
路 {{ item.note }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import EchoInput from '../components/EchoInput.vue'
|
||||
import { BUDGET_CATEGORY_OPTIONS } from '../config/transactionCategories.js'
|
||||
|
||||
// 从 Vite 注入的版本号(来源于 package.json),用于在设置页展示
|
||||
// eslint-disable-next-line no-undef
|
||||
@@ -93,14 +94,7 @@ const hasAnyCategoryBudget = computed(() =>
|
||||
Object.values(categoryBudgets.value || {}).some((v) => (v || 0) > 0),
|
||||
)
|
||||
|
||||
const budgetCategories = [
|
||||
{ value: 'Food', label: '餐饮' },
|
||||
{ value: 'Transport', label: '通勤' },
|
||||
{ value: 'Health', label: '健康' },
|
||||
{ value: 'Groceries', label: '买菜' },
|
||||
{ value: 'Entertainment', label: '娱乐' },
|
||||
{ value: 'Uncategorized', label: '其他' },
|
||||
]
|
||||
const budgetCategories = BUDGET_CATEGORY_OPTIONS
|
||||
const handleAvatarClick = () => {
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.click()
|
||||
@@ -154,7 +148,7 @@ onMounted(() => {
|
||||
<img
|
||||
:src="
|
||||
settingsStore.profileAvatar ||
|
||||
'https://api.dicebear.com/7.x/avataaars/svg?seed=Echo'
|
||||
'/default-avatar.svg'
|
||||
"
|
||||
alt="Avatar"
|
||||
class="w-16 h-16 rounded-full bg-stone-100 object-cover cursor-pointer"
|
||||
@@ -389,9 +383,9 @@ onMounted(() => {
|
||||
<i class="ph-fill ph-magic-wand" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-stone-700 text-sm">AI 自动分类(预留)</p>
|
||||
<p class="font-bold text-stone-700 text-sm">AI 自动分类与标签(预留)</p>
|
||||
<p class="text-[11px] text-stone-400 mt-0.5">
|
||||
开启后,未来会优先使用云端 AI 对商户进行分类与标签分析。
|
||||
开启后,未来会自动补全分类、标签,并生成可复用的商户画像。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -404,7 +398,7 @@ onMounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="aiAutoCategoryEnabled" class="px-4 pb-1 text-[11px] text-purple-600">
|
||||
当前版本仅记录偏好,后续接入云端 AI 后会自动生效。
|
||||
当前版本仅记录偏好,后续接入 AI 能力后会自动生效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -458,3 +452,5 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// 从 package.json 注入版本号,便于前端展示
|
||||
// 从 package.json 注入版本号,供前端设置页展示。
|
||||
// eslint-disable-next-line no-undef
|
||||
const appVersion = (process && process.env && process.env.npm_package_version) || '0.0.0'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
|
||||
Reference in New Issue
Block a user