feat: 添加预算管理功能,支持分类预算和重置周期设置,优化用户界面

This commit is contained in:
2025-12-02 17:50:26 +08:00
parent fae5a408ff
commit 82fc35b7c9
8 changed files with 517 additions and 58 deletions

View File

@@ -1,6 +1,7 @@
<script setup>
import { onMounted } from 'vue'
import { onMounted, onBeforeUnmount } from 'vue'
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'
@@ -9,8 +10,34 @@ import { useUiStore } from './stores/ui'
const transactionStore = useTransactionStore()
const uiStore = useUiStore()
let backButtonListener = null
onMounted(() => {
transactionStore.ensureInitialized()
// Android 物理返回键:如果新增记录面板打开,则优先关闭面板而不是直接退出应用
if (typeof App?.addListener === 'function') {
App.addListener('backButton', ({ canGoBack }) => {
if (uiStore.addEntryVisible) {
uiStore.closeAddEntry()
} else if (!canGoBack) {
// 这里保留默认行为(由 Capacitor 处理),不强制 exitApp
}
})
.then((handle) => {
backButtonListener = handle
})
.catch(() => {
backButtonListener = null
})
}
})
onBeforeUnmount(() => {
if (backButtonListener && typeof backButtonListener.remove === 'function') {
backButtonListener.remove()
backButtonListener = null
}
})
</script>

View File

@@ -0,0 +1,101 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
type: {
type: String,
default: 'text',
},
inputmode: {
type: String,
default: '',
},
step: {
type: [String, Number],
default: undefined,
},
disabled: {
type: Boolean,
default: false,
},
error: {
type: String,
default: '',
},
hint: {
type: String,
default: '',
},
inputClass: {
type: String,
default: '',
},
})
const emit = defineEmits(['update:modelValue', 'blur', 'focus', 'enter'])
const hasError = computed(() => !!props.error)
const handleInput = (event) => {
emit('update:modelValue', event.target.value)
}
const handleKeydown = (event) => {
if (event.key === 'Enter') {
emit('enter', event)
}
}
</script>
<template>
<div class="w-full">
<label v-if="label" class="block">
<span class="text-xs font-bold text-stone-400">{{ label }}</span>
<div
class="mt-2 flex items-center gap-2 rounded-2xl border px-4 py-3 bg-white/80"
:class="
hasError
? 'border-rose-300 shadow-sm shadow-rose-100'
: 'border-stone-200 focus-within:border-stone-400'
"
>
<slot name="prefix" />
<input
:type="type"
:inputmode="inputmode || undefined"
:step="step"
:placeholder="placeholder"
:disabled="disabled"
:value="modelValue"
class="flex-1 bg-transparent text-sm text-stone-800 placeholder:text-stone-300 focus:outline-none"
:class="inputClass"
@input="handleInput"
@blur="$emit('blur', $event)"
@focus="$emit('focus', $event)"
@keydown="handleKeydown"
/>
<slot name="suffix" />
</div>
</label>
<p v-if="hasError" class="mt-1 text-[11px] text-rose-500 font-bold">
{{ error }}
</p>
<p v-else-if="hint" class="mt-1 text-[11px] text-stone-400">
{{ hint }}
</p>
</div>
</template>

View File

@@ -7,6 +7,18 @@ 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 categoryBudgetEnabled = ref(false)
const profileName = ref('Echo 用户')
const profileAvatar = ref('')
@@ -23,6 +35,38 @@ export const useSettingsStore = defineStore(
monthlyBudget.value = Number.isFinite(numeric) && numeric >= 0 ? numeric : 0
}
const setBudgetResetCycle = (value) => {
const allowed = ['monthly', 'weekly', 'none', 'custom']
budgetResetCycle.value = allowed.includes(value) ? value : 'monthly'
}
const setCategoryBudget = (category, value) => {
const numeric = Number(value)
const safe = Number.isFinite(numeric) && numeric >= 0 ? numeric : 0
categoryBudgets.value = {
...categoryBudgets.value,
[category]: safe,
}
}
const setCategoryBudgetEnabled = (value) => {
categoryBudgetEnabled.value = !!value
}
const setBudgetMonthlyResetDay = (value) => {
const n = Number(value)
if (!Number.isFinite(n)) {
budgetMonthlyResetDay.value = 1
return
}
const clamped = Math.min(Math.max(Math.round(n), 1), 28)
budgetMonthlyResetDay.value = clamped
}
const setBudgetCustomStartDate = (value) => {
budgetCustomStartDate.value = value || ''
}
const setProfileName = (value) => {
profileName.value = (value || '').trim() || 'Echo 用户'
}
@@ -35,11 +79,21 @@ export const useSettingsStore = defineStore(
notificationCaptureEnabled,
aiAutoCategoryEnabled,
monthlyBudget,
budgetResetCycle,
budgetMonthlyResetDay,
budgetCustomStartDate,
categoryBudgets,
categoryBudgetEnabled,
profileName,
profileAvatar,
setNotificationCaptureEnabled,
setAiAutoCategoryEnabled,
setMonthlyBudget,
setBudgetResetCycle,
setBudgetMonthlyResetDay,
setBudgetCustomStartDate,
setCategoryBudget,
setCategoryBudgetEnabled,
setProfileName,
setProfileAvatar,
}
@@ -50,6 +104,11 @@ export const useSettingsStore = defineStore(
'notificationCaptureEnabled',
'aiAutoCategoryEnabled',
'monthlyBudget',
'budgetResetCycle',
'budgetMonthlyResetDay',
'budgetCustomStartDate',
'categoryBudgets',
'categoryBudgetEnabled',
'profileName',
'profileAvatar',
],

View File

@@ -2,6 +2,7 @@
import { computed, reactive, ref, watch } from 'vue'
import { useTransactionStore } from '../stores/transactions'
import { useUiStore } from '../stores/ui'
import EchoInput from '../components/EchoInput.vue'
const transactionStore = useTransactionStore()
const uiStore = useUiStore()
@@ -9,8 +10,11 @@ const uiStore = useUiStore()
const editingId = computed(() => uiStore.editingTransactionId || '')
const feedback = ref('')
const saving = ref(false)
const dragStartY = ref(0)
const dragging = ref(false)
const dragOffset = ref(0)
const categories = ['Food', 'Transport', 'Health', 'Groceries', 'Income', 'Uncategorized']
const categories = ['Food', 'Transport', 'Health', 'Groceries', 'Entertainment', 'Income', 'Uncategorized']
const toDatetimeLocal = (value) => {
const date = value ? new Date(value) : new Date()
@@ -109,15 +113,58 @@ const deleteEntry = async () => {
closePanel()
}
const onDragStart = (event) => {
const touch = event.touches?.[0]
if (!touch) return
dragStartY.value = touch.clientY
dragging.value = true
dragOffset.value = 0
}
const onDragMove = (event) => {
if (!dragging.value) return
const touch = event.touches?.[0]
if (!touch) return
const delta = touch.clientY - dragStartY.value
dragOffset.value = delta > 0 ? delta : 0
}
const onDragEnd = () => {
if (!dragging.value) return
dragging.value = false
if (dragOffset.value > 80) {
closePanel()
} else {
dragOffset.value = 0
}
}
const sheetStyle = computed(() => {
if (!dragOffset.value) return {}
return {
transform: `translateY(${dragOffset.value}px)`,
transition: dragging.value ? 'none' : 'transform 0.2s ease-out',
}
})
transactionStore.ensureInitialized()
</script>
<template>
<div class="absolute inset-0 z-50 flex flex-col justify-end bg-stone-900/30 backdrop-blur-sm">
<div
class="absolute inset-0 z-50 flex flex-col justify-end bg-stone-900/30 backdrop-blur-sm"
@click.self="closePanel"
>
<div
class="bg-white w-full rounded-t-[32px] p-6 pb-10 shadow-2xl max-h-[80vh] overflow-y-auto hide-scrollbar"
:style="sheetStyle"
>
<div class="w-12 h-1.5 bg-stone-200 rounded-full mx-auto mb-6" />
<div
class="w-12 h-1.5 bg-stone-200 rounded-full mx-auto mb-6"
@touchstart.passive="onDragStart"
@touchmove.prevent="onDragMove"
@touchend="onDragEnd"
/>
<div class="flex items-center justify-between mb-6">
<div>
@@ -157,29 +204,25 @@ transactionStore.ensureInitialized()
</div>
<div class="space-y-4">
<label class="block">
<span class="text-xs font-bold text-stone-400">金额</span>
<div class="mt-2 flex items-center gap-2 rounded-2xl border border-stone-200 px-4 py-3">
<EchoInput
v-model="form.amount"
label="金额"
type="number"
inputmode="decimal"
step="0.01"
placeholder="0.00"
input-class="text-2xl font-bold"
>
<template #prefix>
<span class="text-stone-400 text-sm">¥</span>
<input
v-model="form.amount"
type="number"
inputmode="decimal"
step="0.01"
placeholder="0.00"
class="flex-1 bg-transparent text-2xl font-bold text-stone-800 focus:outline-none"
/>
</div>
</label>
</template>
</EchoInput>
<label class="block">
<span class="text-xs font-bold text-stone-400">商户 / 来源</span>
<input
v-model="form.merchant"
class="mt-2 w-full rounded-2xl border border-stone-200 px-4 py-3 text-sm focus:outline-none focus:border-stone-400"
placeholder="如 Starbucks / 滴滴出行"
/>
</label>
<EchoInput
v-model="form.merchant"
label="商户 / 来源"
placeholder="如 Starbucks / 滴滴出行"
/>
<div>
<span class="text-xs font-bold text-stone-400">分类</span>

View File

@@ -12,7 +12,7 @@ const router = useRouter()
const transactionStore = useTransactionStore()
const settingsStore = useSettingsStore()
const uiStore = useUiStore()
const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions } =
const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions, sortedTransactions } =
storeToRefs(transactionStore)
const { notifications, confirmNotification, dismissNotification, processingId, syncNotifications } =
useTransactionEntry()
@@ -44,6 +44,7 @@ onBeforeUnmount(() => {
})
const monthlyBudget = computed(() => settingsStore.monthlyBudget || 0)
const budgetResetCycle = computed(() => settingsStore.budgetResetCycle || 'monthly')
const currentDayLabel = computed(
() =>
@@ -54,8 +55,46 @@ const currentDayLabel = computed(
}).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)
}
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
}
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
}
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
}, 0)
})
const budgetUsage = computed(() => {
const expense = Math.abs(totalExpense.value || 0)
const expense = Math.abs(periodExpense.value || 0)
const budget = monthlyBudget.value || 0
if (!budget) return 0
return Math.min(expense / budget, 1)
@@ -63,7 +102,7 @@ const budgetUsage = computed(() => {
const balance = computed(() => (totalIncome.value || 0) + (totalExpense.value || 0))
const remainingBudget = computed(() =>
Math.max((monthlyBudget.value || 0) - Math.abs(totalExpense.value || 0), 0),
Math.max((monthlyBudget.value || 0) - Math.abs(periodExpense.value || 0), 0),
)
const todayIncomeValue = computed(() => todaysIncome.value || 0)
@@ -91,18 +130,6 @@ const categoryMeta = {
const getCategoryMeta = (category) => categoryMeta[category] || categoryMeta.default
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' })
}
@@ -185,16 +212,16 @@ 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) }}%
</span>
<button
class="text-[10px] text-stone-400 underline-offset-2 hover:text-stone-600"
@click="handleEditBudget"
@click="goSettings"
>
调整
去设置
</button>
</div>
</div>
@@ -205,9 +232,29 @@ const openTransactionDetail = (tx) => {
/>
</div>
<div class="flex justify-between mt-2 text-[10px] text-stone-400">
<span>已用 {{ formatCurrency(Math.abs(totalExpense)) }}</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>
周期
{{
budgetResetCycle === 'weekly'
? '每周重置'
: budgetResetCycle === 'none'
? '不重置'
: budgetResetCycle === 'custom'
? '自定义'
: '每月重置'
}}
</span>
<button
class="text-[10px] text-stone-400 underline-offset-2 hover:text-stone-600"
@click="goSettings"
>
去设置
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">

View File

@@ -15,6 +15,7 @@ const categoryChips = [
{ 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' },
]

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
import { useSettingsStore } from '../stores/settings'
import EchoInput from '../components/EchoInput.vue'
// 从 Vite 注入的版本号(来源于 package.json用于在设置页展示
// eslint-disable-next-line no-undef
@@ -9,6 +10,7 @@ const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0
const settingsStore = useSettingsStore()
const fileInputRef = ref(null)
const editingProfileName = ref(false)
const notificationCaptureEnabled = computed({
get: () => settingsStore.notificationCaptureEnabled,
@@ -56,13 +58,49 @@ 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 profileNameModel = computed({
get: () => settingsStore.profileName,
set: (value) => settingsStore.setProfileName(value),
})
const budgetAmount = computed({
get: () => (settingsStore.monthlyBudget || 0).toString(),
set: (value) => settingsStore.setMonthlyBudget(value),
})
const budgetCycle = computed({
get: () => settingsStore.budgetResetCycle,
set: (value) => settingsStore.setBudgetResetCycle(value),
})
const categoryBudgets = computed(() => settingsStore.categoryBudgets || {})
const categoryBudgetEnabled = computed({
get: () => settingsStore.categoryBudgetEnabled,
set: (value) => settingsStore.setCategoryBudgetEnabled(value),
})
const monthlyResetDayModel = computed({
get: () => (settingsStore.budgetMonthlyResetDay || 1).toString(),
set: (value) => settingsStore.setBudgetMonthlyResetDay(value),
})
const customStartDateModel = computed({
get: () => settingsStore.budgetCustomStartDate || '',
set: (value) => settingsStore.setBudgetCustomStartDate(value),
})
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 handleAvatarClick = () => {
if (fileInputRef.value) {
fileInputRef.value.click()
@@ -137,21 +175,152 @@ onMounted(() => {
/>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-bold text-lg text-stone-800">
<div class="flex items-center gap-2 mb-1">
<template v-if="editingProfileName">
<input
v-model="profileNameModel"
class="bg-stone-50 rounded-full px-3 py-1 text-sm font-bold text-stone-800 border border-stone-200 focus:outline-none focus:border-stone-400"
placeholder="输入要显示的昵称"
/>
</template>
<h3
v-else
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"
class="w-6 h-6 rounded-full bg-stone-50 flex items-center justify-center text-[10px] text-stone-500 border border-stone-100"
@click="editingProfileName = !editingProfileName"
>
编辑
<i class="ph-bold ph-pencil-simple" />
</button>
</div>
<p class="text-xs text-stone-400">本地优先 · 数据只存这台设备</p>
<p class="text-xs text-stone-400 mb-2">数据只存放在本地</p>
</div>
</div>
<!-- 预算管理 -->
<section>
<h3 class="text-xs font-bold text-stone-400 uppercase tracking-widest mb-3 ml-2">
预算管理
</h3>
<div class="bg-white rounded-3xl p-4 shadow-sm border border-stone-100 space-y-4">
<EchoInput
v-model="budgetAmount"
label="全部预算金额(元)"
type="number"
inputmode="decimal"
placeholder="例如 8000"
/>
<div class="flex items-center justify-between">
<span class="text-[11px] text-stone-400 font-bold">重置周期</span>
<div class="flex gap-2">
<button
class="px-3 py-1.5 rounded-full text-[11px] font-bold border transition"
:class="
budgetCycle === 'monthly'
? 'bg-gradient-warm text-white border-transparent shadow-sm'
: 'bg-stone-50 text-stone-500 border-stone-200'
"
@click="budgetCycle = 'monthly'"
>
每月
</button>
<button
class="px-3 py-1.5 rounded-full text-[11px] font-bold border transition"
:class="
budgetCycle === 'weekly'
? 'bg-gradient-warm text-white border-transparent shadow-sm'
: 'bg-stone-50 text-stone-500 border-stone-200'
"
@click="budgetCycle = 'weekly'"
>
每周
</button>
<button
class="px-3 py-1.5 rounded-full text-[11px] font-bold border transition"
:class="
budgetCycle === 'none'
? 'bg-gradient-warm text-white border-transparent shadow-sm'
: 'bg-stone-50 text-stone-500 border-stone-200'
"
@click="budgetCycle = 'none'"
>
不重置
</button>
<button
class="px-3 py-1.5 rounded-full text-[11px] font-bold border transition"
:class="
budgetCycle === 'custom'
? 'bg-gradient-warm text-white border-transparent shadow-sm'
: 'bg-stone-50 text-stone-500 border-stone-200'
"
@click="budgetCycle = 'custom'"
>
自定义
</button>
</div>
</div>
<div v-if="budgetCycle === 'monthly'" class="mt-3">
<EchoInput
v-model="monthlyResetDayModel"
label="每月第几天重置1-28"
type="number"
inputmode="numeric"
placeholder="例如 1 表示每月 1 号"
/>
</div>
<div v-else-if="budgetCycle === 'custom'" class="mt-3">
<EchoInput
v-model="customStartDateModel"
label="自定义起始日"
type="date"
hint="从该日期开始作为预算统计区间的起点"
/>
</div>
<div class="pt-2 space-y-2">
<div class="flex items-center justify-between">
<p class="text-[11px] text-stone-400 font-bold">分类预算可选</p>
<button
class="w-10 h-5 rounded-full px-0.5 flex items-center transition-all duration-200"
:class="
categoryBudgetEnabled
? 'bg-orange-400 justify-end'
: 'bg-stone-200 justify-start'
"
@click="categoryBudgetEnabled = !categoryBudgetEnabled"
>
<div class="w-4 h-4 bg-white rounded-full shadow-sm" />
</button>
</div>
<div
v-if="categoryBudgetEnabled || hasAnyCategoryBudget"
class="space-y-3"
>
<div
v-for="cat in budgetCategories"
:key="cat.value"
class="border border-stone-100 rounded-2xl px-3 py-2 bg-stone-50/40"
>
<EchoInput
:model-value="(categoryBudgets[cat.value] || 0) ? String(categoryBudgets[cat.value]) : ''"
:label="`${cat.label}预算(元)`"
type="number"
inputmode="decimal"
placeholder="留空则不启用该分类预算"
@update:modelValue="(val) => settingsStore.setCategoryBudget(cat.value, val)"
/>
</div>
</div>
</div>
</div>
</section>
<!-- 自动化 & AI -->
<section>
<h3 class="text-xs font-bold text-stone-400 uppercase tracking-widest mb-3 ml-2">
@@ -285,7 +454,7 @@ onMounted(() => {
</section>
<div class="text-center pt-4">
<p class="text-xs text-stone-300">Echo · Local-first · v{{ appVersion }}</p>
<p class="text-xs text-stone-300">Echo · v{{ appVersion }}</p>
</div>
</div>
</template>