317 lines
13 KiB
Vue
317 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||
import BudgetCard from '../../../components/budgets/BudgetCard.vue';
|
||
import LucideIcon from '../../../components/common/LucideIcon.vue';
|
||
import { useCreateBudgetMutation, useDeleteBudgetMutation, useBudgetsQuery } from '../../../composables/useBudgets';
|
||
import { useNotificationStatusQuery } from '../../../composables/useNotifications';
|
||
import { useAuthStore } from '../../../stores/auth';
|
||
|
||
const authStore = useAuthStore();
|
||
|
||
const preferences = reactive({
|
||
notifications: true,
|
||
aiSuggestions: true,
|
||
experimentalLab: false
|
||
});
|
||
|
||
const { data: budgets } = useBudgetsQuery();
|
||
const createBudget = useCreateBudgetMutation();
|
||
const deleteBudget = useDeleteBudgetMutation();
|
||
const notificationStatusQuery = useNotificationStatusQuery();
|
||
const notificationStatus = computed(() => notificationStatusQuery.data.value);
|
||
|
||
const budgetSheetOpen = ref(false);
|
||
const budgetForm = reactive({
|
||
category: '',
|
||
amount: '',
|
||
period: 'monthly',
|
||
threshold: 0.8
|
||
});
|
||
|
||
const isCreatingBudget = computed(() => createBudget.isPending.value);
|
||
const canCopySecret = computed(() => Boolean(notificationStatus.value?.webhookSecret));
|
||
const secretDisplay = computed(() => {
|
||
const status = notificationStatus.value;
|
||
if (!status) return '未配置';
|
||
if (status.webhookSecret) return status.webhookSecret;
|
||
if (status.secretHint) return status.secretHint;
|
||
return '未配置';
|
||
});
|
||
const formattedLastNotification = computed(() => {
|
||
const status = notificationStatus.value;
|
||
if (!status?.lastNotificationAt) return '暂无记录';
|
||
return new Date(status.lastNotificationAt).toLocaleString('zh-CN', { hour12: false });
|
||
});
|
||
|
||
const copyFeedback = ref<string | null>(null);
|
||
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
const showCopyFeedback = (message: string) => {
|
||
copyFeedback.value = message;
|
||
if (copyTimeout) {
|
||
clearTimeout(copyTimeout);
|
||
}
|
||
copyTimeout = setTimeout(() => {
|
||
copyFeedback.value = null;
|
||
}, 2000);
|
||
};
|
||
|
||
const copyText = async (text: string, label: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
showCopyFeedback(`${label} 已复制`);
|
||
} catch (error) {
|
||
console.warn('Clipboard copy failed', error);
|
||
showCopyFeedback('复制失败,请手动复制');
|
||
}
|
||
};
|
||
|
||
onBeforeUnmount(() => {
|
||
if (copyTimeout) {
|
||
clearTimeout(copyTimeout);
|
||
}
|
||
});
|
||
|
||
const resetBudgetForm = () => {
|
||
budgetForm.category = '';
|
||
budgetForm.amount = '';
|
||
budgetForm.period = 'monthly';
|
||
budgetForm.threshold = 0.8;
|
||
};
|
||
|
||
const submitBudget = async () => {
|
||
if (!budgetForm.category || !budgetForm.amount) return;
|
||
await createBudget.mutateAsync({
|
||
category: budgetForm.category,
|
||
amount: Number(budgetForm.amount),
|
||
period: budgetForm.period as 'monthly' | 'weekly',
|
||
threshold: budgetForm.threshold,
|
||
currency: 'CNY'
|
||
});
|
||
resetBudgetForm();
|
||
budgetSheetOpen.value = false;
|
||
};
|
||
|
||
const removeBudget = async (id: string) => {
|
||
await deleteBudget.mutateAsync(id);
|
||
};
|
||
|
||
const handleLogout = () => {
|
||
authStore.clearSession();
|
||
};
|
||
</script>
|
||
|
||
<template>
|
||
<div class="p-6 pt-10 pb-24 space-y-8">
|
||
<header class="space-y-2">
|
||
<p class="text-sm text-gray-500">掌控你的记账体验</p>
|
||
<h1 class="text-3xl font-bold text-gray-900">设置中心</h1>
|
||
</header>
|
||
|
||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||
<h2 class="text-lg font-semibold text-gray-900">账户信息</h2>
|
||
<div class="flex items-center space-x-4">
|
||
<div class="w-14 h-14 rounded-full bg-indigo-100 flex items-center justify-center">
|
||
<LucideIcon name="user" class="text-indigo-600" :size="28" />
|
||
</div>
|
||
<div>
|
||
<p class="text-base font-semibold text-gray-900">{{ authStore.profile?.displayName ?? '示例用户' }}</p>
|
||
<p class="text-sm text-gray-500">{{ authStore.profile?.email ?? 'user@example.com' }}</p>
|
||
</div>
|
||
</div>
|
||
<button class="text-sm text-indigo-500 font-medium">编辑资料</button>
|
||
</section>
|
||
|
||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||
<h2 class="text-lg font-semibold text-gray-900">预算管理</h2>
|
||
<p class="text-sm text-gray-500">为高频分类设置预算阈值,接近时自动提醒。</p>
|
||
<div class="space-y-4">
|
||
<BudgetCard v-for="budget in budgets" :key="budget.id" :budget="budget" />
|
||
<button
|
||
class="w-full flex items-center justify-center space-x-2 border border-dashed border-indigo-400 rounded-2xl py-3 text-indigo-500 font-semibold"
|
||
@click="budgetSheetOpen = true"
|
||
>
|
||
<LucideIcon name="plus" :size="20" />
|
||
<span>新增预算</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||
<div class="flex items-center justify-between">
|
||
<div>
|
||
<h2 class="text-lg font-semibold text-gray-900">通知监听</h2>
|
||
<p class="text-sm text-gray-500">配置原生插件的 Webhook 地址与安全密钥。</p>
|
||
</div>
|
||
<button
|
||
class="text-sm text-indigo-500 font-medium disabled:opacity-60"
|
||
:disabled="notificationStatusQuery.isFetching.value"
|
||
@click="notificationStatusQuery.refetch()"
|
||
>
|
||
{{ notificationStatusQuery.isFetching.value ? '刷新中...' : '刷新' }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<div class="bg-gray-50 rounded-2xl p-4 space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-sm text-gray-500">Webhook Endpoint</span>
|
||
<button
|
||
class="text-xs text-indigo-500 font-medium"
|
||
@click="notificationStatus?.ingestEndpoint && copyText(notificationStatus.ingestEndpoint, 'Webhook 地址')"
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
<p class="text-sm font-mono break-all text-gray-800">
|
||
{{ notificationStatus?.ingestEndpoint ?? 'http://localhost:4000/api/transactions/notification' }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="bg-gray-50 rounded-2xl p-4 space-y-2">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-sm text-gray-500">HMAC 密钥</span>
|
||
<button
|
||
class="text-xs font-medium"
|
||
:class="canCopySecret ? 'text-indigo-500' : 'text-gray-400 cursor-not-allowed'
|
||
"
|
||
:disabled="!canCopySecret"
|
||
@click="notificationStatus?.webhookSecret && copyText(notificationStatus.webhookSecret, 'HMAC 密钥')"
|
||
>
|
||
复制
|
||
</button>
|
||
</div>
|
||
<p class="text-sm font-mono break-all text-gray-800">
|
||
{{ secretDisplay }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="border border-gray-100 rounded-2xl p-4">
|
||
<p class="text-xs text-gray-500">通知入库</p>
|
||
<p class="text-xl font-semibold text-gray-900">{{ notificationStatus?.ingestedCount ?? 0 }}</p>
|
||
</div>
|
||
<div class="border border-gray-100 rounded-2xl p-4">
|
||
<p class="text-xs text-gray-500">最新入库</p>
|
||
<p class="text-sm text-gray-900">{{ formattedLastNotification }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<p class="text-xs text-gray-500 mb-2">包名白名单</p>
|
||
<div class="flex flex-wrap gap-2">
|
||
<span
|
||
v-for="pkg in notificationStatus?.packageWhitelist ?? []"
|
||
:key="pkg"
|
||
class="px-3 py-1 text-xs rounded-full bg-gray-100 text-gray-700"
|
||
>
|
||
{{ pkg }}
|
||
</span>
|
||
<span v-if="(notificationStatus?.packageWhitelist?.length ?? 0) === 0" class="text-xs text-gray-400">
|
||
暂无包名配置
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<p v-if="copyFeedback" class="text-xs text-emerald-500">{{ copyFeedback }}</p>
|
||
</section>
|
||
|
||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||
<h2 class="text-lg font-semibold text-gray-900">个性化偏好</h2>
|
||
<div class="space-y-4">
|
||
<label class="flex items-center justify-between">
|
||
<div>
|
||
<p class="font-medium text-gray-800">通知提醒</p>
|
||
<p class="text-sm text-gray-500">开启后自动检测通知并辅助记账</p>
|
||
</div>
|
||
<input v-model="preferences.notifications" type="checkbox" class="w-12 h-6 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition" />
|
||
</label>
|
||
<label class="flex items-center justify-between">
|
||
<div>
|
||
<p class="font-medium text-gray-800">AI 理财建议</p>
|
||
<p class="text-sm text-gray-500">根据消费习惯提供个性化提示</p>
|
||
</div>
|
||
<input v-model="preferences.aiSuggestions" type="checkbox" class="w-12 h-6 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition" />
|
||
</label>
|
||
<label class="flex items-center justify-between">
|
||
<div>
|
||
<p class="font-medium text-gray-800">实验室功能</p>
|
||
<p class="text-sm text-gray-500">抢先体验新功能(可能不稳定)</p>
|
||
</div>
|
||
<input v-model="preferences.experimentalLab" type="checkbox" class="w-12 h-6 rounded-full appearance-none bg-gray-200 checked:bg-indigo-500 transition" />
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="bg-white rounded-3xl p-6 space-y-4">
|
||
<h2 class="text-lg font-semibold text-gray-900">安全与隐私</h2>
|
||
<div class="space-y-3 text-sm text-gray-600">
|
||
<p>· 支持密码找回(邮箱验证码)</p>
|
||
<p>· 可申请导出全部数据</p>
|
||
<p>· 支持平台内删除账户</p>
|
||
</div>
|
||
<button class="w-full py-3 bg-red-50 text-red-600 font-semibold rounded-2xl" @click="handleLogout">退出登录</button>
|
||
</section>
|
||
|
||
<div
|
||
v-if="budgetSheetOpen"
|
||
class="fixed inset-0 bg-black/40 flex items-end justify-center"
|
||
@click.self="budgetSheetOpen = false"
|
||
>
|
||
<div class="w-full max-w-xl bg-white rounded-t-3xl p-6 space-y-4">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-lg font-semibold text-gray-900">新增预算</h3>
|
||
<button @click="budgetSheetOpen = false">
|
||
<LucideIcon name="x" :size="22" />
|
||
</button>
|
||
</div>
|
||
<div class="space-y-3">
|
||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||
分类
|
||
<input v-model="budgetForm.category" type="text" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||
</label>
|
||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||
金额 (¥)
|
||
<input v-model="budgetForm.amount" type="number" min="0" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||
</label>
|
||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||
周期
|
||
<select v-model="budgetForm.period" class="px-4 py-2 border border-gray-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||
<option value="monthly">月度</option>
|
||
<option value="weekly">每周</option>
|
||
</select>
|
||
</label>
|
||
<label class="flex flex-col text-sm font-medium text-gray-700 space-y-2">
|
||
阈值 ({{ Math.round(budgetForm.threshold * 100) }}%)
|
||
<input v-model.number="budgetForm.threshold" type="range" min="0.4" max="1" step="0.05" />
|
||
</label>
|
||
<div class="flex justify-end space-x-3 pt-2">
|
||
<button class="px-4 py-2 text-sm text-gray-500" @click="budgetSheetOpen = false">取消</button>
|
||
<button
|
||
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
|
||
:disabled="isCreatingBudget"
|
||
@click="submitBudget"
|
||
>
|
||
{{ isCreatingBudget ? '保存中...' : '保存预算' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="budgets && budgets.length" class="border-t border-gray-100 pt-4 space-y-2">
|
||
<p class="text-xs text-gray-500">长按预算卡片可在未来版本中编辑阈值。当前可在此快速删除。</p>
|
||
<div class="space-y-2">
|
||
<button
|
||
v-for="budget in budgets"
|
||
:key="budget.id"
|
||
class="w-full text-left text-sm text-red-500 border border-red-200 rounded-xl px-4 py-2"
|
||
@click="removeBudget(budget.id)"
|
||
>
|
||
删除「{{ budget.category }}」预算
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|