feat: 添加预算管理功能,支持分类预算和重置周期设置,优化用户界面
This commit is contained in:
29
src/App.vue
29
src/App.vue
@@ -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>
|
||||
|
||||
|
||||
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 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',
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user