feat: 添加 UI 状态管理,支持新增记录底部弹窗功能,优化设置和主页交互
This commit is contained in:
@@ -2,9 +2,12 @@
|
|||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import BottomDock from './components/BottomDock.vue'
|
import BottomDock from './components/BottomDock.vue'
|
||||||
|
import AddEntryView from './views/AddEntryView.vue'
|
||||||
import { useTransactionStore } from './stores/transactions'
|
import { useTransactionStore } from './stores/transactions'
|
||||||
|
import { useUiStore } from './stores/ui'
|
||||||
|
|
||||||
const transactionStore = useTransactionStore()
|
const transactionStore = useTransactionStore()
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
transactionStore.ensureInitialized()
|
transactionStore.ensureInitialized()
|
||||||
@@ -25,6 +28,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 底部 Dock 导航 -->
|
<!-- 底部 Dock 导航 -->
|
||||||
<BottomDock />
|
<BottomDock />
|
||||||
|
|
||||||
|
<!-- 新增记录底部弹窗:在任意 Tab 上浮层显示 -->
|
||||||
|
<AddEntryView v-if="uiStore.addEntryVisible" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useUiStore } from '../stores/ui'
|
||||||
|
|
||||||
// 使用路由信息来高亮当前 Tab,而不是从父组件传递状态
|
// 使用路由信息来高亮当前 Tab,而不是从父组件传递状态
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
const currentTab = computed(() => route.name)
|
const currentTab = computed(() => route.name)
|
||||||
|
|
||||||
@@ -14,7 +16,7 @@ const goTab = (name) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openAdd = () => {
|
const openAdd = () => {
|
||||||
router.push({ name: 'add' })
|
uiStore.openAddEntry()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const nativeBridgeReady = isNativeNotificationBridgeAvailable()
|
|||||||
let permissionChecked = false
|
let permissionChecked = false
|
||||||
let bootstrapped = false
|
let bootstrapped = false
|
||||||
let nativeNotificationListener = null
|
let nativeNotificationListener = null
|
||||||
|
let pollTimer = null
|
||||||
|
const POLL_INTERVAL_MS = 60000
|
||||||
|
|
||||||
const ensureRulesReady = async () => {
|
const ensureRulesReady = async () => {
|
||||||
if (ruleSet.value.length) return ruleSet.value
|
if (ruleSet.value.length) return ruleSet.value
|
||||||
@@ -147,6 +149,15 @@ export const useTransactionEntry = () => {
|
|||||||
console.warn('[notifications] 无法注册 notificationPosted 监听', error)
|
console.warn('[notifications] 无法注册 notificationPosted 监听', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 前台轮询兜底:即使偶发漏掉原生事件,也能定期从队列补拉一次
|
||||||
|
if (!pollTimer && typeof setInterval === 'function') {
|
||||||
|
pollTimer = setInterval(() => {
|
||||||
|
if (!syncing.value) {
|
||||||
|
void syncNotifications()
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
import ListView from '../views/ListView.vue'
|
import ListView from '../views/ListView.vue'
|
||||||
import SettingsView from '../views/SettingsView.vue'
|
import SettingsView from '../views/SettingsView.vue'
|
||||||
import AddEntryView from '../views/AddEntryView.vue'
|
|
||||||
|
|
||||||
// TODO: 后续补充真实的分析页实现,这里先占位
|
// TODO: 后续补充真实的分析页实现,这里先占位
|
||||||
const AnalysisView = () => import('../views/AnalysisView.vue').catch(() => null)
|
const AnalysisView = () => import('../views/AnalysisView.vue').catch(() => null)
|
||||||
@@ -34,12 +33,6 @@ const router = createRouter({
|
|||||||
component: SettingsView,
|
component: SettingsView,
|
||||||
meta: { tab: 'settings' },
|
meta: { tab: 'settings' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/add',
|
|
||||||
name: 'add',
|
|
||||||
component: AddEntryView,
|
|
||||||
meta: { overlay: true },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -187,26 +187,59 @@ const extractAmount = (text = '') => {
|
|||||||
return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0
|
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 extractMerchant = (text, rule) => {
|
||||||
|
const content = text || ''
|
||||||
|
|
||||||
if (rule?.merchantPattern instanceof RegExp) {
|
if (rule?.merchantPattern instanceof RegExp) {
|
||||||
const m = text.match(rule.merchantPattern)
|
const m = content.match(rule.merchantPattern)
|
||||||
if (m?.[1]) return m[1].trim()
|
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 = [
|
const genericPatterns = [
|
||||||
/向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/,
|
/向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/,
|
||||||
/商户[::]?\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/,
|
/商户[::]?\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/,
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const pattern of genericPatterns) {
|
for (const pattern of genericPatterns) {
|
||||||
const m = text.match(pattern)
|
const m = content.match(pattern)
|
||||||
if (m?.[1]) return m[1].trim()
|
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) {
|
if (parts.length > 1) {
|
||||||
const candidate = parts[1].split(/[,,\s]/)[0]
|
const candidate = parts[1].split(/[,,\s]/)[0]
|
||||||
if (candidate) return candidate.trim()
|
if (candidate && !looksLikeAmount(candidate)) return candidate.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'Unknown'
|
return 'Unknown'
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ 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 profileName = ref('Echo 用户')
|
||||||
|
const profileAvatar = ref('')
|
||||||
|
|
||||||
const setNotificationCaptureEnabled = (value) => {
|
const setNotificationCaptureEnabled = (value) => {
|
||||||
notificationCaptureEnabled.value = !!value
|
notificationCaptureEnabled.value = !!value
|
||||||
@@ -15,16 +18,41 @@ export const useSettingsStore = defineStore(
|
|||||||
aiAutoCategoryEnabled.value = !!value
|
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 {
|
return {
|
||||||
notificationCaptureEnabled,
|
notificationCaptureEnabled,
|
||||||
aiAutoCategoryEnabled,
|
aiAutoCategoryEnabled,
|
||||||
|
monthlyBudget,
|
||||||
|
profileName,
|
||||||
|
profileAvatar,
|
||||||
setNotificationCaptureEnabled,
|
setNotificationCaptureEnabled,
|
||||||
setAiAutoCategoryEnabled,
|
setAiAutoCategoryEnabled,
|
||||||
|
setMonthlyBudget,
|
||||||
|
setProfileName,
|
||||||
|
setProfileAvatar,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
persist: {
|
persist: {
|
||||||
paths: ['notificationCaptureEnabled', 'aiAutoCategoryEnabled'],
|
paths: [
|
||||||
|
'notificationCaptureEnabled',
|
||||||
|
'aiAutoCategoryEnabled',
|
||||||
|
'monthlyBudget',
|
||||||
|
'profileName',
|
||||||
|
'profileAvatar',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
26
src/stores/ui.js
Normal file
26
src/stores/ui.js
Normal 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { useTransactionStore } from '../stores/transactions'
|
import { useTransactionStore } from '../stores/transactions'
|
||||||
|
import { useUiStore } from '../stores/ui'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
const transactionStore = useTransactionStore()
|
const transactionStore = useTransactionStore()
|
||||||
|
const uiStore = useUiStore()
|
||||||
|
|
||||||
const editingId = computed(() => route.query.id || '')
|
const editingId = computed(() => uiStore.editingTransactionId || '')
|
||||||
const feedback = ref('')
|
const feedback = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
@@ -58,7 +57,7 @@ const hydrateForm = () => {
|
|||||||
watch(editingId, hydrateForm, { immediate: true })
|
watch(editingId, hydrateForm, { immediate: true })
|
||||||
|
|
||||||
const closePanel = () => {
|
const closePanel = () => {
|
||||||
router.back()
|
uiStore.closeAddEntry()
|
||||||
}
|
}
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
@@ -67,8 +66,8 @@ const validate = () => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const parsedAmount = Number(form.amount)
|
const parsedAmount = Number(form.amount)
|
||||||
if (!Number.isFinite(parsedAmount) || parsedAmount <= 0) {
|
if (!Number.isFinite(parsedAmount) || parsedAmount < 0) {
|
||||||
feedback.value = '请输入有效的金额'
|
feedback.value = '金额不能为负数'
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
feedback.value = ''
|
feedback.value = ''
|
||||||
@@ -115,7 +114,9 @@ transactionStore.ensureInitialized()
|
|||||||
|
|
||||||
<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">
|
||||||
<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="w-12 h-1.5 bg-stone-200 rounded-full mx-auto mb-6" />
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
import { computed, onMounted, onBeforeUnmount } from 'vue'
|
import { computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { App } from '@capacitor/app'
|
import { App } from '@capacitor/app'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useTransactionStore } from '../stores/transactions'
|
import { useTransactionStore } from '../stores/transactions'
|
||||||
import { useTransactionEntry } from '../composables/useTransactionEntry'
|
import { useTransactionEntry } from '../composables/useTransactionEntry'
|
||||||
|
import { useSettingsStore } from '../stores/settings'
|
||||||
|
import { useUiStore } from '../stores/ui'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const transactionStore = useTransactionStore()
|
const transactionStore = useTransactionStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
const uiStore = useUiStore()
|
||||||
const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions } =
|
const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions } =
|
||||||
storeToRefs(transactionStore)
|
storeToRefs(transactionStore)
|
||||||
const { notifications, confirmNotification, dismissNotification, processingId, syncNotifications } =
|
const { notifications, confirmNotification, dismissNotification, processingId, syncNotifications } =
|
||||||
@@ -14,10 +20,13 @@ const { notifications, confirmNotification, dismissNotification, processingId, s
|
|||||||
let appStateListener = null
|
let appStateListener = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await transactionStore.ensureInitialized()
|
||||||
|
await syncNotifications()
|
||||||
try {
|
try {
|
||||||
// App 从后台回到前台时,自动同步一次通知和交易列表
|
// App 从后台回到前台时,自动刷新本地账本和通知队列
|
||||||
appStateListener = await App.addListener('appStateChange', async ({ isActive }) => {
|
appStateListener = await App.addListener('appStateChange', async ({ isActive }) => {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
|
await transactionStore.hydrateTransactions()
|
||||||
await syncNotifications()
|
await syncNotifications()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -34,8 +43,7 @@ onBeforeUnmount(() => {
|
|||||||
appStateListener = null
|
appStateListener = null
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['changeTab'])
|
const monthlyBudget = computed(() => settingsStore.monthlyBudget || 0)
|
||||||
const monthlyBudget = 12000
|
|
||||||
|
|
||||||
const currentDayLabel = computed(
|
const currentDayLabel = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -48,13 +56,14 @@ const currentDayLabel = computed(
|
|||||||
|
|
||||||
const budgetUsage = computed(() => {
|
const budgetUsage = computed(() => {
|
||||||
const expense = Math.abs(totalExpense.value || 0)
|
const expense = Math.abs(totalExpense.value || 0)
|
||||||
if (!monthlyBudget) return 0
|
const budget = monthlyBudget.value || 0
|
||||||
return Math.min(expense / monthlyBudget, 1)
|
if (!budget) return 0
|
||||||
|
return Math.min(expense / budget, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
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 - Math.abs(totalExpense.value || 0), 0),
|
Math.max((monthlyBudget.value || 0) - Math.abs(totalExpense.value || 0), 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
const todayIncomeValue = computed(() => todaysIncome.value || 0)
|
const todayIncomeValue = computed(() => todaysIncome.value || 0)
|
||||||
@@ -82,7 +91,34 @@ const categoryMeta = {
|
|||||||
|
|
||||||
const getCategoryMeta = (category) => categoryMeta[category] || categoryMeta.default
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,10 +132,13 @@ transactionStore.ensureInitialized()
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="w-10 h-10 rounded-full bg-orange-50 p-0.5 cursor-pointer border border-orange-100"
|
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
|
<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"
|
class="rounded-full"
|
||||||
alt="User"
|
alt="User"
|
||||||
/>
|
/>
|
||||||
@@ -147,9 +186,17 @@ transactionStore.ensureInitialized()
|
|||||||
<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>
|
||||||
<span class="text-xs font-bold text-stone-800">
|
<div class="flex items-center gap-2">
|
||||||
{{ Math.round(budgetUsage * 100) }}%
|
<span class="text-xs font-bold text-stone-800">
|
||||||
</span>
|
{{ 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>
|
||||||
<div class="w-full bg-stone-100 rounded-full h-2.5 overflow-hidden">
|
<div class="w-full bg-stone-100 rounded-full h-2.5 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -179,7 +226,7 @@ transactionStore.ensureInitialized()
|
|||||||
<h3 class="font-bold text-stone-700">最新流水</h3>
|
<h3 class="font-bold text-stone-700">最新流水</h3>
|
||||||
<button
|
<button
|
||||||
class="text-xs font-bold text-gradient-warm"
|
class="text-xs font-bold text-gradient-warm"
|
||||||
@click="emit('changeTab', 'list')"
|
@click="goList"
|
||||||
>
|
>
|
||||||
查看全部
|
查看全部
|
||||||
</button>
|
</button>
|
||||||
@@ -188,7 +235,8 @@ transactionStore.ensureInitialized()
|
|||||||
<div
|
<div
|
||||||
v-for="tx in recentTransactions"
|
v-for="tx in recentTransactions"
|
||||||
:key="tx.id"
|
: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 class="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
@@ -277,7 +325,7 @@ transactionStore.ensureInitialized()
|
|||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div
|
<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"
|
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
|
<div
|
||||||
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-purple-50 rounded-full blur-xl group-hover:bg-purple-100 transition"
|
class="absolute right-[-10px] top-[-10px] w-20 h-20 bg-purple-50 rounded-full blur-xl group-hover:bg-purple-100 transition"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useTransactionStore } from '../stores/transactions'
|
import { useTransactionStore } from '../stores/transactions'
|
||||||
|
import { useUiStore } from '../stores/ui'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const transactionStore = useTransactionStore()
|
const transactionStore = useTransactionStore()
|
||||||
|
const uiStore = useUiStore()
|
||||||
const { groupedTransactions, filters, loading } = storeToRefs(transactionStore)
|
const { groupedTransactions, filters, loading } = storeToRefs(transactionStore)
|
||||||
const deletingId = ref('')
|
const deletingId = ref('')
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ const formatTime = (value) =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleEdit = (item) => {
|
const handleEdit = (item) => {
|
||||||
router.push({ name: 'add', query: { id: item.id } })
|
uiStore.openAddEntry(item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (item) => {
|
const handleDelete = async (item) => {
|
||||||
@@ -63,7 +63,7 @@ transactionStore.ensureInitialized()
|
|||||||
<h2 class="text-2xl font-extrabold text-stone-800">账单明细</h2>
|
<h2 class="text-2xl font-extrabold text-stone-800">账单明细</h2>
|
||||||
<button
|
<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"
|
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" /> 新增
|
<i class="ph-bold ph-plus" /> 新增
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ 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'
|
||||||
|
|
||||||
|
// 从 Vite 注入的版本号(来源于 package.json),用于在设置页展示
|
||||||
|
// eslint-disable-next-line no-undef
|
||||||
|
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
const fileInputRef = ref(null)
|
||||||
|
|
||||||
const notificationCaptureEnabled = computed({
|
const notificationCaptureEnabled = computed({
|
||||||
get: () => settingsStore.notificationCaptureEnabled,
|
get: () => settingsStore.notificationCaptureEnabled,
|
||||||
@@ -51,6 +56,35 @@ const toggleAiAutoCategory = () => {
|
|||||||
aiAutoCategoryEnabled.value = !aiAutoCategoryEnabled.value
|
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 = () => {
|
const handleExportData = () => {
|
||||||
window.alert('导出功能即将上线:届时可以一键导出 CSV / Excel。当前版本建议先通过截图或复制方式备份关键信息。')
|
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">
|
<div class="bg-white rounded-3xl p-5 flex items-center gap-4 shadow-sm border border-stone-100">
|
||||||
<img
|
<div class="relative">
|
||||||
src="https://api.dicebear.com/7.x/avataaars/svg?seed=Echo"
|
<img
|
||||||
alt="Avatar"
|
:src="
|
||||||
class="w-16 h-16 rounded-full bg-stone-100"
|
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">
|
<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>
|
<p class="text-xs text-stone-400">本地优先 · 数据只存这台设备</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -222,7 +285,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 Beta</p>
|
<p class="text-xs text-stone-300">Echo · Local-first · v{{ appVersion }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(appVersion),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user