Files
AI-Bill/apps/frontend/src/features/settings/pages/SettingsPage.vue
2025-11-01 09:24:26 +08:00

317 lines
13 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 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>