feat: 添加预算管理功能,支持分类预算和重置周期设置,优化用户界面
This commit is contained in:
@@ -1,10 +1,13 @@
|
|||||||
package com.echo.app;
|
package com.echo.app;
|
||||||
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
import androidx.core.view.WindowCompat;
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
import com.echo.app.notification.NotificationBridgePlugin;
|
import com.echo.app.notification.NotificationBridgePlugin;
|
||||||
|
|
||||||
// 主 Activity:这里显式注册自定义的 NotificationBridge 插件,打通原生通知监听到前端的桥接
|
// 主 Activity:注册自定义插件,并启用沉浸式状态栏,使前端 Warm 背景延伸到系统栏区域
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState) {
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
@@ -12,5 +15,14 @@ public class MainActivity extends BridgeActivity {
|
|||||||
// 否则 Bridge 已经创建完成,JS 侧会报 "plugin is not implemented on android"
|
// 否则 Bridge 已经创建完成,JS 侧会报 "plugin is not implemented on android"
|
||||||
registerPlugin(NotificationBridgePlugin.class);
|
registerPlugin(NotificationBridgePlugin.class);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
// 让内容绘制到状态栏/导航栏后面,实现沉浸式效果
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
|
||||||
|
|
||||||
|
// 使用浅色背景 + 深色状态栏图标,避免黑色条块感
|
||||||
|
View decorView = getWindow().getDecorView();
|
||||||
|
WindowInsetsControllerCompat insetsController =
|
||||||
|
new WindowInsetsControllerCompat(getWindow(), decorView);
|
||||||
|
insetsController.setAppearanceLightStatusBars(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/App.vue
29
src/App.vue
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
import { App } from '@capacitor/app'
|
||||||
import BottomDock from './components/BottomDock.vue'
|
import BottomDock from './components/BottomDock.vue'
|
||||||
import AddEntryView from './views/AddEntryView.vue'
|
import AddEntryView from './views/AddEntryView.vue'
|
||||||
import { useTransactionStore } from './stores/transactions'
|
import { useTransactionStore } from './stores/transactions'
|
||||||
@@ -9,8 +10,34 @@ import { useUiStore } from './stores/ui'
|
|||||||
const transactionStore = useTransactionStore()
|
const transactionStore = useTransactionStore()
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
|
let backButtonListener = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
transactionStore.ensureInitialized()
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
101
src/components/EchoInput.vue
Normal file
101
src/components/EchoInput.vue
Normal 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>
|
||||||
|
|
||||||
@@ -7,6 +7,18 @@ export const useSettingsStore = defineStore(
|
|||||||
const notificationCaptureEnabled = ref(true)
|
const notificationCaptureEnabled = ref(true)
|
||||||
const aiAutoCategoryEnabled = ref(false)
|
const aiAutoCategoryEnabled = ref(false)
|
||||||
const monthlyBudget = ref(12000)
|
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 profileName = ref('Echo 用户')
|
||||||
const profileAvatar = ref('')
|
const profileAvatar = ref('')
|
||||||
|
|
||||||
@@ -23,6 +35,38 @@ export const useSettingsStore = defineStore(
|
|||||||
monthlyBudget.value = Number.isFinite(numeric) && numeric >= 0 ? numeric : 0
|
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) => {
|
const setProfileName = (value) => {
|
||||||
profileName.value = (value || '').trim() || 'Echo 用户'
|
profileName.value = (value || '').trim() || 'Echo 用户'
|
||||||
}
|
}
|
||||||
@@ -35,11 +79,21 @@ export const useSettingsStore = defineStore(
|
|||||||
notificationCaptureEnabled,
|
notificationCaptureEnabled,
|
||||||
aiAutoCategoryEnabled,
|
aiAutoCategoryEnabled,
|
||||||
monthlyBudget,
|
monthlyBudget,
|
||||||
|
budgetResetCycle,
|
||||||
|
budgetMonthlyResetDay,
|
||||||
|
budgetCustomStartDate,
|
||||||
|
categoryBudgets,
|
||||||
|
categoryBudgetEnabled,
|
||||||
profileName,
|
profileName,
|
||||||
profileAvatar,
|
profileAvatar,
|
||||||
setNotificationCaptureEnabled,
|
setNotificationCaptureEnabled,
|
||||||
setAiAutoCategoryEnabled,
|
setAiAutoCategoryEnabled,
|
||||||
setMonthlyBudget,
|
setMonthlyBudget,
|
||||||
|
setBudgetResetCycle,
|
||||||
|
setBudgetMonthlyResetDay,
|
||||||
|
setBudgetCustomStartDate,
|
||||||
|
setCategoryBudget,
|
||||||
|
setCategoryBudgetEnabled,
|
||||||
setProfileName,
|
setProfileName,
|
||||||
setProfileAvatar,
|
setProfileAvatar,
|
||||||
}
|
}
|
||||||
@@ -50,6 +104,11 @@ export const useSettingsStore = defineStore(
|
|||||||
'notificationCaptureEnabled',
|
'notificationCaptureEnabled',
|
||||||
'aiAutoCategoryEnabled',
|
'aiAutoCategoryEnabled',
|
||||||
'monthlyBudget',
|
'monthlyBudget',
|
||||||
|
'budgetResetCycle',
|
||||||
|
'budgetMonthlyResetDay',
|
||||||
|
'budgetCustomStartDate',
|
||||||
|
'categoryBudgets',
|
||||||
|
'categoryBudgetEnabled',
|
||||||
'profileName',
|
'profileName',
|
||||||
'profileAvatar',
|
'profileAvatar',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { useTransactionStore } from '../stores/transactions'
|
import { useTransactionStore } from '../stores/transactions'
|
||||||
import { useUiStore } from '../stores/ui'
|
import { useUiStore } from '../stores/ui'
|
||||||
|
import EchoInput from '../components/EchoInput.vue'
|
||||||
|
|
||||||
const transactionStore = useTransactionStore()
|
const transactionStore = useTransactionStore()
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
@@ -9,8 +10,11 @@ const uiStore = useUiStore()
|
|||||||
const editingId = computed(() => uiStore.editingTransactionId || '')
|
const editingId = computed(() => uiStore.editingTransactionId || '')
|
||||||
const feedback = ref('')
|
const feedback = ref('')
|
||||||
const saving = ref(false)
|
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 toDatetimeLocal = (value) => {
|
||||||
const date = value ? new Date(value) : new Date()
|
const date = value ? new Date(value) : new Date()
|
||||||
@@ -109,15 +113,58 @@ const deleteEntry = async () => {
|
|||||||
closePanel()
|
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()
|
transactionStore.ensureInitialized()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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
|
<div
|
||||||
class="bg-white w-full rounded-t-[32px] p-6 pb-10 shadow-2xl max-h-[80vh] overflow-y-auto hide-scrollbar"
|
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 class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -157,29 +204,25 @@ transactionStore.ensureInitialized()
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<label class="block">
|
<EchoInput
|
||||||
<span class="text-xs font-bold text-stone-400">金额</span>
|
v-model="form.amount"
|
||||||
<div class="mt-2 flex items-center gap-2 rounded-2xl border border-stone-200 px-4 py-3">
|
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>
|
<span class="text-stone-400 text-sm">¥</span>
|
||||||
<input
|
</template>
|
||||||
v-model="form.amount"
|
</EchoInput>
|
||||||
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>
|
|
||||||
|
|
||||||
<label class="block">
|
<EchoInput
|
||||||
<span class="text-xs font-bold text-stone-400">商户 / 来源</span>
|
v-model="form.merchant"
|
||||||
<input
|
label="商户 / 来源"
|
||||||
v-model="form.merchant"
|
placeholder="如 Starbucks / 滴滴出行"
|
||||||
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>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs font-bold text-stone-400">分类</span>
|
<span class="text-xs font-bold text-stone-400">分类</span>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const router = useRouter()
|
|||||||
const transactionStore = useTransactionStore()
|
const transactionStore = useTransactionStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const uiStore = useUiStore()
|
const uiStore = useUiStore()
|
||||||
const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions } =
|
const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions, sortedTransactions } =
|
||||||
storeToRefs(transactionStore)
|
storeToRefs(transactionStore)
|
||||||
const { notifications, confirmNotification, dismissNotification, processingId, syncNotifications } =
|
const { notifications, confirmNotification, dismissNotification, processingId, syncNotifications } =
|
||||||
useTransactionEntry()
|
useTransactionEntry()
|
||||||
@@ -44,6 +44,7 @@ onBeforeUnmount(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const monthlyBudget = computed(() => settingsStore.monthlyBudget || 0)
|
const monthlyBudget = computed(() => settingsStore.monthlyBudget || 0)
|
||||||
|
const budgetResetCycle = computed(() => settingsStore.budgetResetCycle || 'monthly')
|
||||||
|
|
||||||
const currentDayLabel = computed(
|
const currentDayLabel = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -54,8 +55,46 @@ const currentDayLabel = computed(
|
|||||||
}).format(new Date()),
|
}).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 budgetUsage = computed(() => {
|
||||||
const expense = Math.abs(totalExpense.value || 0)
|
const expense = Math.abs(periodExpense.value || 0)
|
||||||
const budget = monthlyBudget.value || 0
|
const budget = monthlyBudget.value || 0
|
||||||
if (!budget) return 0
|
if (!budget) return 0
|
||||||
return Math.min(expense / budget, 1)
|
return Math.min(expense / budget, 1)
|
||||||
@@ -63,7 +102,7 @@ const budgetUsage = computed(() => {
|
|||||||
|
|
||||||
const balance = computed(() => (totalIncome.value || 0) + (totalExpense.value || 0))
|
const balance = computed(() => (totalIncome.value || 0) + (totalExpense.value || 0))
|
||||||
const remainingBudget = computed(() =>
|
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)
|
const todayIncomeValue = computed(() => todaysIncome.value || 0)
|
||||||
@@ -91,18 +130,6 @@ const categoryMeta = {
|
|||||||
|
|
||||||
const getCategoryMeta = (category) => categoryMeta[category] || categoryMeta.default
|
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 = () => {
|
const goSettings = () => {
|
||||||
router.push({ name: 'settings' })
|
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="bg-white rounded-2xl p-4 shadow-sm border border-stone-100">
|
||||||
<div class="flex justify-between items-center mb-2">
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs font-bold text-stone-800">
|
<span class="text-xs font-bold text-stone-800">
|
||||||
{{ Math.round(budgetUsage * 100) }}%
|
{{ Math.round(budgetUsage * 100) }}%
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
class="text-[10px] text-stone-400 underline-offset-2 hover:text-stone-600"
|
class="text-[10px] text-stone-400 underline-offset-2 hover:text-stone-600"
|
||||||
@click="handleEditBudget"
|
@click="goSettings"
|
||||||
>
|
>
|
||||||
调整
|
去设置
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,9 +232,29 @@ const openTransactionDetail = (tx) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between mt-2 text-[10px] text-stone-400">
|
<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>
|
<span>剩余 {{ formatCurrency(remainingBudget) }}</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const categoryChips = [
|
|||||||
{ label: '通勤', value: 'Transport', icon: 'ph-taxi' },
|
{ label: '通勤', value: 'Transport', icon: 'ph-taxi' },
|
||||||
{ label: '健康', value: 'Health', icon: 'ph-heartbeat' },
|
{ label: '健康', value: 'Health', icon: 'ph-heartbeat' },
|
||||||
{ label: '买菜', value: 'Groceries', icon: 'ph-basket' },
|
{ label: '买菜', value: 'Groceries', icon: 'ph-basket' },
|
||||||
|
{ label: '娱乐', value: 'Entertainment', icon: 'ph-game-controller' },
|
||||||
{ label: '收入', value: 'Income', icon: 'ph-wallet' },
|
{ label: '收入', value: 'Income', icon: 'ph-wallet' },
|
||||||
{ label: '其他', value: 'Uncategorized', icon: 'ph-dots-three-outline' },
|
{ label: '其他', value: 'Uncategorized', icon: 'ph-dots-three-outline' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
|
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
|
import EchoInput from '../components/EchoInput.vue'
|
||||||
|
|
||||||
// 从 Vite 注入的版本号(来源于 package.json),用于在设置页展示
|
// 从 Vite 注入的版本号(来源于 package.json),用于在设置页展示
|
||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
@@ -9,6 +10,7 @@ const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0
|
|||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const fileInputRef = ref(null)
|
const fileInputRef = ref(null)
|
||||||
|
const editingProfileName = ref(false)
|
||||||
|
|
||||||
const notificationCaptureEnabled = computed({
|
const notificationCaptureEnabled = computed({
|
||||||
get: () => settingsStore.notificationCaptureEnabled,
|
get: () => settingsStore.notificationCaptureEnabled,
|
||||||
@@ -56,13 +58,49 @@ const toggleAiAutoCategory = () => {
|
|||||||
aiAutoCategoryEnabled.value = !aiAutoCategoryEnabled.value
|
aiAutoCategoryEnabled.value = !aiAutoCategoryEnabled.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditProfileName = () => {
|
const profileNameModel = computed({
|
||||||
const current = settingsStore.profileName || 'Echo 用户'
|
get: () => settingsStore.profileName,
|
||||||
const input = window.prompt('修改昵称', current)
|
set: (value) => settingsStore.setProfileName(value),
|
||||||
if (input == null) return
|
})
|
||||||
settingsStore.setProfileName(input)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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 = () => {
|
const handleAvatarClick = () => {
|
||||||
if (fileInputRef.value) {
|
if (fileInputRef.value) {
|
||||||
fileInputRef.value.click()
|
fileInputRef.value.click()
|
||||||
@@ -137,21 +175,152 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 mb-1">
|
||||||
<h3 class="font-bold text-lg text-stone-800">
|
<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 }}
|
{{ settingsStore.profileName }}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
class="text-[11px] text-stone-400 underline-offset-2 hover:text-stone-600"
|
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="handleEditProfileName"
|
@click="editingProfileName = !editingProfileName"
|
||||||
>
|
>
|
||||||
编辑
|
<i class="ph-bold ph-pencil-simple" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-stone-400">本地优先 · 数据只存这台设备</p>
|
<p class="text-xs text-stone-400 mb-2">数据只存放在本地</p>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- 自动化 & AI -->
|
||||||
<section>
|
<section>
|
||||||
<h3 class="text-xs font-bold text-stone-400 uppercase tracking-widest mb-3 ml-2">
|
<h3 class="text-xs font-bold text-stone-400 uppercase tracking-widest mb-3 ml-2">
|
||||||
@@ -285,7 +454,7 @@ onMounted(() => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="text-center pt-4">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user