feat:添加登录功能

This commit is contained in:
2025-11-10 13:58:06 +08:00
parent 49f9b28091
commit 2e2caeaab5
36 changed files with 1076 additions and 458 deletions

View File

@@ -1,5 +1,14 @@
package com.bill.ai;
import android.os.Bundle;
import com.bill.ai.notification.NotificationPermissionPlugin;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(NotificationPermissionPlugin.class);
super.onCreate(savedInstanceState);
}
}

View File

@@ -0,0 +1,84 @@
package com.bill.ai.notification;
import android.content.Context;
import android.content.Intent;
import android.provider.Settings;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationManagerCompat;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.Permission;
import com.getcapacitor.annotation.PermissionCallback;
import com.getcapacitor.annotation.PluginMethod;
@CapacitorPlugin(name = "NotificationPermissions", permissions = {
@Permission(alias = "notifications", strings = {"android.permission.POST_NOTIFICATIONS"})
})
public class NotificationPermissionPlugin extends Plugin {
@PluginMethod
public void checkStatus(@NonNull PluginCall call) {
JSObject result = new JSObject();
result.put("granted", isNotificationListenerEnabled(getContext()));
result.put("postNotificationsGranted", isPostNotificationsGranted());
call.resolve(result);
}
@PluginMethod
public void requestAccess(@NonNull PluginCall call) {
boolean granted = isNotificationListenerEnabled(getContext());
if (!granted) {
openNotificationListenerSettings();
}
JSObject result = new JSObject();
result.put("opened", true);
call.resolve(result);
}
@PluginMethod
public void openSettings(@NonNull PluginCall call) {
openNotificationListenerSettings();
JSObject result = new JSObject();
result.put("opened", true);
call.resolve(result);
}
@PluginMethod
public void requestPostNotifications(@NonNull PluginCall call) {
if (isPostNotificationsGranted()) {
JSObject result = new JSObject();
result.put("granted", true);
call.resolve(result);
return;
}
requestPermissionForAlias("notifications", call, "handlePostNotificationPermission");
}
@PermissionCallback
private void handlePostNotificationPermission(PluginCall call) {
boolean granted = isPostNotificationsGranted();
JSObject result = new JSObject();
result.put("granted", granted);
call.resolve(result);
}
private boolean isNotificationListenerEnabled(Context context) {
return NotificationManagerCompat.getEnabledListenerPackages(context).contains(context.getPackageName());
}
private boolean isPostNotificationsGranted() {
return NotificationManagerCompat.from(getContext()).areNotificationsEnabled();
}
private void openNotificationListenerSettings() {
Context context = getContext();
Intent intent = new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
}
}

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@capacitor/android": "^7.4.4",
"@capacitor/app": "^7.1.0",
"@capacitor/cli": "^7.4.4",
"@capacitor/core": "^7.4.4",
"@tanstack/vue-query": "^5",

View File

@@ -1,6 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { apiClient } from '../lib/api/client';
import { sampleBudgets } from '../mocks/budgets';
import type { Budget, BudgetPayload } from '../types/budget';
const queryKey = ['budgets'];
@@ -22,15 +21,11 @@ export function useBudgetsQuery() {
return useQuery<Budget[]>({
queryKey,
queryFn: async () => {
try {
const { data } = await apiClient.get('/budgets');
return (data.data as Budget[]).map(mapBudget);
} catch (error) {
console.warn('[budgets] fallback to sample data', error);
return sampleBudgets;
}
const { data } = await apiClient.get('/budgets');
return (data.data as Budget[]).map(mapBudget);
},
initialData: sampleBudgets
refetchOnWindowFocus: true,
staleTime: 60_000
});
}

View File

@@ -1,6 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query';
import { apiClient } from '../lib/api/client';
import { sampleTransactions } from '../mocks/transactions';
import type { Transaction, TransactionPayload } from '../types/transaction';
const queryKey = ['transactions'];
@@ -28,15 +27,11 @@ export function useTransactionsQuery() {
return useQuery<Transaction[]>({
queryKey,
queryFn: async () => {
try {
const { data } = await apiClient.get('/transactions');
return (data.data as Transaction[]).map(mapTransaction);
} catch (error) {
console.warn('[transactions] fallback to sample data', error);
return sampleTransactions;
}
const { data } = await apiClient.get('/transactions');
return (data.data as Transaction[]).map(mapTransaction);
},
initialData: sampleTransactions
refetchOnWindowFocus: true,
staleTime: 60_000
});
}
@@ -50,6 +45,9 @@ export function useCreateTransactionMutation() {
},
onSuccess: (transaction) => {
queryClient.setQueryData<Transaction[]>(queryKey, (existing = []) => [transaction, ...existing]);
},
onError: () => {
queryClient.invalidateQueries({ queryKey });
}
});
}

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { computed, reactive, ref } from 'vue';
import { useRouter, useRoute, RouterLink } from 'vue-router';
import { apiClient } from '../../../lib/api/client';
import { useAuthStore } from '../../../stores/auth';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const form = reactive({
@@ -13,24 +14,34 @@ const form = reactive({
});
const errorMessage = ref('');
const successMessage = ref('');
const isLoading = ref(false);
const redirectTarget = computed(() => {
const target = route.query.redirect;
return typeof target === 'string' && target.startsWith('/') ? target : '/';
});
const submit = async () => {
isLoading.value = true;
errorMessage.value = '';
successMessage.value = '';
try {
const { data } = await apiClient.post('/auth/login', form);
authStore.setSession(data.tokens, data.user);
router.push('/');
} catch (error) {
successMessage.value = '登录成功,正在跳转...';
await router.replace(redirectTarget.value);
} catch (error: any) {
console.warn('login failed', error);
errorMessage.value = '登录失败,请检查邮箱或密码。';
const message = error?.response?.data?.message ?? '登录失败,请检查邮箱或密码。';
errorMessage.value = message;
} finally {
isLoading.value = false;
}
};
const enterDemoMode = () => {
errorMessage.value = '';
successMessage.value = '已进入体验模式';
authStore.setSession(
{ accessToken: 'demo-access', refreshToken: 'demo-refresh' },
{
@@ -40,7 +51,7 @@ const enterDemoMode = () => {
preferredCurrency: 'CNY'
}
);
router.push('/');
void router.replace(redirectTarget.value);
};
</script>
@@ -61,6 +72,7 @@ const enterDemoMode = () => {
<input v-model="form.password" type="password" class="px-4 py-3 border border-gray-300 rounded-2xl focus:outline-none focus:ring-2 focus:ring-indigo-500" />
</label>
<p v-if="errorMessage" class="text-sm text-red-500">{{ errorMessage }}</p>
<p v-if="successMessage" class="text-sm text-emerald-500">{{ successMessage }}</p>
<button
class="w-full bg-indigo-500 text-white py-3 rounded-2xl font-semibold hover:bg-indigo-600 disabled:opacity-60"
:disabled="isLoading"

View File

@@ -11,15 +11,23 @@ import { useRouter } from 'vue-router';
const authStore = useAuthStore();
const router = useRouter();
const { data: transactions } = useTransactionsQuery();
const { data: budgets } = useBudgetsQuery();
const transactionsQuery = useTransactionsQuery();
const budgetsQuery = useBudgetsQuery();
const transactions = computed(() => transactionsQuery.data.value ?? []);
const budgets = computed(() => budgetsQuery.data.value ?? []);
const isInitialLoading = computed(() => transactionsQuery.isLoading.value || budgetsQuery.isLoading.value);
const isRefreshing = computed(
() =>
(transactionsQuery.isFetching.value || budgetsQuery.isFetching.value) &&
!isInitialLoading.value
);
const greetingName = computed(() => authStore.profile?.displayName ?? '用户');
const monthlyStats = computed(() => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const monthTransactions = (transactions.value ?? []).filter(
const monthTransactions = transactions.value.filter(
(txn) => new Date(txn.occurredAt) >= startOfMonth
);
@@ -38,7 +46,7 @@ const monthlyStats = computed(() => {
};
});
const activeBudget = computed(() => budgets.value?.[0]);
const activeBudget = computed(() => budgets.value[0]);
const budgetUsagePercentage = computed(() => {
if (!activeBudget.value || !activeBudget.value.amount) return 0;
@@ -70,6 +78,9 @@ const navigateToBudgets = () => router.push('/settings');
</header>
<main class="px-6 pb-24 space-y-8">
<p v-if="isInitialLoading" class="text-xs text-gray-400 text-right">数据加载中...</p>
<p v-else-if="isRefreshing" class="text-xs text-indigo-500 text-right">数据同步中...</p>
<OverviewCard
title="本月总支出"
:amount="`¥ ${monthlyStats.expense.toFixed(2)}`"
@@ -107,7 +118,15 @@ const navigateToBudgets = () => router.push('/settings');
<RouterLink to="/transactions" class="text-sm text-indigo-500">查看全部</RouterLink>
</div>
<div class="space-y-4 mt-4 pb-10">
<TransactionItem v-for="transaction in transactions" :key="transaction.id" :transaction="transaction" />
<div v-if="isInitialLoading" class="text-sm text-gray-400 text-center py-10">
数据加载中...
</div>
<div v-else-if="transactions.length === 0" class="text-sm text-gray-400 text-center py-10">
暂无交易记录前往交易页添加一笔吧
</div>
<template v-else>
<TransactionItem v-for="transaction in transactions" :key="transaction.id" :transaction="transaction" />
</template>
</div>
</section>
</main>

View File

@@ -1,12 +1,18 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { Capacitor, type PluginListenerHandle } from '@capacitor/core';
import { App, type AppState } from '@capacitor/app';
import { useRouter } from 'vue-router';
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';
import { NotificationPermission } from '../../../lib/native/notification-permission';
const authStore = useAuthStore();
const router = useRouter();
const PREFERENCES_STORAGE_KEY = 'ai-bill/preferences';
const preferences = reactive({
notifications: true,
@@ -14,6 +20,25 @@ const preferences = reactive({
experimentalLab: false
});
const isAndroid = Capacitor.getPlatform() === 'android';
const permissionLoading = ref(false);
const notificationListenerGranted = ref(!isAndroid);
const postNotificationGranted = ref(!isAndroid);
let appStateListener: PluginListenerHandle | null = null;
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(PREFERENCES_STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored) as Partial<typeof preferences>;
Object.assign(preferences, parsed);
} catch (error) {
console.warn('Failed to parse stored preferences', error);
localStorage.removeItem(PREFERENCES_STORAGE_KEY);
}
}
}
const { data: budgets } = useBudgetsQuery();
const createBudget = useCreateBudgetMutation();
const deleteBudget = useDeleteBudgetMutation();
@@ -45,6 +70,47 @@ const formattedLastNotification = computed(() => {
const copyFeedback = ref<string | null>(null);
let copyTimeout: ReturnType<typeof setTimeout> | null = null;
const preferencesFeedback = ref<string | null>(null);
let preferenceTimeout: ReturnType<typeof setTimeout> | null = null;
const permissionStatusText = computed(() => {
if (!isAndroid) {
return '原生通知权限已就绪';
}
if (!notificationListenerGranted.value) {
return '尚未授权读取通知,自动记账将受限';
}
if (!postNotificationGranted.value) {
return '系统通知权限关闭,提醒可能无法送达';
}
return '通知权限已开启';
});
const refreshPermissionStatus = async () => {
if (!isAndroid) return;
permissionLoading.value = true;
try {
const status = await NotificationPermission.checkStatus();
notificationListenerGranted.value = Boolean(status.granted);
postNotificationGranted.value = status.postNotificationsGranted !== false;
} catch (error) {
console.warn('Failed to check notification permission', error);
} finally {
permissionLoading.value = false;
}
};
const openNotificationSettings = async () => {
if (!isAndroid) return;
permissionLoading.value = true;
try {
await NotificationPermission.requestAccess();
} catch (error) {
console.warn('Failed to open notification settings', error);
} finally {
permissionLoading.value = false;
}
};
const showCopyFeedback = (message: string) => {
copyFeedback.value = message;
@@ -56,6 +122,32 @@ const showCopyFeedback = (message: string) => {
}, 2000);
};
const showPreferencesFeedback = (message: string) => {
preferencesFeedback.value = message;
if (preferenceTimeout) {
clearTimeout(preferenceTimeout);
}
preferenceTimeout = setTimeout(() => {
preferencesFeedback.value = null;
}, 2000);
};
const requestSystemNotificationPermission = async () => {
if (!isAndroid) return;
permissionLoading.value = true;
try {
const result = await NotificationPermission.requestPostNotifications();
if (result?.granted) {
showPreferencesFeedback('系统通知权限已开启');
}
} catch (error) {
console.warn('Failed to request system notification permission', error);
} finally {
permissionLoading.value = false;
await refreshPermissionStatus();
}
};
const copyText = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
@@ -66,10 +158,42 @@ const copyText = async (text: string, label: string) => {
}
};
watch(
preferences,
(value) => {
if (typeof window !== 'undefined') {
const snapshot = { ...value };
localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(snapshot));
}
showPreferencesFeedback('偏好设置已更新');
},
{ deep: true }
);
onMounted(() => {
if (!isAndroid) {
return;
}
void refreshPermissionStatus();
App.addListener('appStateChange', ({ isActive }: AppState) => {
if (isActive) {
void refreshPermissionStatus();
}
}).then((handle: PluginListenerHandle) => {
appStateListener = handle;
});
});
onBeforeUnmount(() => {
if (copyTimeout) {
clearTimeout(copyTimeout);
}
if (preferenceTimeout) {
clearTimeout(preferenceTimeout);
}
if (appStateListener) {
void appStateListener.remove();
}
});
const resetBudgetForm = () => {
@@ -98,6 +222,7 @@ const removeBudget = async (id: string) => {
const handleLogout = () => {
authStore.clearSession();
router.replace({ name: 'login', query: { redirect: '/' } });
};
</script>
@@ -152,6 +277,50 @@ const handleLogout = () => {
</button>
</div>
<div
v-if="isAndroid"
class="bg-indigo-50 border border-indigo-100 rounded-2xl p-4 space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-indigo-600">{{ permissionStatusText }}</p>
<p class="text-xs text-indigo-500">开启后可自动解析支付宝微信等通知中的账单信息</p>
</div>
<button
class="text-xs text-indigo-600 font-medium disabled:opacity-60"
:disabled="permissionLoading"
@click="refreshPermissionStatus"
>
{{ permissionLoading ? '检测中...' : '重新检测' }}
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
v-if="!notificationListenerGranted"
class="px-3 py-2 text-xs rounded-xl bg-white text-indigo-600 border border-indigo-200 disabled:opacity-60"
:disabled="permissionLoading"
@click="openNotificationSettings"
>
打开系统设置
</button>
<button
v-if="notificationListenerGranted && !postNotificationGranted"
class="px-3 py-2 text-xs rounded-xl bg-indigo-500 text-white disabled:opacity-60"
:disabled="permissionLoading"
@click="requestSystemNotificationPermission"
>
允许系统通知
</button>
<span
v-if="notificationListenerGranted && postNotificationGranted"
class="text-xs text-indigo-500 flex items-center space-x-1"
>
<LucideIcon name="check" :size="14" />
<span>权限已齐备</span>
</span>
</div>
</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">
@@ -242,6 +411,7 @@ const handleLogout = () => {
<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>
<p v-if="preferencesFeedback" class="text-xs text-emerald-500">{{ preferencesFeedback }}</p>
</section>
<section class="bg-white rounded-3xl p-6 space-y-4">

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import LucideIcon from '../../../components/common/LucideIcon.vue';
import TransactionItem from '../../../components/transactions/TransactionItem.vue';
import {
@@ -23,9 +23,10 @@ const form = reactive({
source: 'manual'
});
const { data: transactions } = useTransactionsQuery();
const transactionsQuery = useTransactionsQuery();
const createTransaction = useCreateTransactionMutation();
const deleteTransaction = useDeleteTransactionMutation();
const transactions = computed(() => transactionsQuery.data.value ?? []);
const filters: Array<{ label: string; value: FilterOption }> = [
{ label: '全部', value: 'all' },
@@ -34,12 +35,33 @@ const filters: Array<{ label: string; value: FilterOption }> = [
];
const filteredTransactions = computed(() => {
if (!transactions.value) return [];
if (!transactions.value.length) return [];
if (filter.value === 'all') return transactions.value;
return transactions.value.filter((txn) => txn.type === filter.value);
});
const isSaving = computed(() => createTransaction.isPending.value);
const isInitialLoading = computed(() => transactionsQuery.isLoading.value);
const isRefreshing = computed(() => transactionsQuery.isFetching.value && !transactionsQuery.isLoading.value);
const feedbackMessage = ref('');
let feedbackTimeout: ReturnType<typeof setTimeout> | null = null;
const showFeedback = (message: string) => {
feedbackMessage.value = message;
if (feedbackTimeout) {
clearTimeout(feedbackTimeout);
}
feedbackTimeout = setTimeout(() => {
feedbackMessage.value = '';
}, 2400);
};
onBeforeUnmount(() => {
if (feedbackTimeout) {
clearTimeout(feedbackTimeout);
}
});
const setFilter = (value: FilterOption) => {
filter.value = value;
@@ -67,13 +89,30 @@ const submit = async () => {
notes: form.notes || undefined,
occurredAt: new Date().toISOString()
};
await createTransaction.mutateAsync(payload);
showSheet.value = false;
resetForm();
try {
await createTransaction.mutateAsync(payload);
showFeedback('新增交易已提交');
showSheet.value = false;
resetForm();
} catch (error) {
console.warn('Failed to create transaction', error);
showFeedback('保存失败,请稍后重试');
}
};
const removeTransaction = async (id: string) => {
await deleteTransaction.mutateAsync(id);
try {
await deleteTransaction.mutateAsync(id);
showFeedback('交易已删除');
} catch (error) {
console.warn('Failed to delete transaction', error);
showFeedback('删除失败,请稍后重试');
}
};
const refreshTransactions = async () => {
await transactionsQuery.refetch();
showFeedback('列表已更新');
};
</script>
@@ -84,14 +123,35 @@ const removeTransaction = async (id: string) => {
<p class="text-sm text-gray-500">轻松管理你的收支</p>
<h1 class="text-3xl font-bold text-gray-900">交易记录</h1>
</div>
<button
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-medium hover:bg-indigo-600"
@click="showSheet = true"
>
新增
</button>
<div class="flex items-center space-x-2">
<button
class="px-4 py-2 border border-gray-200 text-sm rounded-xl text-gray-600 hover:bg-gray-50 disabled:opacity-60"
:disabled="isRefreshing"
@click="refreshTransactions"
>
<span v-if="isRefreshing" class="flex items-center space-x-2">
<svg class="w-4 h-4 animate-spin text-indigo-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<span>刷新中</span>
</span>
<span v-else class="flex items-center space-x-2">
<LucideIcon name="refresh-cw" :size="16" />
<span>刷新</span>
</span>
</button>
<button
class="px-4 py-2 bg-indigo-500 text-white rounded-xl font-medium hover:bg-indigo-600"
@click="showSheet = true"
>
新增
</button>
</div>
</header>
<p v-if="feedbackMessage" class="text-xs text-emerald-500 text-right">{{ feedbackMessage }}</p>
<div class="bg-white rounded-2xl p-2 flex space-x-2 border border-gray-100">
<button
v-for="item in filters"
@@ -104,19 +164,33 @@ const removeTransaction = async (id: string) => {
</button>
</div>
<div class="space-y-4">
<div class="space-y-4 min-h-[140px]">
<div v-if="isInitialLoading" class="flex justify-center py-16">
<svg class="w-8 h-8 animate-spin text-indigo-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
<div
v-for="transaction in filteredTransactions"
:key="transaction.id"
class="relative group"
v-else-if="filteredTransactions.length === 0"
class="bg-white border border-dashed border-gray-200 rounded-2xl py-12 text-center text-sm text-gray-400"
>
<TransactionItem :transaction="transaction" />
<button
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500"
@click="removeTransaction(transaction.id)"
暂无交易记录点击新增开始记账
</div>
<div v-else>
<div
v-for="transaction in filteredTransactions"
:key="transaction.id"
class="relative group"
>
<LucideIcon name="trash-2" :size="18" />
</button>
<TransactionItem :transaction="transaction" />
<button
class="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500"
@click="removeTransaction(transaction.id)"
>
<LucideIcon name="trash-2" :size="18" />
</button>
</div>
</div>
</div>

View File

@@ -1,4 +1,6 @@
import axios from 'axios';
import axios, { AxiosHeaders, type AxiosRequestHeaders } from 'axios';
import router from '../../router';
import { useAuthStore } from '../../stores/auth';
const baseURL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:4000/api';
@@ -7,11 +9,33 @@ export const apiClient = axios.create({
timeout: 10_000
});
apiClient.interceptors.request.use((config) => {
const authStore = useAuthStore();
if (authStore.isAuthenticated && authStore.accessToken) {
if (config.headers instanceof AxiosHeaders) {
config.headers.set('Authorization', `Bearer ${authStore.accessToken}`);
} else if (config.headers) {
(config.headers as AxiosRequestHeaders).Authorization = `Bearer ${authStore.accessToken}`;
} else {
config.headers = { Authorization: `Bearer ${authStore.accessToken}` } as AxiosRequestHeaders;
}
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response) {
console.error('API error', error.response.status, error.response.data);
if (error.response.status === 401) {
const authStore = useAuthStore();
authStore.clearSession();
const current = router.currentRoute.value;
if (!current.path.startsWith('/auth')) {
void router.replace({ name: 'login', query: { redirect: current.fullPath } });
}
}
} else {
console.error('Network error', error.message);
}

View File

@@ -0,0 +1,49 @@
import { Capacitor } from '@capacitor/core';
import { registerPlugin } from '@capacitor/core';
interface NotificationPermissionStatus {
granted: boolean;
postNotificationsGranted?: boolean;
}
interface NotificationPermissionPlugin {
checkStatus(): Promise<NotificationPermissionStatus>;
requestAccess(): Promise<{ opened: boolean } | void>;
openSettings(): Promise<{ opened: boolean } | void>;
requestPostNotifications(): Promise<{ granted: boolean }>;
}
const WebFallback: NotificationPermissionPlugin = {
async checkStatus() {
return { granted: true, postNotificationsGranted: true };
},
async requestAccess() {
return { opened: false };
},
async openSettings() {
return { opened: false };
},
async requestPostNotifications() {
return { granted: true };
}
};
export const NotificationPermission = registerPlugin<NotificationPermissionPlugin>('NotificationPermissions', {
web: WebFallback
});
export async function ensureNotificationPermissions() {
if (!Capacitor.isNativePlatform()) {
return { granted: true, postNotificationsGranted: true } satisfies NotificationPermissionStatus;
}
const status = await NotificationPermission.checkStatus();
if (!status.postNotificationsGranted) {
try {
await NotificationPermission.requestPostNotifications();
} catch (error) {
console.warn('requestPostNotifications failed', error);
}
}
return NotificationPermission.checkStatus();
}

View File

@@ -4,6 +4,7 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import App from './App.vue';
import router from './router';
import './assets/main.css';
import { useAuthStore } from './stores/auth';
const app = createApp(App);
const pinia = createPinia();
@@ -16,8 +17,13 @@ const queryClient = new QueryClient({
}
});
const authStore = useAuthStore(pinia);
authStore.initialize();
app.use(pinia);
app.use(router);
app.use(VueQueryPlugin, { queryClient });
app.mount('#app');
router.isReady().finally(() => {
app.mount('#app');
});

View File

@@ -1,29 +1,30 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '../stores/auth';
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'dashboard',
component: () => import('../features/dashboard/pages/DashboardPage.vue'),
meta: { title: '仪表盘' }
meta: { title: '仪表盘', requiresAuth: true }
},
{
path: '/transactions',
name: 'transactions',
component: () => import('../features/transactions/pages/TransactionsPage.vue'),
meta: { title: '交易记录' }
meta: { title: '交易记录', requiresAuth: true }
},
{
path: '/analysis',
name: 'analysis',
component: () => import('../features/analysis/pages/AnalysisPage.vue'),
meta: { title: 'AI 智能分析' }
meta: { title: 'AI 智能分析', requiresAuth: true }
},
{
path: '/settings',
name: 'settings',
component: () => import('../features/settings/pages/SettingsPage.vue'),
meta: { title: '设置' }
meta: { title: '设置', requiresAuth: true }
},
{
path: '/auth',
@@ -56,6 +57,22 @@ const router = createRouter({
routes
});
router.beforeEach((to, _from, next) => {
const authStore = useAuthStore();
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return next({
name: 'login',
query: to.fullPath === '/' ? undefined : { redirect: to.fullPath }
});
}
if (authStore.isAuthenticated && to.name && ['login', 'register', 'forgot-password'].includes(to.name.toString())) {
return next({ name: 'dashboard' });
}
return next();
});
router.afterEach((to) => {
if (to.meta.title) {
document.title = `AI 记账 · ${to.meta.title}`;

View File

@@ -1,5 +1,7 @@
import { defineStore } from 'pinia';
const STORAGE_KEY = 'ai-bill/auth-session';
type AuthStatus = 'authenticated' | 'guest';
interface UserProfile {
@@ -30,12 +32,47 @@ export const useAuthStore = defineStore('auth', {
this.accessToken = tokens.accessToken;
this.refreshToken = tokens.refreshToken;
this.profile = profile;
if (typeof window !== 'undefined') {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
status: 'authenticated',
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
profile
})
);
}
},
clearSession() {
this.status = 'guest';
this.accessToken = undefined;
this.refreshToken = undefined;
this.profile = undefined;
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
},
initialize() {
if (typeof window === 'undefined') {
return;
}
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return;
}
try {
const stored = JSON.parse(raw) as { status: AuthStatus; accessToken: string; refreshToken: string; profile: UserProfile };
if (stored.status === 'authenticated' && stored.accessToken && stored.refreshToken && stored.profile) {
this.status = stored.status;
this.accessToken = stored.accessToken;
this.refreshToken = stored.refreshToken;
this.profile = stored.profile;
}
} catch (error) {
console.warn('Failed to restore auth session', error);
localStorage.removeItem(STORAGE_KEY);
}
}
}
});