feat: 添加 UI 状态管理,支持新增记录底部弹窗功能,优化设置和主页交互

This commit is contained in:
2025-12-02 16:22:46 +08:00
parent a81b106ac8
commit 629a54c92d
12 changed files with 265 additions and 47 deletions

View File

@@ -2,9 +2,12 @@
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import BottomDock from './components/BottomDock.vue'
import AddEntryView from './views/AddEntryView.vue'
import { useTransactionStore } from './stores/transactions'
import { useUiStore } from './stores/ui'
const transactionStore = useTransactionStore()
const uiStore = useUiStore()
onMounted(() => {
transactionStore.ensureInitialized()
@@ -25,6 +28,9 @@ onMounted(() => {
<!-- 底部 Dock 导航 -->
<BottomDock />
<!-- 新增记录底部弹窗在任意 Tab 上浮层显示 -->
<AddEntryView v-if="uiStore.addEntryVisible" />
</div>
</div>
</template>

View File

@@ -1,10 +1,12 @@
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUiStore } from '../stores/ui'
// 使用路由信息来高亮当前 Tab而不是从父组件传递状态
const route = useRoute()
const router = useRouter()
const uiStore = useUiStore()
const currentTab = computed(() => route.name)
@@ -14,7 +16,7 @@ const goTab = (name) => {
}
const openAdd = () => {
router.push({ name: 'add' })
uiStore.openAddEntry()
}
</script>

View File

@@ -22,6 +22,8 @@ const nativeBridgeReady = isNativeNotificationBridgeAvailable()
let permissionChecked = false
let bootstrapped = false
let nativeNotificationListener = null
let pollTimer = null
const POLL_INTERVAL_MS = 60000
const ensureRulesReady = async () => {
if (ruleSet.value.length) return ruleSet.value
@@ -147,6 +149,15 @@ export const useTransactionEntry = () => {
console.warn('[notifications] 无法注册 notificationPosted 监听', error)
})
}
// 前台轮询兜底:即使偶发漏掉原生事件,也能定期从队列补拉一次
if (!pollTimer && typeof setInterval === 'function') {
pollTimer = setInterval(() => {
if (!syncing.value) {
void syncNotifications()
}
}, POLL_INTERVAL_MS)
}
}
return {

View File

@@ -2,7 +2,6 @@ import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import ListView from '../views/ListView.vue'
import SettingsView from '../views/SettingsView.vue'
import AddEntryView from '../views/AddEntryView.vue'
// TODO: 后续补充真实的分析页实现,这里先占位
const AnalysisView = () => import('../views/AnalysisView.vue').catch(() => null)
@@ -34,12 +33,6 @@ const router = createRouter({
component: SettingsView,
meta: { tab: 'settings' },
},
{
path: '/add',
name: 'add',
component: AddEntryView,
meta: { overlay: true },
},
],
})

View File

@@ -187,26 +187,59 @@ const extractAmount = (text = '') => {
return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0
}
// 判断字符串是否更像是「金额」而不是商户名用于避免把「1.00元」当成商户
const looksLikeAmount = (raw = '') => {
const value = String(raw).trim()
if (!value) return false
const amountPattern = /^[-\d.,]+\s*(?:元|块钱|¥|¥|人民币)?$/
return amountPattern.test(value)
}
const extractMerchant = (text, rule) => {
const content = text || ''
if (rule?.merchantPattern instanceof RegExp) {
const m = text.match(rule.merchantPattern)
const m = content.match(rule.merchantPattern)
if (m?.[1]) return m[1].trim()
}
// 通用的「站点 A -> 站点 B」线路模式公交/地铁通知),
// 即使未命中专用规则也尝试提取避免退化为金额或「Unknown」
const routeMatch = content.match(
/([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到|->)\s*[\u4e00-\u9fa5]{2,})/,
)
if (routeMatch?.[1]) {
return routeMatch[1].trim()
}
const genericPatterns = [
/向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/,
/商户[:]?\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/,
]
for (const pattern of genericPatterns) {
const m = text.match(pattern)
const m = content.match(pattern)
if (m?.[1]) return m[1].trim()
}
const parts = text.split(/|:/)
// 针对「……支出(消费支付宝-上海拉扎斯信息科技有限公司)3.23元」这类银行通知,
// 优先尝试从括号中的「支付宝-商户名」结构中提取真正的商户名
const parenMatch = content.match(/\(([^)]+)\)/)
if (parenMatch?.[1]) {
const inner = parenMatch[1]
const payMatch = inner.match(/(?:支付宝|微信支付|微信)[-—\s]*([^\d元¥¥]+)$/)
if (payMatch?.[1]) {
const candidate = payMatch[1].trim()
if (candidate && !looksLikeAmount(candidate)) {
return candidate
}
}
}
const parts = content.split(/|:/)
if (parts.length > 1) {
const candidate = parts[1].split(/[,\s]/)[0]
if (candidate) return candidate.trim()
if (candidate && !looksLikeAmount(candidate)) return candidate.trim()
}
return 'Unknown'

View File

@@ -6,6 +6,9 @@ export const useSettingsStore = defineStore(
() => {
const notificationCaptureEnabled = ref(true)
const aiAutoCategoryEnabled = ref(false)
const monthlyBudget = ref(12000)
const profileName = ref('Echo 用户')
const profileAvatar = ref('')
const setNotificationCaptureEnabled = (value) => {
notificationCaptureEnabled.value = !!value
@@ -15,16 +18,41 @@ export const useSettingsStore = defineStore(
aiAutoCategoryEnabled.value = !!value
}
const setMonthlyBudget = (value) => {
const numeric = Number(value)
monthlyBudget.value = Number.isFinite(numeric) && numeric >= 0 ? numeric : 0
}
const setProfileName = (value) => {
profileName.value = (value || '').trim() || 'Echo 用户'
}
const setProfileAvatar = (value) => {
profileAvatar.value = value || ''
}
return {
notificationCaptureEnabled,
aiAutoCategoryEnabled,
monthlyBudget,
profileName,
profileAvatar,
setNotificationCaptureEnabled,
setAiAutoCategoryEnabled,
setMonthlyBudget,
setProfileName,
setProfileAvatar,
}
},
{
persist: {
paths: ['notificationCaptureEnabled', 'aiAutoCategoryEnabled'],
paths: [
'notificationCaptureEnabled',
'aiAutoCategoryEnabled',
'monthlyBudget',
'profileName',
'profileAvatar',
],
},
},
)

26
src/stores/ui.js Normal file
View File

@@ -0,0 +1,26 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 负责全局 UI 状态,例如新增记录的底部弹窗
export const useUiStore = defineStore('ui', () => {
const addEntryVisible = ref(false)
const editingTransactionId = ref('')
const openAddEntry = (id = '') => {
editingTransactionId.value = id || ''
addEntryVisible.value = true
}
const closeAddEntry = () => {
addEntryVisible.value = false
editingTransactionId.value = ''
}
return {
addEntryVisible,
editingTransactionId,
openAddEntry,
closeAddEntry,
}
})

View File

@@ -1,13 +1,12 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTransactionStore } from '../stores/transactions'
import { useUiStore } from '../stores/ui'
const router = useRouter()
const route = useRoute()
const transactionStore = useTransactionStore()
const uiStore = useUiStore()
const editingId = computed(() => route.query.id || '')
const editingId = computed(() => uiStore.editingTransactionId || '')
const feedback = ref('')
const saving = ref(false)
@@ -58,7 +57,7 @@ const hydrateForm = () => {
watch(editingId, hydrateForm, { immediate: true })
const closePanel = () => {
router.back()
uiStore.closeAddEntry()
}
const validate = () => {
@@ -67,8 +66,8 @@ const validate = () => {
return false
}
const parsedAmount = Number(form.amount)
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
feedback.value = '请输入有效的金额'
if (!Number.isFinite(parsedAmount) || parsedAmount < 0) {
feedback.value = '金额不能为负数'
return false
}
feedback.value = ''
@@ -115,7 +114,9 @@ transactionStore.ensureInitialized()
<template>
<div class="absolute inset-0 z-50 flex flex-col justify-end bg-stone-900/30 backdrop-blur-sm">
<div class="bg-white w-full rounded-t-[32px] p-6 pb-10 shadow-2xl">
<div
class="bg-white w-full rounded-t-[32px] p-6 pb-10 shadow-2xl max-h-[80vh] overflow-y-auto hide-scrollbar"
>
<div class="w-12 h-1.5 bg-stone-200 rounded-full mx-auto mb-6" />
<div class="flex items-center justify-between mb-6">

View File

@@ -2,10 +2,16 @@
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { App } from '@capacitor/app'
import { useRouter } from 'vue-router'
import { useTransactionStore } from '../stores/transactions'
import { useTransactionEntry } from '../composables/useTransactionEntry'
import { useSettingsStore } from '../stores/settings'
import { useUiStore } from '../stores/ui'
const router = useRouter()
const transactionStore = useTransactionStore()
const settingsStore = useSettingsStore()
const uiStore = useUiStore()
const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions } =
storeToRefs(transactionStore)
const { notifications, confirmNotification, dismissNotification, processingId, syncNotifications } =
@@ -14,10 +20,13 @@ const { notifications, confirmNotification, dismissNotification, processingId, s
let appStateListener = null
onMounted(async () => {
await transactionStore.ensureInitialized()
await syncNotifications()
try {
// App 从后台回到前台时,自动同步一次通知和交易列表
// App 从后台回到前台时,自动刷新本地账本和通知队列
appStateListener = await App.addListener('appStateChange', async ({ isActive }) => {
if (isActive) {
await transactionStore.hydrateTransactions()
await syncNotifications()
}
})
@@ -34,8 +43,7 @@ onBeforeUnmount(() => {
appStateListener = null
})
const emit = defineEmits(['changeTab'])
const monthlyBudget = 12000
const monthlyBudget = computed(() => settingsStore.monthlyBudget || 0)
const currentDayLabel = computed(
() =>
@@ -48,13 +56,14 @@ const currentDayLabel = computed(
const budgetUsage = computed(() => {
const expense = Math.abs(totalExpense.value || 0)
if (!monthlyBudget) return 0
return Math.min(expense / monthlyBudget, 1)
const budget = monthlyBudget.value || 0
if (!budget) return 0
return Math.min(expense / budget, 1)
})
const balance = computed(() => (totalIncome.value || 0) + (totalExpense.value || 0))
const remainingBudget = computed(() =>
Math.max(monthlyBudget - Math.abs(totalExpense.value || 0), 0),
Math.max((monthlyBudget.value || 0) - Math.abs(totalExpense.value || 0), 0),
)
const todayIncomeValue = computed(() => todaysIncome.value || 0)
@@ -82,7 +91,34 @@ const categoryMeta = {
const getCategoryMeta = (category) => categoryMeta[category] || categoryMeta.default
transactionStore.ensureInitialized()
const handleEditBudget = () => {
const current = monthlyBudget.value || 0
const input = window.prompt('设置本月预算(元)', current ? String(current) : '')
if (input == null) return
const numeric = Number(input)
if (!Number.isFinite(numeric) || numeric < 0) {
window.alert('请输入合法的预算金额')
return
}
settingsStore.setMonthlyBudget(numeric)
}
const goSettings = () => {
router.push({ name: 'settings' })
}
const goList = () => {
router.push({ name: 'list' })
}
const goAnalysis = () => {
router.push({ name: 'analysis' })
}
const openTransactionDetail = (tx) => {
if (!tx?.id) return
uiStore.openAddEntry(tx.id)
}
</script>
<template>
@@ -96,10 +132,13 @@ transactionStore.ensureInitialized()
</div>
<div
class="w-10 h-10 rounded-full bg-orange-50 p-0.5 cursor-pointer border border-orange-100"
@click="emit('changeTab', 'settings')"
@click="goSettings"
>
<img
src="https://api.dicebear.com/7.x/avataaars/svg?seed=Echo"
:src="
settingsStore.profileAvatar ||
'https://api.dicebear.com/7.x/avataaars/svg?seed=Echo'
"
class="rounded-full"
alt="User"
/>
@@ -147,9 +186,17 @@ transactionStore.ensureInitialized()
<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-800">
{{ Math.round(budgetUsage * 100) }}%
</span>
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-stone-800">
{{ Math.round(budgetUsage * 100) }}%
</span>
<button
class="text-[10px] text-stone-400 underline-offset-2 hover:text-stone-600"
@click="handleEditBudget"
>
调整
</button>
</div>
</div>
<div class="w-full bg-stone-100 rounded-full h-2.5 overflow-hidden">
<div
@@ -179,7 +226,7 @@ transactionStore.ensureInitialized()
<h3 class="font-bold text-stone-700">最新流水</h3>
<button
class="text-xs font-bold text-gradient-warm"
@click="emit('changeTab', 'list')"
@click="goList"
>
查看全部
</button>
@@ -188,7 +235,8 @@ transactionStore.ensureInitialized()
<div
v-for="tx in recentTransactions"
:key="tx.id"
class="flex items-center justify-between"
class="flex items-center justify-between cursor-pointer active:bg-stone-50 rounded-2xl px-2 -mx-2 py-1 transition"
@click="openTransactionDetail(tx)"
>
<div class="flex items-center gap-3">
<div
@@ -277,7 +325,7 @@ transactionStore.ensureInitialized()
<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="emit('changeTab', 'analysis')"
@click="goAnalysis"
>
<div
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-purple-50 rounded-full blur-xl group-hover:bg-purple-100 transition"

View File

@@ -1,11 +1,11 @@
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useTransactionStore } from '../stores/transactions'
import { useUiStore } from '../stores/ui'
const router = useRouter()
const transactionStore = useTransactionStore()
const uiStore = useUiStore()
const { groupedTransactions, filters, loading } = storeToRefs(transactionStore)
const deletingId = ref('')
@@ -36,7 +36,7 @@ const formatTime = (value) =>
)
const handleEdit = (item) => {
router.push({ name: 'add', query: { id: item.id } })
uiStore.openAddEntry(item.id)
}
const handleDelete = async (item) => {
@@ -63,7 +63,7 @@ transactionStore.ensureInitialized()
<h2 class="text-2xl font-extrabold text-stone-800">账单明细</h2>
<button
class="flex items-center gap-2 bg-white border border-stone-100 px-3 py-1.5 rounded-full shadow-sm text-xs font-bold text-stone-500"
@click="router.push({ name: 'add' })"
@click="uiStore.openAddEntry()"
>
<i class="ph-bold ph-plus" /> 新增
</button>

View File

@@ -3,7 +3,12 @@ import { computed, onMounted, ref } from 'vue'
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
import { useSettingsStore } from '../stores/settings'
// 从 Vite 注入的版本号(来源于 package.json用于在设置页展示
// eslint-disable-next-line no-undef
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'
const settingsStore = useSettingsStore()
const fileInputRef = ref(null)
const notificationCaptureEnabled = computed({
get: () => settingsStore.notificationCaptureEnabled,
@@ -51,6 +56,35 @@ const toggleAiAutoCategory = () => {
aiAutoCategoryEnabled.value = !aiAutoCategoryEnabled.value
}
const handleEditProfileName = () => {
const current = settingsStore.profileName || 'Echo 用户'
const input = window.prompt('修改昵称', current)
if (input == null) return
settingsStore.setProfileName(input)
}
const handleAvatarClick = () => {
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
const handleAvatarChange = (event) => {
const [file] = event.target.files || []
if (!file) return
if (!file.type || !file.type.startsWith('image/')) {
window.alert('请选择图片文件作为头像')
return
}
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
settingsStore.setProfileAvatar(reader.result)
}
}
reader.readAsDataURL(file)
}
const handleExportData = () => {
window.alert('导出功能即将上线:届时可以一键导出 CSV / Excel。当前版本建议先通过截图或复制方式备份关键信息。')
}
@@ -78,13 +112,42 @@ onMounted(() => {
<!-- 个人信息 / 简短说明 -->
<div class="bg-white rounded-3xl p-5 flex items-center gap-4 shadow-sm border border-stone-100">
<img
src="https://api.dicebear.com/7.x/avataaars/svg?seed=Echo"
alt="Avatar"
class="w-16 h-16 rounded-full bg-stone-100"
/>
<div class="relative">
<img
:src="
settingsStore.profileAvatar ||
'https://api.dicebear.com/7.x/avataaars/svg?seed=Echo'
"
alt="Avatar"
class="w-16 h-16 rounded-full bg-stone-100 object-cover cursor-pointer"
@click="handleAvatarClick"
/>
<button
class="absolute bottom-0 right-0 w-6 h-6 rounded-full bg-white flex items-center justify-center text-[10px] text-stone-500 border border-stone-100 shadow-sm"
@click.stop="handleAvatarClick"
>
<i class="ph-bold ph-pencil-simple" />
</button>
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="handleAvatarChange"
/>
</div>
<div class="flex-1">
<h3 class="font-bold text-lg text-stone-800">Echo 用户</h3>
<div class="flex items-center gap-2">
<h3 class="font-bold text-lg text-stone-800">
{{ settingsStore.profileName }}
</h3>
<button
class="text-[11px] text-stone-400 underline-offset-2 hover:text-stone-600"
@click="handleEditProfileName"
>
编辑
</button>
</div>
<p class="text-xs text-stone-400">本地优先 · 数据只存这台设备</p>
</div>
</div>
@@ -222,7 +285,7 @@ onMounted(() => {
</section>
<div class="text-center pt-4">
<p class="text-xs text-stone-300">Echo · Local-first Beta</p>
<p class="text-xs text-stone-300">Echo · Local-first · v{{ appVersion }}</p>
</div>
</div>
</template>

View File

@@ -1,7 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 从 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: {
__APP_VERSION__: JSON.stringify(appVersion),
},
})