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

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