Files
echo/src/views/SettingsView.vue

509 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { computed, onMounted, ref } from 'vue'
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
import { useSettingsStore } from '../stores/settings'
import EchoInput from '../components/EchoInput.vue'
import { BUDGET_CATEGORY_OPTIONS } from '../config/transactionCategories.js'
import { AI_MODEL_OPTIONS, AI_PROVIDER_OPTIONS } from '../config/transactionTags.js'
// 从 Vite 注入的版本号(来源于 package.json用于在设置页展示
// eslint-disable-next-line no-undef
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '0.0.0'
const settingsStore = useSettingsStore()
const fileInputRef = ref(null)
const editingProfileName = ref(false)
const notificationCaptureEnabled = computed({
get: () => settingsStore.notificationCaptureEnabled,
set: (value) => settingsStore.setNotificationCaptureEnabled(value),
})
const aiAutoCategoryEnabled = computed({
get: () => settingsStore.aiAutoCategoryEnabled,
set: (value) => settingsStore.setAiAutoCategoryEnabled(value),
})
const aiApiKey = computed({
get: () => settingsStore.aiApiKey,
set: (value) => settingsStore.setAiApiKey(value),
})
const aiAutoApplyThreshold = computed({
get: () => String(settingsStore.aiAutoApplyThreshold ?? 0.9),
set: (value) => settingsStore.setAiAutoApplyThreshold(value),
})
const currentAiProviderLabel = computed(
() => AI_PROVIDER_OPTIONS.find((item) => item.value === settingsStore.aiProvider)?.label || 'DeepSeek',
)
const currentAiModelLabel = computed(
() => AI_MODEL_OPTIONS.find((item) => item.value === settingsStore.aiModel)?.label || 'deepseek-chat',
)
const nativeBridgeReady = isNativeNotificationBridgeAvailable()
const notificationPermissionGranted = ref(true)
const checkingPermission = ref(false)
const checkNotificationPermission = async () => {
if (!nativeBridgeReady) return
checkingPermission.value = true
try {
const result = await NotificationBridge.hasPermission()
notificationPermissionGranted.value = !!result?.granted
} catch {
notificationPermissionGranted.value = false
} finally {
checkingPermission.value = false
}
}
const toggleNotificationCapture = async () => {
const next = !notificationCaptureEnabled.value
notificationCaptureEnabled.value = next
if (next && nativeBridgeReady) {
await checkNotificationPermission()
if (!notificationPermissionGranted.value) {
try {
await NotificationBridge.openNotificationSettings()
} catch {
// 插件调用失败时忽略,由下方文案提示用户
}
}
}
}
const toggleAiAutoCategory = () => {
aiAutoCategoryEnabled.value = !aiAutoCategoryEnabled.value
}
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 = BUDGET_CATEGORY_OPTIONS
const handleAvatarClick = () => {
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
const handleAvatarChange = (event) => {
const [file] = event.target.files || []
if (!file) return
if (!file.type || !file.type.startsWith('image/')) {
window.alert('请选择图片文件作为头像')
return
}
const reader = new FileReader()
reader.onload = () => {
if (typeof reader.result === 'string') {
settingsStore.setProfileAvatar(reader.result)
}
}
reader.readAsDataURL(file)
}
const handleExportData = () => {
window.alert('导出功能即将上线:届时可以一键导出 CSV / Excel。当前版本建议先通过截图或复制方式备份关键信息。')
}
const handleClearCache = () => {
const ok = window.confirm('确认清除缓存吗?这不会删除正式账本数据,但会重置筛选偏好等本地设置。')
if (!ok) return
try {
// 暂时只清理本地设置缓存,避免误删账本
localStorage.removeItem('settings')
window.alert('已清除设置缓存,部分偏好将在下次启动时重置。')
} catch {
window.alert('清除缓存失败,可稍后重试。')
}
}
onMounted(() => {
void checkNotificationPermission()
})
</script>
<template>
<div class="space-y-6 animate-fade-in pb-10">
<h2 class="text-2xl font-extrabold text-stone-800">设置</h2>
<!-- 个人信息 / 简短说明 -->
<div class="bg-white rounded-3xl p-5 flex items-center gap-4 shadow-sm border border-stone-100">
<div class="relative">
<img
:src="
settingsStore.profileAvatar ||
'/default-avatar.svg'
"
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 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="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 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">
自动化 &amp; AI
</h3>
<div class="bg-white rounded-3xl overflow-hidden shadow-sm border border-stone-100">
<!-- 自动捕获支付通知 -->
<div class="flex flex-col gap-1 border-b border-stone-50">
<div class="flex items-center justify-between p-4">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full bg-blue-50 text-blue-500 flex items-center justify-center"
>
<i class="ph-fill ph-bell-ringing" />
</div>
<div>
<p class="font-bold text-stone-700 text-sm">自动捕获支付通知</p>
<p class="text-[11px] text-stone-400 mt-0.5">
监听支付宝 / 微信 / 银行 App 通知在本机自动生成记账草稿
</p>
</div>
</div>
<button
class="w-11 h-6 rounded-full px-0.5 flex items-center transition-all duration-200"
:class="
notificationCaptureEnabled
? 'bg-orange-400 justify-end'
: 'bg-stone-200 justify-start'
"
@click="toggleNotificationCapture"
>
<div class="w-4 h-4 bg-white rounded-full shadow-sm" />
</button>
</div>
<div v-if="notificationCaptureEnabled" class="px-4 pb-4">
<div
v-if="!notificationPermissionGranted"
class="bg-orange-50 border border-orange-200 rounded-2xl px-3 py-2 flex items-start gap-2"
>
<i class="ph-bold ph-warning text-orange-500 mt-0.5" />
<div class="text-[11px] text-orange-700 leading-relaxed">
<p>系统尚未授予 Echo通知使用权否则无法自动捕获通知</p>
<button
class="mt-1 text-[11px] font-bold text-orange-600 underline"
@click="NotificationBridge.openNotificationSettings()"
>
去系统设置开启
</button>
</div>
</div>
<p v-else class="text-[11px] text-emerald-600 flex items-center gap-1">
<i class="ph-bold ph-check-circle" />
通知权限已开启Echo 会在收到新通知后自动刷新首页
</p>
</div>
</div>
<!-- AI 自动分类与标签 -->
<div class="flex flex-col gap-1 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full bg-purple-50 text-purple-500 flex items-center justify-center"
>
<i class="ph-fill ph-magic-wand" />
</div>
<div>
<p class="font-bold text-stone-700 text-sm">AI 自动分类与标签</p>
<p class="text-[11px] text-stone-400 mt-0.5">
使用本地 DeepSeek API Key 为新交易补全分类标签并记录商户画像
</p>
</div>
</div>
<button
class="w-11 h-6 rounded-full px-0.5 flex items-center transition-all duration-200"
:class="aiAutoCategoryEnabled ? 'bg-orange-400 justify-end' : 'bg-stone-200 justify-start'"
@click="toggleAiAutoCategory"
>
<div class="w-4 h-4 bg-white rounded-full shadow-sm" />
</button>
</div>
<div v-if="aiAutoCategoryEnabled" class="px-4 pt-3 pb-1 space-y-3">
<div class="flex flex-wrap gap-2">
<span class="px-3 py-1 rounded-full bg-stone-100 text-[11px] font-bold text-stone-600">
Provider · {{ currentAiProviderLabel }}
</span>
<span class="px-3 py-1 rounded-full bg-stone-100 text-[11px] font-bold text-stone-600">
Model · {{ currentAiModelLabel }}
</span>
</div>
<EchoInput
v-model="aiApiKey"
label="DeepSeek API Key"
type="password"
placeholder="sk-..."
hint="仅保存在当前设备,本阶段用于本地直连 DeepSeek。"
/>
<EchoInput
v-model="aiAutoApplyThreshold"
label="自动应用阈值0-1"
type="number"
inputmode="decimal"
step="0.05"
placeholder="例如 0.9"
hint="高于该阈值的 AI 分类会自动写入交易分类,低于阈值仅作为建议保留。"
/>
<p
v-if="!settingsStore.aiApiKey"
class="text-[11px] text-amber-600 bg-amber-50 border border-amber-200 rounded-2xl px-3 py-2"
>
当前已开启 AI但还没有填写 DeepSeek API Key保存交易时不会发起 AI 分类请求
</p>
</div>
</div>
</div>
</section>
<!-- 通用设置 -->
<section>
<h3 class="text-xs font-bold text-stone-400 uppercase tracking-widest mb-3 ml-2">
通用
</h3>
<div class="bg-white rounded-3xl overflow-hidden shadow-sm border border-stone-100">
<button
class="flex w-full items-center justify-between p-4 border-b border-stone-50 active:bg-stone-50 transition text-left"
@click="handleExportData"
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full bg-orange-50 text-orange-500 flex items-center justify-center"
>
<i class="ph-fill ph-export" />
</div>
<div>
<p class="font-bold text-stone-700 text-sm">导出账单数据预留</p>
<p class="text-[11px] text-stone-400 mt-0.5">未来支持导出为 CSV / Excel 文件</p>
</div>
</div>
<i class="ph-bold ph-caret-right text-stone-300" />
</button>
<button
class="flex w-full items-center justify-between p-4 active:bg-stone-50 transition text-left"
@click="handleClearCache"
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full bg-red-50 text-red-500 flex items-center justify-center"
>
<i class="ph-fill ph-trash" />
</div>
<div>
<p class="font-bold text-stone-700 text-sm">清除缓存设置</p>
<p class="text-[11px] text-stone-400 mt-0.5">仅清理本地偏好不影响正式账本数据</p>
</div>
</div>
<span class="text-xs text-stone-400"> KB</span>
</button>
</div>
</section>
<div class="text-center pt-4">
<p class="text-xs text-stone-300">Echo · v{{ appVersion }}</p>
</div>
</div>
</template>