feat: 优化统一规则逻辑

This commit is contained in:
2026-03-12 10:04:29 +08:00
parent d0f68e07b7
commit bf15918136
17 changed files with 859 additions and 600 deletions

View File

@@ -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>

View 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

View 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

View 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
}

View File

@@ -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 }
}

View File

@@ -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(
},
},
)

View File

@@ -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

View File

@@ -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()
@@ -20,10 +22,10 @@ const { notifications, confirmNotification, dismissNotification, processingId, s
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,8 +33,7 @@ onMounted(async () => {
}
})
} catch (error) {
// Web 环境或插件不可用时忽略
console.warn('[notifications] appStateChange listener failed', error)
// Web 鐜鎴栨彃浠朵笉鍙敤鏃跺拷鐣? console.warn('[notifications] appStateChange listener failed', error)
}
})
@@ -59,7 +60,7 @@ const periodStart = computed(() => {
const cycle = budgetResetCycle.value
const now = new Date()
if (cycle === 'weekly') {
const day = now.getDay() || 7 // 周一=1周日=7
const day = now.getDay() || 7 // 鍛ㄤ竴=1锛屽懆鏃?7
const diff = day - 1
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - diff)
}
@@ -76,7 +77,7 @@ const periodStart = computed(() => {
if (!settingsStore.budgetCustomStartDate) return null
return new Date(settingsStore.budgetCustomStartDate)
}
// none:不限制周期
// none锛氫笉闄愬埗鍛ㄦ湡
return null
})
@@ -108,9 +109,9 @@ const remainingBudget = computed(() =>
const todayIncomeValue = computed(() => todaysIncome.value || 0)
const todayExpenseValue = computed(() => Math.abs(todaysExpense.value || 0))
const formatCurrency = (value) => `¥ ${Math.abs(value || 0).toFixed(2)}`
const formatCurrency = (value) => ` ${Math.abs(value || 0).toFixed(2)}`
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(
new Date(value),
@@ -118,18 +119,6 @@ const formatTime = (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' })
}
@@ -155,7 +144,7 @@ const openTransactionDetail = (tx) => {
<p class="text-xs text-stone-400 font-bold tracking-wider">
{{ currentDayLabel }}
</p>
<h1 class="text-2xl font-extrabold text-stone-800">今日概览</h1>
<h1 class="text-2xl font-extrabold text-stone-800">浠婃棩姒傝</h1>
</div>
<div
class="w-10 h-10 rounded-full bg-orange-50 p-0.5 cursor-pointer border border-orange-100"
@@ -181,7 +170,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"
>
@@ -196,13 +185,13 @@ const openTransactionDetail = (tx) => {
<div class="flex gap-8">
<div>
<p class="text-white/70 text-xs mb-0.5 flex items-center gap-1">
<i class="ph-bold ph-arrow-down-left" /> 收入
<i class="ph-bold ph-arrow-down-left" /> 鏀跺叆
</p>
<p class="font-bold text-lg">{{ formatCurrency(totalIncome) }}</p>
</div>
<div>
<p class="text-white/70 text-xs mb-0.5 flex items-center gap-1">
<i class="ph-bold ph-arrow-up-right" /> 支出
<i class="ph-bold ph-arrow-up-right" />
</p>
<p class="font-bold text-lg">{{ formatCurrency(Math.abs(totalExpense)) }}</p>
</div>
@@ -212,7 +201,7 @@ const openTransactionDetail = (tx) => {
<div class="bg-white rounded-2xl p-4 shadow-sm border border-stone-100">
<div class="flex justify-between items-center mb-2">
<span class="text-xs font-bold text-stone-500">本期预算</span>
<span class="text-xs font-bold text-stone-500">湡棰勭畻</span>
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-stone-800">
{{ Math.round(budgetUsage * 100) }}%
@@ -221,8 +210,7 @@ const openTransactionDetail = (tx) => {
class="text-[10px] text-stone-400 underline-offset-2 hover:text-stone-600"
@click="goSettings"
>
去设置
</button>
鍘昏? </button>
</div>
</div>
<div class="w-full bg-stone-100 rounded-full h-2.5 overflow-hidden">
@@ -232,8 +220,8 @@ const openTransactionDetail = (tx) => {
/>
</div>
<div class="flex justify-between mt-2 text-[10px] text-stone-400">
<span>已用 {{ formatCurrency(Math.abs(periodExpense)) }}</span>
<span>剩余 {{ formatCurrency(remainingBudget) }}</span>
<span>宸茬敤 {{ formatCurrency(Math.abs(periodExpense)) }}</span>
<span> {{ formatCurrency(remainingBudget) }}</span>
</div>
<div class="flex justify-between mt-1 text-[10px] text-stone-400">
<span>
@@ -252,18 +240,17 @@ const openTransactionDetail = (tx) => {
class="text-[10px] text-stone-400 underline-offset-2 hover:text-stone-600"
@click="goSettings"
>
去设置
</button>
鍘昏? </button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="bg-white rounded-2xl p-4 border border-stone-100 shadow-sm">
<p class="text-xs text-stone-400 mb-2">今日收入</p>
<p class="text-xs text-stone-400 mb-2">浠婃棩鏀跺叆</p>
<p class="text-xl font-bold text-emerald-600">{{ formatCurrency(todayIncomeValue) }}</p>
</div>
<div class="bg-white rounded-2xl p-4 border border-stone-100 shadow-sm">
<p class="text-xs text-stone-400 mb-2">今日支出</p>
<p class="text-xs text-stone-400 mb-2">浠婃棩鏀</p>
<p class="text-xl font-bold text-rose-600">- {{ formatCurrency(todayExpenseValue) }}</p>
</div>
</div>
@@ -275,7 +262,7 @@ const openTransactionDetail = (tx) => {
class="text-xs font-bold text-gradient-warm"
@click="goList"
>
查看全部
鏌ョ湅鍏ㄩ儴
</button>
</div>
<div v-if="recentTransactions.length" class="space-y-4">
@@ -298,7 +285,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,15 +300,15 @@ const openTransactionDetail = (tx) => {
</div>
</div>
<div v-else class="text-sm text-stone-400 text-center py-4">
还没有记录快去添加第一笔消费吧
杩樻病鏈夎褰曪紝蹇幓娣诲姞绗竴绗旀秷璐瑰惂锝?
</div>
</div>
<div class="space-y-3">
<div class="flex justify-between items-end">
<h3 class="font-bold text-lg text-stone-700">待确认通知</h3>
<h3 class="font-bold text-lg text-stone-700">寰呯璁ら氱煡</h3>
<span class="text-[10px] bg-orange-100 text-orange-600 px-2 py-1 rounded-full font-bold">
自动捕获
姩鎹曡幏
</span>
</div>
@@ -341,7 +328,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 }}
@@ -353,19 +340,19 @@ const openTransactionDetail = (tx) => {
:disabled="processingId === notif.id"
@click="confirmNotification(notif.id)"
>
入账
鍏ヨ处
</button>
<button
class="text-[11px] text-stone-400"
@click="dismissNotification(notif.id)"
>
忽略
蹇界暐
</button>
</div>
</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>
@@ -383,8 +370,8 @@ const openTransactionDetail = (tx) => {
<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>
<h4 class="font-bold text-stone-700">AI 椤鹃棶</h4>
<p class="text-xs text-stone-400 mt-1">鍒嗘瀽鎴戠殑娑堣垂涔犳儻</p>
</div>
</div>
@@ -400,10 +387,13 @@ const openTransactionDetail = (tx) => {
<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>
<h4 class="font-bold text-stone-700">噺绠</h4>
<p class="text-xs text-stone-400 mt-1">浠婃棩鍓 750 kcal</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -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>

View File

@@ -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
@@ -458,3 +459,4 @@ onMounted(() => {
</div>
</div>
</template>