Files
AI-Bill/apps/frontend/src/features/settings/pages/SettingsPage.vue

317 lines
13 KiB
Vue
Raw Normal View History

2025-11-01 09:24:26 +08:00
<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>